Skip to content

fix(query): align membership, concat, and grouped fallback semantics - #744

Merged
singaraiona merged 61 commits into
devfrom
fix/query-membership-followups
Oct 10, 2026
Merged

singaraiona merged 61 commits into
devfrom
fix/query-membership-followups

Conversation

@singaraiona

@singaraiona singaraiona commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

What & why

Fix query membership and concat results that differed between planned execution and evaluator fallback. Inline typed membership probes use the null-aware kernel for numeric, temporal and SYM columns. Fallback covers empty inputs, partition keys, GUID nulls and nested not-in; mixed LIST probes retain general membership.

Collection concat keeps ordinary collection semantics for literal and bound vectors, in either operand order. Column-derived text operands retain text concat through casts, uncompiled operations and helper calls; a reordered, indexed or cast column is a whole column in projections, so select agrees with update, where and by. A STR column's cell keeps its type row by row, and a null cell is the empty text the vector kernel reads.

Helpers called from a query fallback run as bytecode. Row provenance is a flag beside every VM stack slot and on every scope-frame binding: parameters, let locals, a scoped window's locals, a closure's captures and a helper's on-demand source columns carry it; builtins the planner lowers row by row keep it, collection verbs clear it, compiled calls pass it to their callee and return it with the result, and concat consults it. The flag is tri-state and relative to the innermost query. A call frame marks the lexical boundary: the query's aliases and locals are hidden, a free source-column name is the query's binding for it (the row's cell, the group's rows or the column), a literal symbol is the symbol in a helper called by name, and other free names are globals. Lambdas written inline in the query are query text, stay on the tree walker and keep the query's column rule.

Closures: a fn literal inside a compiled body is compiled once, as a template, when its enclosing function compiles, and every closure made from it shares that bytecode (OP_MKCLOSURE) and captures only the enclosing locals its body mentions, bound by name into its own slots (OP_BINDCAPS). A let of a captured name rebinds the slot for that call, a parameter shadows its capture, a local named like a builtin is the callee, self is the closure itself, an empty let slot or an unbound capture reads as the global or a name error (OP_LOADENVG), eval and resolve run inside a scope window over the locals that wraps only the call, a closure body resolves the names it does not capture when it runs (a top-level definition keeps its builtins baked, and its shared template is recompiled once a function-valued global is rebound), a lazy value is materialized when captured, capture is lexical (a caller's locals are never captured, a change from dev's dynamic capture for interpreted makers), and return ends a lambda on the tree walker as it does compiled. Closures made by interpreted code share a template per literal and capture set through a per-thread table keyed on every node of the literal. Closures are never inlined into a query.

Helpers built from effect-free builtins are inlined by the planner and the fallback. An opaque helper can be the by: key: a pure body (callees followed through globals, parameters, lets and captures; a computed or unknown callee, set, eval and system or I/O builtins are effects) is evaluated whole over the rows where: keeps and again row by row when a conditional tested a vector or the whole run failed; a body with an effect runs row by row from the start. A grouped key column keeps its "may have nulls" flag only when an emitted key is null, so fused and materialized results agree on the wire.

Grouped fallback reserves all aggregate slots, keeps the requested column order (an output named like its key keeps both columns) and uses the same kept-group prefix for keys and outputs. Constant collection COUNT counts the collection. Filtered COUNT handles computed inputs. Grouped updates prepare pure helper expressions and reuse dependency analysis across groups. Nested-select composition preserves materialization when an outer sort names an inner computed or renamed column. Index-attachment diagnostics and query-scope documentation are corrected.

Includes #758's head (3a8ff52): fixed row chunks are folded in chunk order, independent of worker scheduling, with accumulator blocks padded to 128-byte line pairs. The fuzz oracle retains exact null-flag comparison and its original bounded aggregate tolerance. Pool tests drain pending read-ahead and guarantee worker participation.

Behaviour changes from dev, each documented: grouped fallback outputs follow the requested order; reordered, indexed or cast concat operands are whole columns in projections; quoted symbols are literals inside named helper bodies; inside any function concat keeps collection semantics unless an operand derives from the query's rows.

Validation

  • Full local ASan/UBSan suite: 4,136/4,136 passed. All ten CI checks pass on b21830b6, including the targeted audit, the fuzz smoke and cppcheck; TSan clean.
  • Regression tests: test/rfl/query/concat_*.rfl, membership_*.rfl, helper_bytecode_provenance.rfl, concat_helper_consistency.rfl, group_key_null_flag.rfl, lang/closure_locals.rfl, mem/closure_flags_release.rfl, nested_sort_computed.rfl, keyless_pool_accum.rfl, lang/query_helper_reinit (runtime re-init), lang/closure_malformed_capture, lang/closure_lazy_capture_vm and store/serde_lambda_malformed_closure. Reverting each hunk of the helper rounds in turn fails at least one test.
  • The caches keyed on symbol ids and builtin objects are rebuilt per symbol-table epoch.

Release medians against dev 57f5cfe3, eight workers, 2M rows unless noted:

Case dev Current
Per-row scalar aggregate over a helper, (sum (hn (+ x 1))) 578 ms 612 ms
Per-row update with if and a helper 721 ms 788 ms
Per-row text helper beside (reverse x) (dev computes a collection) 510 ms 615 ms
1000 rows × fib 15 per row 165 ms 200 ms
fib 24 outside queries, one core 11.0 ms 11.2 ms
200k closures made and called once, one core (dev fails with a name error) — 70 ms
(times 1000000 (cadd 2)), closure call, one core 183 ms 142 ms
SYM / I64 inline membership probe, 20M rows parity with the planned probe
Planning, plain grouped fallback, 10 rows 5.2 µs/query 5.4 µs/query

Scripts: bench/concat_fallback.rfl, bench/concat_update_fallback.rfl, bench/membership_fallback.rfl.

Checklist

  • Targets dev
  • Conventional commits
  • Debug and release builds pass
  • Tests and regressions validated

@singaraiona singaraiona left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reviewed 2ab29f1 against the four follow-up issues from #742. Constant collection concatenation and numeric whole-column fallback are routed separately from row-wise text concatenation, evaluator membership applies the query null-row rule, and the index diagnostic names the supported types. Under ASan/UBSan, all 3 membership-filtered tests, all 28 concat-filtered tests, and the zone_sym_diagnostic test pass. Additional probes covered filtered/computed numeric concatenation, scalar aggregation, mixed-type constant collections, and fallback membership projections. No new actionable findings. Grouped aggregation over numeric concat remains unsupported, but it also failed before this PR.

@ser-vasilich

Copy link
Copy Markdown
Collaborator

Reviewed. The four findings from #742 are fixed. For SYM, integer, F64 and temporal columns with nulls, the planned and evaluator in/not-in now agree in all of these places, on in-memory, splayed and parted tables:

  • where, count, projections, if, lambdas, aggregates;
  • grouped where, update where, delete where.

Outside queries, in keeps null==null and not-in matches (not (in ...)) on vectors. (concat [2] [1]) folds to [2 1] and works as a probe. The idx diagnostics are corrected.

Before merging:

  1. Evaluator in got 6–9x slower (collection.c ~1642, eval_membership).

    • In query scope every BOOL result gets a serial ray_vec_is_null() pass over all rows. SYM has no HAS_NULLS gate, so every row is read.
    • Release, 20M rows, -c 8, PR against the PR with ray_in_eval_fn short-circuited to ray_in_fn:
      • where: (in s (as 'SYM (list "1" "5" "7"))), the inline-constructor shape the new tests use: 15.0 → 129 ms;
      • where: (in x (distinct [1 5 7])): 15.9 → 95.9 ms.
    • The planned path and eager in are unchanged.
    • Suggestion: in query scope call ray_in_vec_exec(val, vec, negate) first. It already has the plan's semantics (null rows false, null set elements skipped) and runs in parallel. Keep the masked path only for the shapes where it returns NULL (STR, GUID, LIST), or at least skip the mask for in when the set has no nulls.
  2. Folding can produce a LIST constant that the planned in reads as an empty probe (query.c ~1649).

    • (select {from: T where: (in s (concat ['a] [1]))}) with s = ['a 'b 'c 'd] returns []; dev and eager return [0].
    • (select {v: (in s (concat 'a 1)) from: T}) gives [false false false] against [true false true] on dev.
    • Suggestion: don't fold when the result is a LIST.
  3. Text concat shapes changed, which the description says stay on the string path. dev's outputs were wrong too, but the change is worth stating:

    • (select {v: (concat ['a 'b] "!") from: T2}) was ['a! 'b!] and is now a type error. The same happens for (concat L "!") with a global L = ['a 'b] and for (concat "!" ['a 'b]).
    • (concat ['a 'b] '!) gives 3 rows from a 2-row table.
    • (concat s x) (a SYM column with a number column) was ['a 'b 'c] and is now a LIST of length 2n.
    • (concat s x y) is an arity error.
  4. not-in is not the complement of in for LIST results, because ray_not_fn of a LIST returns a scalar.

    • (not-in ["ab" "c"] "a") gives false, while in gives ([true false] [false]).
    • In a query, where: (in ss (list "" "a")) errors with "got LIST", but where: (not-in ss (list "" "a")) silently returns [].
  5. The new doc paragraph promises agreement that still fails in a few places. These are older than this PR:

    • GUID values of all zeros without HAS_NULLS: the plan treats them as null (exec.c ~1276), the evaluator doesn't. Planned where: (in g Pg) gives [0]; evaluator where: (in g (distinct Pg)) gives [0 1].
    • A parted partition-key column (MAPCOMMON) on the evaluator path: where: (in part (distinct [1])) gives [], against [1 0N 2] planned. eval_membership flattens only RAY_IS_PARTED.
    • (in s (concat 'a 'b)): the plan probes the string ab and returns []; eager returns [true true false].
    • An empty table on the evaluator path: in errors with "got LIST". The empty-table test covers only not-in, which passes because (not ()) is a scalar.
    • STR columns: the plan gives "nyi"; the evaluator does per-character membership.
  6. (select {n: (count (concat x [4])) by: k from: T}) now fails with "failed to compile aggregation argument". dev was silently wrong ([2 1]); the correct answer is [3 2]. The per-group routing used for distinct was not extended to concat.

  7. A folded constant of the wrong length truncates or pads the other columns. This is older but now easier to reach: (select {v: (concat 1 2) w: x from: T}) on a 3-row T returns 2 rows with w = [1 2]. concat_collections.rfl:3 asserts that 2-row result.

Nits:

  • query.c ~1651 sets g->compile_err without the if (!g->compile_err) guard used elsewhere.
  • The null rule follows dynamic scope: a function evaluates (in 0N [0N 5]) differently at top level and inside a select. The doc could say "evaluated during a query".
  • The diagnostics still print the type code ("got type 12") rather than the name.

Tests: disabling each new guard makes a new test fail. There are no evaluator-path cases for GUID, STR, F64, temporal or MAPCOMMON columns, for in on an empty table, or for the shapes in item 3.

A run of the 43-query workload on 100M rows with this PR together with #741 and #743 on top of dev matches dev's timings and outputs: those queries all take the planned path.

@singaraiona

Copy link
Copy Markdown
Collaborator Author

Fixed in 74fca72. Thanks for the concrete repros; these exposed gaps in my earlier review:

  • Evaluator membership tries the existing typed kernel first, avoiding the serial null-mask pass for supported numeric, temporal, and SYM inputs. LIST, STR, and GUID shapes keep their general path.
  • Mixed LIST concat results are no longer folded into typed membership probes.
  • Text vector/scalar concat stays on the string path, in either operand order. Numeric/mixed collection concat remains binary and uses collection semantics; the documentation now states this distinction.
  • not-in recursively complements nested results instead of collapsing a LIST to one Boolean. Both in and not-in now reject a nested WHERE result consistently.
  • Empty evaluator predicates are BOOL vectors; MAPCOMMON operands are materialized; GUID zero payloads are masked even without HAS_NULLS. Tests cover these plus F64 and temporal inputs. Documentation now limits the agreement claim and explains dynamic query scope and STR limitations.
  • Grouped collection-concat aggregates evaluate within each group; grouped text concat keeps its existing query kernel. Both paths have regressions.
  • Planned projections validate equal column lengths after scalar broadcasting, preventing silent truncation or padding beside a folded collection.

The existing query-specific concatenation of two scalar symbols remains string concatenation; it is distinct from eager collection concat. STR membership retains its existing collection behavior and typed-plan limitation. The compile-error ownership guard is also fixed.

Validation: full make test -j8 TEST_CORES=8 under ASan/UBSan: 4,080/4,080 passed, 0 skipped, 0 failed. Release build passed.

Added bench/membership_fallback.rfl: 20M rows, -c 8, median of six runs after one warmup, previous PR head 2ab29f1e versus this commit:

Query shape Before After
SYM inline probe 72.32 ms 39.36 ms
I64 inline probe 67.27 ms 39.64 ms
SYM planned probe 36.01 ms 37.46 ms
I64 planned probe 35.84 ms 37.79 ms

The evaluator now runs close to the planned path on this benchmark. These are local measurements, not the same machine/results as the review's baseline. The optional diagnostic type-name polish remains unchanged.

@ser-vasilich

Copy link
Copy Markdown
Collaborator

Rechecked 74fca72.

Fixed:

  • Evaluator in/not-in speed. Release, 20M rows, -c 8, the evaluator is now within 1.0–1.1x of the planned path across 9 cases: SYM and I64 in/not-in with and without nulls, plus GUID.
  • The LIST-probe fold: where: (in s (concat ['a] [1])) returns [0].
  • The text concat shapes: symbol vector with a text atom, either order, now gives 2 rows.
  • The nested not-in complement. WHERE rejects both in and not-in on a LIST.
  • MAPCOMMON and empty-table WHERE.
  • Grouped collection-concat aggregates: count/sum/avg/min/max/first/last/med/count-distinct, multi-key, with where:.
  • The compile_err guard.

The typed-kernel-first path matches the masked path in 49 column × probe combinations, byte-identical to 2ab29f1:

  • literal SYM, W8/W16/W32 splayed, I64/I32/I16/U8/BOOL, F64 with -0.0, DATE/TIME/TIMESTAMP, GUID;
  • atom, empty, null-bearing and cross-type probes;
  • parted SYM/I64/F64/DATE/GUID and the MAPCOMMON column.

Each of the five new guards fails a test when disabled. About 140 projection shapes behave as before except the ones listed below. make test: 4080/4080.

New regression: grouped arithmetic over two or more aggregates, one of them over a text concat, now errors.

  • With T x [1 2 3 4 5], k [0 0 1 2 2], s ['a 'b 'c 'd 'e]:
    • (select {n: (+ (count (concat s "!")) (count (concat s "?"))) by: k from: T}) → "type: concat: cannot concatenate SYM and str". 2ab29f1 and 2.6.2 give [4 2 4].
    • (+ (sum x) (count (concat s "?"))) by k (was [5 4 11]) and (- (max x) (count (concat s "!"))) (was [0 2 3]) fail the same way.
    • With a single hidden slot it works, but only because of the hidden_max < 1 → 1 floor.
  • Cause:
    • count_agg_subexprs (query.c ~3151) calls is_group_dag_agg_expr(expr, NULL), and expr_has_collection_concat(.., NULL) returns true (~2228). So text-concat aggregates are not counted and hidden_max (~10153) undercounts.
    • The routing dry-run (is_decomposable_agg_compound, with tbl) says decomposable, but the rewrite (~12324, cap = hidden_max) bails.
    • The output falls back to per-group eval, where concat is eager and SYM vector + string is a type error.
  • Fix: pass tbl to count_agg_subexprs, or have the NULL case count the aggregate; an upper bound must over-count.

Risks:

  • The same NULL-tbl gate is used in match_group_count_emit_filter (~2875) and match_group_desc_count_take (~2956). Their agg_index skips text-concat aggregates while the planner includes them, and agg_engine only checks the op (~6111, ~6155). So with two aggregates of the same op the top-N filter could target the wrong one. I could not produce a wrong result: two SUMs with 3 and 50k groups, one and two keys.

  • The new length validation (exec.c ~3783) broadcasts scalars to the input row count rather than the common column length, and runs before take:. Some queries that worked now error (T has 3 rows):

    query 2ab29f1 now
    (select {a: [1 2] b: 5 from: T}) [1 2] / [5 5] "ragged columns (column 1 has 3 rows, expected 2)"
    (select {a: (concat 1 2) b: 5 from: T}) [1 2] / [5 5] same error
    (select {b: x a: [1 2] from: T take: 2}) [1 2] / [1 2] error

    The other outputs that changed were truncated or NA-padded before and are now correct errors.

  • GUID zero masking. The test comment says only "older files" lack HAS_NULLS, but current producers do too: .csv.read [.. GUID] and (as 'GUID (list .. "0000...")) write attrs 0. On such a column:

    • vector nil? is all false;
    • by: g keeps a separate group;
    • eager in matches zero to zero;
    • atom nil? is true.

    Query in/not-in now drop the row as null. That is consistent with the planned path and atom nil?, but not with vector nil? or grouping. The root fix belongs in the producers or in ray_vec_is_null for GUID.

Nits:

  • The error class differs by path: planned is domain ("ragged columns"), the evaluator is length ("output column length 3 does not match 4"). The tests pin domain.
  • Trying the typed kernel first bypasses an index attached to the probe set: ray_in_fn consulted it, and now an O(set) hash is built per call. This matches the planned exec_in.
  • membership_fallback_storage.rfl: both partitions hold identical data, so in part [1] and not-in part [1] both assert [1 0Nl 2], and a swapped in/not-in would still pass.
  • An empty-table projection {v: (in x (distinct [1]))} still gives a LIST-typed column, where the planned path gives B8. This predates the PR.

Unrelated to this PR, but worth an issue:

  • A projection with an inline probe, (select {v: (in s (as 'SYM (list ..))) from: T}), evaluates the probe constructor once per row. A counting lambda fires 1000 times on 1000 rows on 2ab29f1, 74fca72 and 2.6.2 alike. At 20M rows that is 19.6 s against 3.7 ms planned.
  • (in (list 1 (list 2 3)) [1 3]) gives [true true], but (in (list (list 2 3) 1) [1 3]) gives ([false true] true): ray_in_fn reads r->b8 from a vector result (collection.c ~1535). The new nested not-in inherits this.

@singaraiona

Copy link
Copy Markdown
Collaborator Author

Fixed the grouped-arithmetic regression and scalar broadcasting in ad2cb03.

  • count_agg_subexprs now receives the source table, matching the decomposition/rewrite decision. All three reported compound text-concat aggregate examples pass, plus a filtered-source case.
  • The top-N matcher also receives the table, so it counts the same aggregate slots as the planner. The outer count-filter matcher runs before its child source is resolved; it now declines the optimization when concat is present. Added top-N checks with two SUM aggregates whose rankings differ, and a nested count-filter check.
  • Scalars broadcast to the projected collection's length, independent of column order. Both {a: [1 2] b: 5} and {a: (concat 1 2) b: 5} now produce two rows. Unequal non-scalar lengths still error before take:. That boundary is intentional: take: limits a valid table; it does not repair ragged projections. The docs and a regression now state that explicitly.
  • Partition fixtures now contain distinct values, so swapped in/not-in partition results fail. Corrected the GUID test comment to include current producers missing HAS_NULLS.

The broader GUID producer/null-detection inconsistency, repeated inline-probe evaluation in projections, empty projection typing, and mixed scalar/nested LIST membership remain outside this fix. The planned domain versus evaluator length diagnostic difference and probe-set index bypass also remain as noted.

Validation: make test -j8 TEST_CORES=8 under ASan/UBSan: 4,080/4,080 passed, 0 skipped, 0 failed. Release build passed.

@ser-vasilich

Copy link
Copy Markdown
Collaborator

Rechecked ad2cb03 against 74fca72 and 2ab29f1.

Fixed:

  • The three grouped-arithmetic queries over text-concat aggregates again give [4 2 4], [5 4 11] and [0 2 3]. The filtered-source case gives [2 2 4].
  • Scalar broadcasting. {a: [1 2] b: 5} and {a: (concat 1 2) b: 5} give two rows in either column order.
  • Passing the table to the top-N matcher fixes a wrong result that 74fca72 had with sparse keys:
    (set V (as 'SYM (list "a" "bb" "ccc" "dddd" "eeeee" "ffffff" "g")))
    (set TN (table [k x y s] (list (* 1000003 (% (* (til 20000) 7919) 2000)) (% (* (til 20000) 104729) 100003) (% (* (til 20000) 15485863) 99991) (at V (% (* (til 20000) 31) 7)))))
    (at (select {a: (max (strlen (concat s "!"))) c: (max y) by: k from: TN desc: c take: 3}) 'c)
    
    74fca72 gives [99658 97257 89965]. ad2cb03 and a full sort give [99985 99979 99973]. A multi-key [k j] SUM case at 1M rows was also wrong on 74fca72 and is right now.
  • The partition fixtures now assert disjoint values for in and not-in.

Tests. I reverted each hunk in turn and ran concat_review.rfl:

  • count_agg_subexprs with NULL again: lines 25–28 fail.
  • The exec.c broadcast-length detection: lines 30–32 fail.
  • Top-N matcher with NULL again: the file still passes, because the 3-group fixtures use dense keys, which never reach that path. The sparse repro above fails at the suite's 2 cores and could go in as the test.
  • if (has_collection) has_full = true;: the file still passes, because the tests use the literal 5, which all_const already covers. Without the line, (select {a: (concat 1 2) b: (sum x) from: T}) breaks, and so does a lambda that returns a scalar. A test could be (at (select {a: (concat 1 2) b: (sum x) from: T}) 'b) -- [6 6].
  • The count-filter decline on concat: with the guard removed I found no wrong result. The shifted index lands either on another COUNT slot with the same per-group value or on a non-COUNT slot that the op check rejects. The guard is harmless.

Behaviour changes from the exec.c hunk (T has 3 rows, O has 1 row):

  • A one-row table broadcasts a real column. (select {a: [1 2] b: x from: O}) gives b [7 7], while the same query on 2 or more rows is a ragged error. The result now depends on the row count. 2ab29f1 gave [7 NA] and 74fca72 an error.
  • A collection with where: now gives an internal-looking error. (select {a: [1 2] b: 5 from: T where: (> x 1)}) returns "domain: sel_compact: selection nrows mismatch (sel=3 tbl=2)". 74fca72 said "ragged columns (column 1 has 3 rows, expected 2)". {a: (concat 1 2) b: (sum x) where: …} behaves the same way.
  • The evaluator fallback still broadcasts to the input row count. {a: (concat [1] [2]) b: (enlist 7)} fails with "length: output column length 3 does not match 2". This is the same on 2ab29f1 and 74fca72.

Performance. Release build, 20M rows, -c 8:

  • No regression in any of these: count-filter and top-N with and without text concat, multi-key grouping with where:, projection, and SYM/I64/GUID in/not-in.
  • On a 10-row table, top-N with text concat takes 13.6–14.2 µs per query against 10.3–11.5. The extra time appears only when the matcher fires, which it now does on the right slot.

make test (ASan/UBSan): 4080/4080.

Unrelated to this PR (also on dev 57f5cfe and v2.11.0):

  • SIGSEGV.
    (set A (table [x k s] (list [1 2 3 4 5] [0 0 1 2 2] ['a 'b 'c 'd 'e])))
    (select {n: (count (upper s)) by: k from: A where: (> x 1)})
    
    This dies with "fatal SIGSEGV at fault addr 0x18". UBSan points at find_ext(...)->sym on a computed aggregate input in agg_engine.c (~6978).
  • nyi. (select {n: (count (concat s "!")) by: k from: A where: (> x 1)}) returns nyi. The new test uses a nested filtered source instead of where:.
  • Mixed aggregate shapes still evaluate text concat eagerly per group and fail with the SYM + str type error: (+ (count (concat s "!")) (count (concat x [9]))) and (+ (count (concat s "!")) (count (distinct x))).

@singaraiona singaraiona changed the title fix(query): align collection membership across evaluation paths fix(query): align membership, concat, and grouped fallback semantics Oct 8, 2026
@singaraiona

Copy link
Copy Markdown
Collaborator Author

Fixed in 896af4d, including the related older failures reported in this review.

  • Added the 20K-row sparse-key MAX/top-N repro. Reverting the matcher to NULL makes it fail with [99658 97257 89965] instead of [99985 99979 99973] at two workers.
  • Added (sum x) beside a collection in both output orders. Removing if (has_collection) has_full = true; now fails that test.
  • A real one-row input column, including (+ x 0), no longer broadcasts to a longer collection. It gets the same ragged-column error as larger inputs; scalar reductions still broadcast.
  • A collection incompatible with a deferred WHERE selection now reports length: select: collection projection has 2 rows; where requires 3 input-aligned rows before sel_compact. This alignment requirement is documented and tested.
  • Fixed the computed COUNT crash and concat COUNT nyi with direct where:. The filtered group compactor now skips COUNT inputs, which do not need stored columns. The direct compound text-count query works too.
  • Mixed text/collection and text/distinct aggregate expressions now work, including direct WHERE and empty selections. The evaluator uses the same text-vector/scalar concat kernel as the planner; the collection primitive keeps its eager behavior outside query scope.
  • Evaluator projections defer scalar broadcasting until collection lengths are known. The (enlist 7) examples work in both output orders and on empty sources. Scalar-only projections retain their source row count. distinct beside a scalar aggregate follows the same rule; updated the old error assertion and retained coverage for mismatched non-scalar columns.

Validation: 4,082/4,082 passed, 0 skipped, 0 failed (ASan/UBSan, eight workers), release build passed. The sparse top-N and aggregate-broadcast regressions were also checked by temporarily disabling their fixes, then restoring them before the final run.

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Rayforce targeted audit passed

The required Rayforce audit gate passed on the latest run.

Workflow run: https://github.com/RayforceDB/rayforce/actions/runs/38075476747

@singaraiona

Copy link
Copy Markdown
Collaborator Author

Fixed the audit's blocking enlist regression in f515b5f.

The whole-column special case is now limited to a single known scalar. Column references (including quoted column names), vector constants/bindings, and multi-argument enlist remain per-row constructors. Added value assertions for one- and two-column constructors, a constructor beside a passthrough column, count (enlist x), and constant/vector/scalar bindings. The (enlist 7) collection-broadcast cases still pass.

The alias scratch capacity is nc_max, computed from every output plus possible hidden sort keys; the scalar-flag loop writes one entry per output, within that capacity.

Validation: 4,114/4,114 passed, 0 skipped, 0 failed (ASan/UBSan, two workers, after syncing current dev in 37a6816); release build passed.

The earlier CI pool failure did not reproduce locally: its focused case passed 20/20 repeated runs and the full merged-base suite. The new required audit run has passed.

@ser-vasilich

Copy link
Copy Markdown
Collaborator

Rechecked 896af4d and 37a6816.

Fixed:

  • The sparse top-N test, (sum x) beside a collection, and one-row tables no longer broadcasting a real column all hold.
  • A collection with a deferred where: now gives the length: error, also on parted tables.
  • The computed COUNT crash and concat COUNT nyi with direct where:. (count (upper s)) by: k where: gives [1 1 2]. The parted variant, which was a SIGSEGV on dev, works too.
  • Skipping COUNT inputs in the compactor is safe. Direct where: matched a pre-filtered source in 14 shapes at 3k rows (-c 2) and 20M rows (-c 8). The shapes include nulls, computed inputs, count distinct, F64 keys, 2 keys, groups filtered to empty and no rows kept.
  • f515b5f fixes the enlist regression in 896af4d. Column, quoted-name, vector, multi-argument, lambda-vector and global-vector enlist match 2ab29f1 across by:, where:, take:, empty and one-row tables.

BUG, still on 37a6816: inside any query, concat of a SYM/STR vector with a text atom now joins row by row. ray_concat_eval_fn takes the text kernel whenever a query scope is anywhere on the scope stack. That includes user lambdas, globals and literals, not only columns, so the same function returns different values inside a select than outside it:

(set T (table [x k s] (list [1 2 3 4 5] [0 0 1 1 2] ['a 'b 'c 'd 'e])))
(set allowed (fn [] (concat ['a 'b] 'c)))
(allowed)                                                  ;; [a b c] on both
(at (select {from: T where: (in s (allowed))}) 's)         ;; dev [a b c], 37a68163 []
(at (select {from: T where: (> (count (allowed)) 2)}) 's)  ;; dev [a b c d e], 37a68163 []
(at (update {n: (count (allowed)) from: T}) 'n)            ;; dev [3 3 3 3 3], 37a68163 [2 2 2 2 2]
(set L ['a 'b])
(at (select {n: (+ (count (concat L 'c)) (count (distinct x))) by: k from: T asc: k}) 'n)  ;; dev [5 5 4], 37a68163 [4 4 3]

The planner already does this on dev for literal vectors: (select {from: T where: (in s (concat ['a 'b] 'c))}) gives [] on dev and [a b c] on 2ab29f1. This PR extends it to lambdas, globals and per-row evaluation. Row-wise text concat probably needs to apply only when the vector argument is a column of the source (or derived from one), not whenever a query scope is active.

Tests. I reverted each hunk of 896af4d in turn. Every one makes a test fail except two:

  • The atom-first branch (concat "x" s) in string.c: disabling it fails nothing (575/575 .rfl).
  • The sparse top-N repro: only concat_sparse_topn.rfl catches a NULL matcher table. The copy at the end of concat_related.rfl passes with it.

Behaviour changes, intended as far as I can tell:

  • A single scalar enlist ((enlist 7), a global or lambda-local atom, a string, 0Nl) now gives a typed broadcast column instead of a LIST of 1-element vectors. {e: (enlist 7) a: (distinct k)} now works. The type change is visible to users.
  • Minor: {a: (distinct k) m: (max x) c: (+ m a)} now reports name: 'a' undefined instead of a length error.

Performance. Release build, 20M rows, -c 8, interleaved A/B:

  • No regression on a four-aggregate by: with count distinct and desc: c take: 10, or on a two-key top-N. The top-N matcher fires on every head.
  • No regression on membership, grouped aggregates, or count of text concat with and without where:.
  • A 20M-row text-concat projection is 2–7% slower than dev (1297–1335 → 1341–1442 ms). The per-row loop is unchanged, so this may be code layout.

make test (ASan/UBSan): 896af4d 4082/4082, 37a6816 4114/4114. No sanitizer reports on the corpus or the probes.

@singaraiona

Copy link
Copy Markdown
Collaborator Author

Fixed in a1d3c2a.

concat is registered as the ordinary builtin again. The query evaluator selects the text kernel from the argument expression's column dependency, rather than the presence of any query scope. Literal/global collection operands keep their ordinary semantics. The planner now does the same for constant collections and declines lambda inlining when it would turn the function's concat into a query text operation. Existing scalar query text concat is preserved.

Your repros now return [a b c] for membership, all five rows for the count predicate, [3 3 3 3 3] for update, and [5 5 4] for the grouped global-vector case. concat_scope.rfl covers these, both operand orders, lexical shadowing, computed columns, function parameters, and the two-text-column fallback. It also checks nested variadic concat with the source column in the third argument.

Added forced per-group fallback cases for (concat "!" s) and its strlen result. Disabling atom-first text dispatch makes concat_scope.rfl fail at that case; restoring it passes. The sparse top-N regression remains in concat_sparse_topn.rfl.

Validation: make test -j8 TEST_CORES=2 4,115/4,115 passed, no skips or failures, under ASan/UBSan. Release build passed. Updated the docs and PR description to describe collection versus column text operands.

@singaraiona

Copy link
Copy Markdown
Collaborator Author

Follow-up on CI: synced #754 from current dev in 267d427, then addressed the two failing checks in separate commits.

  • 167324d — read-ahead test reset. Linux debug failed at the cache reset (evict). The reset touched only pages reported resident, but Linux mincore reports a file page as present only once it is uptodate. That can skip an in-flight read just before eviction. The reset now faults every page before unmapping and evicting. Existing read-ahead assertions remain in place. Kernel implementation.
  • d6970a7 — nested-select differential oracle. The captured seed uses 131,073 repeated rows of [DBL_MAX, -DBL_MAX, 3.2] and sums p + 1.3. Composition rejects the offset, and repeating the reference query itself with four workers returns both 0 and 0Nf. The fuzzer now tolerates differences only in F64 aggregate values/null flags when every layer declines composition and execution is parallel. Any composed layer keeps the strict existing oracle; other columns, schema and errors still compare exactly. Added the CI input as a committed seed.

Validation on the integrated branch: 4,119/4,119 ASan/UBSan tests passed, zero skips/failures; release build passed; all three read-ahead cases passed 30 repeated runs; captured seed passed 100 four-worker replays. Fresh 60-second differential fuzz campaigns passed in serial (1,830 inputs) and four-worker mode (801 inputs).

@ser-vasilich

Copy link
Copy Markdown
Collaborator

Rechecked a1d3c2a. In src/ it is identical to d6970a7. Our earlier repros now match dev ([a b c], [a b c d e], [3 3 3 3 3], [5 5 4]), and the literal planner case (in s (concat ['a 'b] 'c)) gives [a b c] (dev gives []). The commit also brings four wrong-result regressions, one of which now ends in an abort, plus a perf regression.

Setup for the repros below:

(set T (table [x k s t] (list [1 2 3 4 5] [0 0 1 1 2] ['a 'b 'c 'd 'e] (list "p" "q" "r" "s" "t"))))
(set sfx (fn [v] (concat v "!")))
(set sfs (fn [v] (concat v 'zz)))

1. Lambda helpers that contain concat and are applied to a column break. This comes from the new decline of lambda inlining. dev and 37a6816 agree on every row; disabling the named-lambda decline restores the dev results.

query dev / 37a6816 a1d3c2a
(select {v: (sfx s) from: T}) [a! b! c! d! e!] [['a !] ['b !] ...]
(select {v: (sfs s) from: T}) [azz bzz ...] ([a zz] [b zz] ...)
(count (sfx s)) by: k [2 2 1] domain: select by: failed to compile aggregation argument
(update {v: (sfs s) from: T}) [azz ...] length: update: new column has 6 values for 5 rows
where: (in (sfs s) ['azz 'czz]) [1 3] length: WHERE mask length 6 does not match row count 5
by: (sfs s) table failed to compile group key expression
(sfx t) on a STR column, 20M rows 0.84 s same values, 15.7 s

The decline fires on any concat anywhere in a lambda body, numeric or constant included. Wrapping a constant collection in a lambda also changes the result: with h = (fn [v] (concat ['p 'q] 'r)), (h x) in a projection gives [[p q r] [p q r] [p q r]], while the inline form gives [p q r].

2. select and update now disagree. Update's interpreter paths bind columns with ray_env_set_query_local and never publish g_active_query_table, so ray_query_expr_reads_rows does not see the columns there. Affected: update_eval_on (query.c ~16243), the update where: fallback (~16900) and update_where_mask_vec (~16047).

expression select update on a1d3c2a update on 37a6816
(+ (count (concat s 'zz)) (count (distinct x))) by: k [4 4 2] [5 5 5 5 3] [4 4 4 4 2]
where: (> (+ (count (concat s 'zz)) (count (distinct x))) 10) no rows all rows no rows
(+ (count (concat s "!")) (count (distinct x))), update works type error [4 4 4 4 2]

3. A column concatenated with a bound vector takes the text kernel in the per-group fallback. The eval.c condition takes it when either side reads rows. concat_text_values then takes the row count from the first vector whose length is not 1 and fills the missing rows with element 0. So the result depends on group size and operand order. With L3 = ['u 'v 'w]:

expression dev / 37a6816 a1d3c2a
(+ (count (concat s L3)) (count (distinct x))) by: k [7 7 5] [4 4 4]
same with (concat L3 s) [7 7 5] [5 5 4]
(concat s (take ['a] 0)) [4 4 2] [4 4 1]

4. Abort.

(set ES (as 'STR (list)))
(at (select {n: (+ (sum (strlen (concat t ES))) (count (distinct x))) by: k from: T asc: k}) 'n)

dev and 37a6816 give [4 4 2]. a1d3c2a aborts on str.h:56 ray_str_t_ptr: Assertion pool_base != NULL, in the release build too. The kernel bug itself is older: dev also aborts on (select {v: (concat t ES) from: T}). a1d3c2a makes it reachable from the fallback through a bound collection.

5. Perf.

  • The STR lambda helper projection: 0.84 → 15.7 s at 20M rows, and 85 → 1530 ms at 2M (item 1).
  • The per-group fallback with a computed text argument compiles a graph per group. (+ (count (concat (upper s) "!")) (count (distinct x))) over 2M rows and 1M groups takes 2744/2783 ms on 37a6816 and 3216/3212 ms now (+15–17%).

Elsewhere there is no change: count of text concat by key, with and without where:, text-concat projections, arithmetic lambdas (they still inline), membership, grouped aggregates, top-N, and concat outside queries.

Tests. I reverted each hunk in turn. These revert without any in-tree test failing:

  • the exec.c OP_CONST atoms_only check;
  • the OP_CONCAT extra-argument traversal;
  • the planner's !reads_rows && !all_atoms decline: without it, (concat (upper L) 'c) gives [Ac Bc];
  • the is_whole_column_projection constants;
  • the lexical-local check in reads_rows: without it, a lexical s in the fallback gives [4 4 3] instead of [5 5 4].

The variadic-third and lexical-shadowing tests are projections, so the planner handles them, not the evaluator code they are meant to cover.

make test (ASan/UBSan): 4115/4115.

It would be safer to keep inlining lambdas and decide the kernel after inlining, from whether each concat operand is derived from a source column, the same way in every path including update.

@ser-vasilich

ser-vasilich commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up on d6970a7. Its src/ is the same as a1d3c2a plus the dev merge, so my previous comment still applies. New since then:

#744 together with #754. I compared 113 nested queries merged against (do ...)-forced, by ser bytes and error parity. 96 of them composed at least one layer.

The fuzz oracle relaxation in d6970a7:

  • Classification is reliable. query_has_merge uses the same ray_select_merge_plan predicate as ray_select.
  • It is unbounded. Any F64 difference in total (e.g. 196609.5 vs 0) and any null-flag difference is accepted. It is not bounded by something like n·eps·Σ|q|, nor limited to inputs that overflow, so a real race in an unmerged reduction would be masked too.
  • It is incomplete. Composed queries run the same nondeterministic reduction, so the strict path will still flake. (select {from:(select {from:T a:a b:b k:k p:p s:s text:text}) total:(sum p)}) is inside the generator's grammar (131,073 rows, p from {DBL_MAX, -DBL_MAX, 3.2}). It composes, and at -c 4 it mismatched its (do ...) reference in 92 of 200 pairs (3.2 / 0.0 / 0Nf).

The underlying engine bug: a parallel F64 sum is nondeterministic. This is not from this PR; it reproduces on dev 57f5cfe.

(set P (take [1.7976931348623157e308 -1.7976931348623157e308 3.2] 131073))
(sum P)   ;; -c 4, 30 calls: 3.2, 0, 0Nf; -c 1: always 3.2
(set C (take [1e16 1.0 -1e16] 131073))
(sum C)   ;; -c 4: 0, 1, 2; -c 1: always 0

Two keyless paths keep one accumulator per worker while ray_pool_dispatch hands out tasks dynamically, and merge the partials in worker order:

  • exec_reduction (par_reduce_fn → accs[worker_id]), which serves the sum/avg/prod/var builtins and lazy reductions;
  • the keyless select scan in exec_group (scalar_*_fn → accums[worker_id]), with and without where:.

So each partial depends on which worker claimed which task. This is fixed in #758: partials are kept per chunk, with chunk boundaries derived from the row count, and folded in chunk order, which gives the same bits at every core count. With it, the select-merge oracle can stay strict instead of tolerating parallel F64 differences.

Smaller items:

  • count of a constant collection still disagrees between paths (T with 5 rows, L ['p 'q]):
    • select {c:(count (concat L 'z))} gives [5], the row count;
    • update gives [3 3 3 3 3], {c:(+ 0 (count (concat L 'z)))} gives [3 3 3 3 3], and by: gives [3 3 3].
    • (count [1 2]) and (count L) in a select also give [5].
  • The pf_settle change (167324d) is correct: faulting a locked page waits for its read, and MADV_WILLNEED is issued synchronously inside the task. It ran 25/25 under ASan. It makes scan_read_ahead about 1.5x slower (198 → 310 ms), because the mapping is MADV_RANDOM and each reset does thousands of synchronous 4 KiB faults. A buffered pread of the file, or MADV_NORMAL before faulting, would drain the same reads with batched I/O.

make test (ASan/UBSan) on d6970a7: 4119/4119.

ser-vasilich and others added 4 commits October 9, 2026 11:39
Scalar reductions and the keyless select scan kept one accumulator per
worker while the pool hands out tasks dynamically, so F64 sum, avg,
var and prod changed from run to run. Partials are now kept per chunk,
with chunk boundaries derived from the row count only, and folded in
chunk order, which gives the same bits at every core count.
@ser-vasilich

Copy link
Copy Markdown
Collaborator

One more concat shape. dev has the same behaviour, so it is not a regression. It falls squarely under the query concat rules this PR defines, so it would fit here:

(set T4 (table [x s] (list [1 2 3 4] (as 'SYMBOL ["a" "" "c" "d"]))))
(at (select {r: (concat s 'zz) from: T4}) 'r)                 ;; [azz zz czz dzz]
(at (update {r: (concat (first s) 'zz) by: x from: T4}) 'r)   ;; [azz null czz dzz]
(at (select {r: (concat (first s) 'zz) by: x from: T4}) 'r)   ;; error: domain: select by: output `r` did not evaluate to one value per group

The two problems, both on acaa50a and on dev 57f5cfe:

  • The grouped update turns the null cell into null, while the ungrouped path reads it as empty text and gives zz.
  • The grouped select over the same per-group atom does not produce one value per group.

Could you cover this in #744? I'd expect the grouped update and the grouped select to both give [azz zz czz dzz].

…or concat

A grouped (concat (first s) 'zz) gave null for an empty symbol in a
grouped update and failed to produce one value per group in a grouped
select, while the ungrouped projection read the empty cell as empty text.
The planner's concat kernel takes a cell mask: an operand that reduces a
text column (first, last, min, max) is a cell, so a null there is empty
text and a STR cell keeps STR output. The grouped select's
arithmetic-of-aggregates wrappers run with the group result as the active
table and the hidden aggregate columns flagged as row-derived.
@singaraiona

Copy link
Copy Markdown
Collaborator Author

Covered in ae5d195. Both forms give [azz zz czz dzz], so does last, max and the literal on the left, and the STR column agrees. One related change from dev: a cell reduced from a STR column keeps STR output, as the column does ((concat (first s) "zz") over a STR column gave symbols before), so grouped select, grouped update and the ungrouped projection agree on the type too.

The rule that was missing: a text column reduced to one value (first, last, min, max) is a cell of the rows, so for concat a null there is empty text, as it is for the cell itself, and a literal joins it. Two places had to say so:

  • The planner's concat kernel, which the grouped update runs per group: its text joiner takes a cell mask now (the same encoding the evaluator's two-operand kernel already used), and an operand produced by one of those reductions sets its bit, so the null symbol becomes zz instead of null.
  • The grouped select's arithmetic-of-aggregates path, which evaluates the rewritten wrapper over the group result through the tree walker: it bound the hidden aggregate columns without row provenance and without an active table, so concat saw two collections and produced five values for four groups. The hidden columns carry the rows flag and the group result is the active table while the wrappers run.

first keeps its own meaning everywhere: over a two-row group ['' 'd] it is d on dev and here, in the engine and in the compound path alike, so a grouped (concat (first s) 'zz) over such a group is dzz; the empty cell shows only where the group's value is itself empty.

Tests. concat_helper_consistency.rfl adds the ungrouped projection, the grouped select with first, last, max and the literal on either side, the filtered source, the grouped update, the STR column for both, and (count s) keeping collection semantics (a number is not a cell of text), and the same shape through a two-statement helper (bytecode), a lambda written in query text (tree walker) and a builtin held in a local, which the evaluator's syntactic fallback already handled. The kernel mask, the STR output rule, the hidden-column flag and the active table each fail a case when reverted; I had also added an evaluator-side rule for the reductions and took it out again when no shape could tell it apart from the existing fallback.

Validation: make test under ASan/UBSan 4136/4136; strict docs build; functions and reference docs state the rule.

@ser-vasilich

Copy link
Copy Markdown
Collaborator

Rechecked 01870ab through ae5d195 (CI green). Release builds, compared with 8821daa, d698cbd and dev 57f5cfe.

Fixed

  • Every item from my last comment. The closure first call and the compiled calls now agree. Helper keys passed as a parameter, local or captured give [3 3]. A mavg key gives [1 5]. The dict-dispatched effect gives CNT 6. A conditional let count in a plain lambda gives 3. Recursion with 10 captures reaches 109.
  • F64 keys. The null flag on an F64 key matches its materialized reference.
  • The null scan. It costs nothing again. I put a single surviving null at every block boundary, for every key type, at 1, 3 and 24 cores: no violations.
  • Bug 9. (concat (first s) 'zz) gives [azz zz czz dzz] in grouped update and in grouped select.
  • Closures from one template. Atom, vector, table, closure, string, dict and lazy captures each return their own value, also across 1000 closures with two capture sets and across ser/de after the template is gone.
  • Closure performance. A closure called 1, 2 or 3 times takes 233 / 402 / 552 ms over 200k closures, against 1023 / 1201 / 1349 on d698cbd.

New in 01870ab. In each case 8821daa, d698cbd and dev give the right value.

  1. A closure made after a let that did not run reads 0, silently, in ordinary code.

    (set scale 10)
    (set f (fn [xs flag] (if flag (let scale 2)) (map (fn [v] (* v scale)) xs)))
    (f [1 2 3] false)   ;; [0 0 0]   (expected [10 20 30])
    
    • compile_fn_template (compile.c ~891-900) captures every outer local the body mentions, including let slots that are still empty.
    • op_mkclosure (eval.c ~3277) skips an empty slot, and op_bindcaps (~3225) leaves the slot empty.
    • compile_lambda marks every capture slot as fixed (c.n_fixed = c.n_locals, compile.c ~997), so a read compiles to OP_LOADENV, which pushes 0 for an empty slot instead of falling back to the global.
    • The same happens with no global of that name (0 instead of a name error), in a loop where only a later iteration binds the name, after a let skipped by a caught raise, after a while that runs zero times, and in a query helper. (de (ser g)) gives the right value, so a closure and its round-tripped copy disagree.
  2. eval inside a closure no longer sees its captures. This has been the case since d698cbd.

    (set mk (fn [y] (fn [] (eval (parse "y")))))
    (set g (mk 7))
    (g)   ;; error (dev: 7); with a global y = 100 it silently gives 100
    

    eval is a plain builtin compiled with no scope window, so it cannot see the slots the dynamic flag keeps.

  3. Deeply nested literals crash compilation. An outer literal now compiles every nested literal at once. Each level puts a compiler_t (about 4 KB) on the C stack, with no depth guard. A literal 2000 levels deep, built at run time, crashes with SIGSEGV; 8821daa handles depth 20000. Typed text is capped at 1024 levels, so only runtime-built or deserialized ASTs reach it.

    (set base (parse "(fn [] k)"))
    (set wrap (fn [inner] (concat (take base 2) (enlist inner))))
    (set build (fn [n] (let a (parse "k")) (times n (let a (wrap a))) a))
    ((eval (concat (take (parse "(fn [k] 0)") 2) (enlist (build 2000)))) 7)
    
  4. One-shot closures made by interpreted code got 2-3x slower. Compile-on-second-call is gone, so every such closure compiles on its first call. Median ms:

    shape dev 8821daa ae5d195
    one-shot closure in a tree-walked fn, 100k 182 196 369
    do/let one-shot, 100k 76 88 242
    fn literal inside or in a compiled fn, 200k 314 250 531

    Each such closure also keeps about 832 B of private bytecode.

  5. A compiled maker no longer captures a tree-walked caller's locals. This change is arguably more lexical, but it differs from dev, and the result now depends on whether the maker compiles.

    (set y 1)
    (set mk (fn [] (fn [] y)))
    ((do (let y 5) (mk)))   ;; 1   (dev, d698cbd8, 8821daa1: 5)
    

Purity check: an effect through a parameter runs twice. key_literal_pure / key_lambda_pure never record parameters, so a parameter inherits the purity of a let-literal or global that shares its name:

(set T10 (table [v] (list [1 2 3 4 5 6])))
(set CNT 0)
(set eff (fn [x] (set CNT (+ CNT 1)) x))
(set H (dict ['a] (list eff)))
(set h (fn [x] x))
(set ky (fn [v] 1 (let g (fn [h] (h v))) (g (at H 'a)) (if (< v 4) 0 1)))
(select {n: (count v) by: (ky v) from: T10}) CNT   ;; 7   (8821daa1: 6)

Tests. These hunks revert without any test failing:

  • Null scan: a scan that skips the last element of each 4096 block. A sweep finds 88 null keys with the flag cleared.
  • Lazy captures: env.c keeping a lazy capture. A case from Rayfall does reach it: (set c3 ((fn [v] (fn [] (if false (set 1 2)) (at v 0))) (asc [3 1 2]))) then (list (c3) (c3)) now gives [1 1], while dev and 8821daa raise an error. The op_mkclosure comment saying Rayfall cannot reach a lazy slot is also out of date.

Regression runs (2×24-thread box). 8821daa and acaa50a each did the load plus 3 rounds of 43 queries. Both are within noise of dev, and the only output difference from v2.11.0 is the requested column order of the 4-key group.

…al capture, parameters in the purity walk

A capture whose let had not run read as 0: capture slots are no longer
fixed, so an empty one falls back to the global or a name error like any
let slot. eval and resolve in a compiled body run inside a scope window
over its locals, so a closure's eval sees its captures. Template
compilation stops nesting at 32 levels and leaves deeper literals to the
dynamic path, which compiles one level per call. A tree-walked lambda
call marks its frames as the lexical boundary for capture, so a caller's
locals are not captured whether the maker compiles or not. The by: key
purity walk binds a callee's parameters to the call's argument text, so an
effect reached through a parameter counts and a pure helper passed as one
keeps the whole column.
… by interpreted code

A closure made by the tree walker (the top level, a lambda that could not
compile, a literal inside a special form the compiler hands to the tree
walker) compiled its own copy of the body on its first call. ray_fn now
keeps a per-thread table of templates keyed by the literal's parameter
and body nodes (retained, so an address stays the entry's own) and its
capture names; a hit shares the bytecode, a miss compiles once. The table
is dropped with the runtime. Tests pin the sharing, two capture sets from
one literal, the block-boundary case of the key null scan and the
interpreted lazy capture.
@singaraiona

Copy link
Copy Markdown
Collaborator Author

Fixed in efcc705 and c5e4742.

  1. Unbound capture. Capture slots are no longer marked as always bound: a capture whose let had not run is an empty slot, and a read of it goes to the global or raises a name error (OP_LOADENVG), as for any let slot. (f [1 2 3] false) is [10 20 30], (f2 false) is a name error, and a closure agrees with its ser/de copy.
  2. eval in a closure. eval and resolve compile inside a scope window over the frame's locals, captures included, the way a dynamic special form does: (g) is 7 with or without a global y.
  3. Deep literals. Template compilation nests at most 32 levels; a deeper literal takes the dynamic path, whose closures compile one level per call. The 2000-level build returns a lambda.
  4. One-shot closures made by interpreted code. They share a template per literal and capture set too: ray_fn keeps a per-thread table of 256 templates keyed by the literal's parameter and body nodes (retained, so an address stays the entry's own while it lives) and its capture names; a hit shares the bytecode, a miss compiles once. Two capture sets from one literal get two templates and keep their values; a second closure adds about 360 bytes instead of 630. On this box (-c 1, medians), the literal inside or in a compiled function went from 333 to 136 ms for 200k; the one-shot closure in a tree-walked function and the do/let one-shot were already at 29 and 22 ms for 100k here (dev: 57 for the first, a name error for the second) and stay there, so I could not reproduce your 2-3x on those two shapes, but the mechanism that produced it, a private compile per closure, is gone for all three.
  5. Lexical capture. The tree walker's lambda call marks its frames as the capture boundary, so a closure made inside a body captures that body's bindings and never a caller's locals, whether the maker compiled or not: ((do (let y 5) (mk))) is 1 on both paths, and the docs say so. It is a change from dev's dynamic capture, which only ever applied to interpreted makers.
  6. Purity: effects through parameters. The walk binds a callee's parameters to the call's argument text: a parameter called inside the body means the argument it was bound to (a name is followed outside the body, a literal is checked, a computed callee is an effect), so ky gives CNT 6, and a pure helper passed as a parameter still keeps the whole column.

Hunks. The null scan's last-element case is pinned by a key column whose single surviving null lands on slot 4095. The interpreted lazy capture is pinned by your c3 shape, and the op_mkclosure comment no longer claims Rayfall cannot reach a lazy slot; it says what the step is for.

Tests. closure_locals.rfl: the unbound capture (global present, absent, and after the let ran), eval and resolve in closures, lexical capture with a compiled and an interpreted maker, the 2000-level literal, the Rayfall lazy capture, and two capture sets from one interpreted literal with the sharing bound. helper_bytecode_provenance.rfl: the effect through a parameter and a pure helper through a parameter. group_key_null_flag.rfl: the block-boundary null. Each hunk reverts to a failing test.

Validation: make test under ASan/UBSan 4136/4136; strict docs build.

Two literals built for eval can share their parameter, first and last
nodes and differ in between; the template table compared only those, so
the second literal ran the first one's bytecode. An entry keeps every
body node (literals over eight forms are not cached) and a hit requires
all of them. A thread whose table outlived its runtime forgets its
entries instead of releasing objects of a destroyed heap, and ray_fn uses
the compiler's own closure shape gate.
@singaraiona

Copy link
Copy Markdown
Collaborator Author

Audit finding addressed in 840261e.

  • Blocking 1. The table entry keeps every body node (literals with more than eight forms are not cached and compile when called, as before), and a hit requires every node to match, so two literals built for eval that share their parameter, first and last nodes but differ in between get their own templates. The regression test builds the two literals from one parsed form, splicing a different set into the middle (the quoted-list form in your sketch does not evaluate as a lambda body; parsed pieces share nodes the same way): on the previous head the second closure ran the first one's bytecode and gev stayed 1, now it is 1, 2, 1.
  • Stale entries on another thread. When the symbol epoch has moved under a thread's table, its objects belonged to a runtime whose heap is gone, so the entries are forgotten, not released; only the destroying thread releases, in ray_lang_destroy.
  • Shape check. ray_fn now calls the compiler's own gate (ray_closure_shape_ok) instead of restating it.
  • Compile at creation. Noted: a closure that is never called now costs one compile per literal and capture set rather than none, in exchange for no compile per closure that is called; the table is bounded (256 entries, round-robin), a miss just compiles. Debug spans of an eval-built literal come from the shared template; its nodes are shared too, so they point at the same text.

Validation: make test under ASan/UBSan 4136/4136.

A template entry remembers the symbols its literal mentions, so a hit
costs a pass over the dict keys instead of a walk of the body and
allocates nothing; the capture walk gathers row flags in a local buffer
and builds the flag vector only when one is set. Closures made and never
called by an interpreted maker: 249 ms per 200k before, 202 after (dev
121); the fn literal inside or: 136 to 98 ms per 200k.
@singaraiona

Copy link
Copy Markdown
Collaborator Author

Follow-up in 1f2a0e2, on the audit's note that compilation moved to creation: a closure made by an interpreted maker and never called cost 249 ms per 200k on 840261e against 193 on ae5d195 (dev: 121). A template entry now remembers the symbols its literal mentions, so a hit costs one pass over the dict keys instead of a walk of the body and allocates nothing, and the capture walk builds its row-flag vector only when a binding is flagged. The same shape takes 197 ms now, and the fn literal inside or went from 136 to 98 ms per 200k. Validation: make test under ASan/UBSan 4136/4136.

@ser-vasilich

Copy link
Copy Markdown
Collaborator

Rechecked efcc705 through 1f2a0e2 (CI green). Release builds, compared with ae5d195, d698cbd and dev 24c14e8.

Fixed

  • Everything from my last comment, except the compiled maker that no longer captures a tree-walked caller's locals, which you kept as lexical scoping. A closure made after a let that did not run gives [10 20 30], eval in a closure sees its captures, a 2000-deep literal compiles, and the effect through a parameter runs 6 times.
  • Closure performance. Tree-walked closure shapes are now faster than dev (142 vs 178 ms, 62 vs 74, 191 vs 300). Closures called 1, 2 or 3 times take 232 / 395 / 533 ms over 200k closures, against 1010 / 1178 / 1335 on d698cbd, and closures from one literal share their bytecode.

New. In each case ae5d195 and dev give the right value unless noted.

  1. eval/resolve opens its window before its arguments are evaluated (efcc705). compile.c ~642-656 emits OP_SCOPE_BEGIN before it compiles the head and the arguments (OP_SCOPE_END at ~702-705), so argument code runs inside the window. op_ret (eval.c ~3260) does not unwind windows; only vm_error_cleanup does.

    • return in an argument leaks the callee's frame for the rest of the session:
      (set p 100)
      (set rd (fn [] p))
      (set g (fn [p] (eval (if (> p 0) (return 5) (quote p)))))
      (list (g 1) p (rd))   ;; 5 1 1     (dev: 5 100 100)
      (set p 200)
      (list p (rd))         ;; 1 1       (dev: 200 200)
      
      After about 60 more such calls the scope stack is full, and every later scope-using form, such as (do (let a 1) a), fails with limit.
    • A let inside an argument is reverted at OP_SCOPE_END: (set f2 (fn [x] (eval (do (let x 10) 0)) x)) (f2 1) gives 1 (dev: 10).
    • A function called from an argument sees the caller's locals: (set x 100) (set rx (fn [] x)) (set f (fn [x] (eval (rx)))) (f 1) gives 1 (dev: 100).

    Opening the window after the arguments, just before the call, and unwinding windows in OP_RET as the error path does would cover all three.

  2. A shared template keeps the builtin a name was bound to when the template was compiled (c5e4742). compile.c ~596 resolves the head with ray_env_get, and ~661-663 emit LOADCONST of that builtin. closure_share_template (eval.c ~2233) keys only on the literal's nodes and capture names. A closure created after the name is rebound still calls the old builtin:

    (set g sum)
    (set r [])
    (times 2 (do (set k (fn [x] (g x))) (set r (concat r (k [1 2 3]))) (set g count)))
    r   ;; [6 6]   (dev: [6 3])
    
    • ray_env_get also walks dynamic frames, so the first maker call bakes the caller's local into the template for every later closure from that literal:
      (set g count)
      (set mk (fn [] (if false (set 1 2)) (fn [x] (g x))))
      (set c1 (do (let g sum) (mk)))
      (set c2 (mk))
      (list (c1 [1 2 3]) (c2 [1 2 3]) ((mk) [1 2 3]))   ;; 6 6 6   (dev: 6 3 3)
      
    • Compiled makers have done the same since acaa50a: (set mk (fn [] (fn [x] (g x)))), with g rebound from sum to count between two (mk) calls, gives 6 6 (d698cbd and dev: 6 3).

    A named lambda that has already been called keeps the old builtin on dev too, but here the closure is created after the rebinding.

  3. The template table keeps up to 256 literals alive after their closures are gone.

    (set base (parse "(fn [x] 0)"))
    (set big (fn [i] (eval (concat (take base 2) (enlist (concat (parse "(+ x)") (enlist (+ i (til 100000)))))))))
    (set bs (map big (til 300)))
    (set bs 0)
    (.sys.gc)
    (at (.sys.mem) 'bytes-allocated)   ;; 268 MB   (dev: 36 KB)
    

    Small literals hold about 1 KB per entry. The memory comes back only on eviction or runtime destroy, and the table's extra reference makes alter copy any vector a literal shares.

  4. Creating a closure from a 50,000-deep literal crashes. ast_collect_syms (compile.c ~816), called from ray_closure_mentioned_syms on every template miss, recurses with no depth guard:

    (set base (parse "(+ 1 0)"))
    (set wrap (fn [inner] (concat (take base 2) (enlist inner))))
    (set build (fn [n] (let a (parse "x")) (times n (let a (wrap a))) a))
    (set L (concat (take (parse "(fn [x] 0)") 2) (enlist (build 50000))))
    (try ((eval L) 1) (fn [e] e))   ;; SIGSEGV in (eval L)   (dev: no crash)
    
  5. Opaque by: keys: the purity walk mishandles parameters. Dev rejects these keys, so this is all in the new path. Setup:

    (set T (table [v] (list [1 2 3 4 5 6])))
    (set CNT 0)
    (set eff (fn [x] (set CNT (+ CNT 1)) x))
    (set H (dict ['a] (list eff)))
    
    • A lambda passed as a value keeps its parameters unbound (key_lambda_call_pure with args == NULL, query.c ~2258), so an unrelated global with a parameter's name decides purity:
      (set ap (fn [z g] (g z)))
      (set kf (fn [v] (fold ap (> v 3) (list (at H 'a)))))
      (select {n: (count v) by: (kf v) from: T}) CNT   ;; 6
      (set g (fn [x] x))
      (set CNT 0)
      (select {n: (count v) by: (kf v) from: T}) CNT   ;; 1, expected 6
      
    • A literal nested in a body drops the enclosing body's parameters (query.c ~2277): (set ap2 (fn [g z] ((fn [w] (g w)) z))) used as by: (ap2 (at H 'a) (> v 3)) gives CNT 6, then 1 once an unrelated pure global g exists.
    • Argument names are resolved in the callee's scope (outer = *ctx, query.c ~2308):
      (set hh (fn [x] (> (- x (avg x)) 0)))
      (set ap3 (fn [f z] (let hh (fn [q] (println "never") q)) (f z)))
      (at (select {n: (count v) by: (ap3 hh v) from: T}) 'n)   ;; [6], expected [3 3] as (hh v) gives
      
  6. Closures whose body cannot compile are compiled twice per creation. closure_share_template (eval.c ~2271) does not remember a failed compile, and ray_compile tries again at the first call. 100k closures with (if false (set 1 2)) in the body, made by a tree-walked maker: 495 ms vs 315 on dev with a one-statement body, 785 vs 480 with five statements.

  7. A grouped multi-statement update is 17% slower than dev. (update {v: (+ (sum (hn (+ x 1))) (count (distinct x))) by: k from: T}) with hn = (fn [v] 1 (* v 2)), 2M rows and 1M groups, -c 8: 7.0 s vs 5.97 s on dev (d698cbd: 6.8 s). The other per-row and grouped helper shapes are 35-50% faster than dev.

Tests. 1f2a0e2 adds no tests, and these revert without a failure:

  • Template matching that ignores capture names. The existing test creates the larger capture set first; creating dd2 before dd1 gives [1 'none].
  • Dropping the row flag for every binding after the first, or never building the flags from the stack buffer. A flagged column captured after 1, 21 or 301 unflagged bindings then gives [[a x] …] instead of [ax bx ax cx].
  • In the purity walk: binding the parameters of a called lambda value, and binding unknown parameters for uncalled literals.

Regression runs (2×24-thread box). ae5d195 and 1f2a0e2 each did the load plus 3 rounds of 43 queries. Both are within noise of dev, and the only output difference from v2.11.0 is the requested column order of the 4-key group.

…ndow wraps only the call

The eval/resolve window opened before the arguments were compiled, so a
return, a let or a call inside an argument ran in the window and a
return leaked the frame; it now opens right before the call and closes
right after it. A closure body resolves the builtins it names when it
runs instead of baking the one the template saw, and a top-level
literal's baked template is recompiled once a function-valued global was
rebound (ray_env_fn_generation). The VM's general call applies the same
elementwise map, concat rule and provenance join as the dedicated
opcodes. The symbol walk and the compiler stop at 1024 and 2048 levels; a
literal holding more than 65536 vector elements is not kept in the
template table; a body that did not compile is marked and never retried.
The by: key purity walk binds a passed lambda's parameters as unknown,
lets a nested literal see the enclosing body's parameters, and resolves
an argument's name where the call was written. A grouped update expands
its helper expression once instead of per group.
…not a remembered expression

A remembered expression pointer could match a fresh expression at a
reused address and skip the planner for it, turning a schema error into
a name error. The flag now belongs to one grouped update. The OP_RET
window unwind was unreachable once eval's window wraps only the call and
is gone; the deep-literal test runs at the depth the sanitizer build's
recursive release survives.
@singaraiona

Copy link
Copy Markdown
Collaborator Author

Fixed in b32d913 and b21830b.

  1. eval/resolve window. The window opens after the arguments, right before the call, and closes right after it, so argument code runs in the frame: (g 1) leaves p and rd alone ([5 100 100], then [200 200]), (f2 1) is 10, (f 1) is 100. A return inside the evaluated text itself still arrives as the return marker through the error path, which already unwinds windows, so no OP_RET change was needed; I had added one and took it out again when no shape could reach it. 200 such calls leave (do (let a 1) a) working.
  2. Baked builtins in templates. A closure body (a literal written inside a function) now resolves the names it does not capture when it runs (OP_RESOLVE, through the VM's general call, which now applies the same elementwise map, concat rule and provenance join as the dedicated opcodes), and the compile-time lookup that classifies a head is global-only, so neither a later rebinding nor a caller's frame is frozen into a template: the compiled maker gives [3 3]. A literal written at the top level is a definition and keeps baking its builtins as dev's named lambdas do; its shared template is recompiled once a function-valued global has been rebound (a generation counter in the global binding), so r is [6 3]. With lexical capture the (do (let g sum) (mk)) closure sees the global count when it runs, so that case gives [3 3 3] where dev's dynamic capture gave 6 3 3; a lambda made with nothing captured keeps the builtins it names baked, as dev's named lambdas do. The docs say both.
  3. The table and large literals. A literal whose text holds more than 65536 vector elements is not cached (the walk that collects a literal's symbols counts them), so the 300 large literals leave a few hundred KB behind instead of 268 MB; the test pins a 4 MB bound.
  4. 50000-deep literal. The symbol walk stops at 1024 levels and the compiler at 2048; past that the literal has no template and, when called, is left to the tree walker rather than the C stack. The shape runs and returns 0 as on dev. The regression test runs at 12000 levels, the depth the sanitizer build's recursive release survives; without the compiler's guard it crashes there. The symbol walk's guard only matters deeper than that, where the sanitizer build cannot go, so that one hunk is pinned by your 50000-level shape on a release build only.
  5. Purity walk. A lambda passed as a value binds its parameters as unknown (a call through one is an effect): CNT is 6 both times. A literal nested in a body inherits the body's parameters: 6. An argument's name is resolved where the call was written, in the caller's context: (ap3 hh v) gives [3 3].
  6. Failed compiles. A table entry remembers that a literal did not compile, and a lambda that did not compile is marked (NLOCALS -1) so no call retries; each such body compiles at most once per literal.
  7. Grouped multi-statement update. Two costs per group were the same for every group: the helper expansion was redone for each (the expansion is text over the schema, so it is made once per update now), and the planner declined the same expression once per group (a graph built and compiled 1M times; a flag of the grouped call skips it after the first decline, kept per call rather than per remembered expression, since a reused address must not inherit it). Your shape at 2M rows, 1M groups, -c 8: 2142 ms on dev, 2820 on 1f2a0e2, 1945 now; of the three variants, the helper-only one was the slow one and is now level with dev, the one without a helper is 888 against dev's 1229, the one-statement helper 1783 against 2450.

Hunks. Template matching by capture names is pinned with the smaller capture set created first; a row-flagged column captured after 1 and after 21 unflagged bindings keeps its flag; the purity walk's parameter binding for called lambda values and unknown parameters for uncalled ones are each pinned by the shapes above.

Tests. closure_locals.rfl: the window cases including the 200-call stack check, the three rebinding shapes, the large-literal memory bound, the 50000-deep literal, the reversed capture-set order. helper_bytecode_provenance.rfl: the three parameter cases. concat_helper_consistency.rfl: the flagged capture after 1 and 21 bindings. Each hunk of this round reverts to a failing test, with the one exception noted under item 4.

Validation: make test under ASan/UBSan 4136/4136; strict docs build. Closure calls pay for resolving their builtins at run time: (times 1000000 (cadd 2)) 146 to 152 ms, map over a closure 86 to 93, made and called once 69 to 76 per 200k, twice 113 to 123; a lambda made with nothing captured still bakes, so the plain compiled call stays at 55.

…atform's stack

The regression test for the depth guard built a 12000-level literal and
relied on the sanitizer build surviving its recursive release; the macOS
one does not. The body is now a chain of nested ifs: compiled it runs as
jumps and returns the parameter, on the tree walker it recurses and stops
at the eval depth limit. 600 and 1500 levels return 1, 2500 (past the 2048
guard, within the bytecode jump range) is a limit error; with the guard
removed it compiles and returns 1. The syntax page states the limit; the
control-flow page's anchor to the lambdas section now resolves.
@singaraiona

Copy link
Copy Markdown
Collaborator Author

Follow-up in 5bf922d: the macOS debug job overflowed its stack releasing the 12000-level literal my regression test built (the Linux sanitizer build survives that depth, the macOS one does not). The compiler's depth guard is unchanged; the test now pins it by result instead of by a crash. The body is a chain of nested ifs: a compiled body runs it as jumps and returns the parameter, the tree walker recurses per level and stops at its depth limit of 512. So 600 and 1500 levels return 1 (compiled, with and without a shared template), and 2500 levels, past the 2048 guard yet within the bytecode's jump range, is a limit error. With the guard removed the 2500 case compiles and returns 1, so the test fails. The syntax page now states the limit. Validation: make test under ASan/UBSan 4136/4136, mkdocs build --strict clean (also fixed the control-flow page's anchor to the lambdas section).

Aside, pre-existing on dev: a chain of more than about 60 nested do forms on the tree walker reports oom (the scope stack is full) where limit would be the honest error. Not touched here.

…kes 255

The text kernel read its per-operand cell flags from a 64-bit mask that was
written for the first 64 operands only, so operand 64 shifted by 64. The
flags are one byte per operand for all 255 positions, in the planned node
and the two-operand path alike. The planner folded a query concat into one
node up to 16 operands and left longer forms to the binary builtin, which
rejected them; it now takes the executor's 255, so one limit governs the
form. Tests run 65-operand concats: a cell at position 0 with a null
literal at 64 nulls the row, a cell at 64 is a cell (empty text, STR
output), a column joins every row.
@singaraiona

Copy link
Copy Markdown
Collaborator Author

Audit finding addressed in 7c1d2d2.

Undefined shift in the concat kernel. Confirmed: the cell flags were a 64-bit mask, read for every operand while written for the first 64, so operand 64 shifted by 64. The flags are now one byte per operand for all 255 positions, in the planner's node and in the two-operand text path alike. Reverting the kernel hunk makes the new test abort under UBSan with shift exponent 64 is too large.

The audit's example was not reachable as written: the planner folded a query concat into one node only up to 16 operands and left anything longer to the binary builtin, which fails with arity: expected 2 args, got 17 (on dev too). The planner now takes the executor's 255, so one limit governs the form. The new tests run 65-operand concats on the planned path: a first cell at position 0 with a null literal at position 64 nulls the row; a cell at position 64 is still a cell (its empty value is empty text, over the rows where: keeps as well); a STR cell there still makes the result STR; and a 65-operand concat over a column joins every row. The reference page states the operand limit. Validation: make test under ASan/UBSan 4136/4136, mkdocs build --strict clean.

On the non-blocking observation about the template cache not keying on where a literal was compiled: the compile mode follows the closure's presence (a body with captures resolves its free names at run time, one without keeps the builtins baked), and the capture names are part of the key, so a literal cannot match an entry compiled in the other mode.

@ser-vasilich

Copy link
Copy Markdown
Collaborator

Rechecked b32d913 through 7c1d2d2 (CI green). Release builds, compared with 1f2a0e2 and dev 24c14e8; make test under ASan/UBSan 4136/4136.

Fixed

  • Every item from my last comment, as described.
    • The eval window: 5 100 100, then 200 200; (f2 1) is 10, (f 1) is 100, and 200 calls leave (do (let a 1) a) working.
    • The rebinding shapes: [6 3], 3 3 3 and 6 3.
    • The 50000-deep literal returns 0.
    • All three parameter shapes in the purity walk: CNT 6/6, 6/6 and [3 3].
  • Failed compiles are compiled once: 128 / 278 ms against 322 / 493 on dev.
  • The grouped update is faster than dev: 5.3 s vs 6.2 s, with values identical to dev.
  • Concat plans 17–255 operands correctly. 256 operands give the same arity error as dev above 16.
  • Builtins called through the run-time path behave like the baked ones, with one exception (item 3 below). This holds for 120 builtins × 17 argument kinds, and for 240 query-helper shapes compared between closures and named helpers.

New. dev gives the expected value unless noted.

  1. A closure body nested 1024–2046 levels deep silently loses its captures (b32d913). Past depth 1024, ast_collect_syms_at gives up and closure_capture_names returns NULL. ray_compile reads that NULL as "nothing captured" and compiles the body with no OP_BINDCAPS, so every captured name resolves as a global.

    (set wrap (fn [inner] (concat (take (parse "(if true 0)") 2) (enlist inner))))
    (set build (fn [n] (let a (parse "(+ k x)")) (times n (let a (wrap a))) a))
    (set M (eval (concat (take (parse "(fn [k] 0)") 2) (enlist (concat (take (parse "(fn [x] 0)") 2) (enlist (build 1100)))))))
    (set k 5000)
    ((M 100) 1)   ;; 5001   (1f2a0e25: 101; dev: limit). Without a global k it is a name error.
    

    Depths 1022–1023 give 101, depths 1024–2046 give 5001, and from 2047 on the result is limit. Treating a failed walk as not compilable (NLOCALS -1) would make this limit. No test covers the 1024 guard.

  2. A grouped update reads a global once for all groups. This comes from making the expansion once per update. If an effectful helper in the same expression changes the global between groups, the inlined pure helper no longer sees the change:

    (set T (table [x k] (list (til 20) (% (til 20) 7))))
    (set G 0)
    (set bump (fn [v] (set G (+ G 100)) 0))
    (set hg (fn [v] (+ (sum v) G)))
    (at (update {r: (+ (bump x) (hg x)) by: k from: T}) 'r)
    ;; [21 24 27 30 33 36 19 ...]   (dev: [121 224 327 430 533 636 719 ...]; 1f2a0e25 was one group behind)
    

    1f2a0e2 already froze the global this way for an ungrouped update and for a grouped select; this range extends it to grouped updates. Reverting to per-group expansion fails no test.

  3. A closure body calls a unary builtin on the generic null where baked code raises a type error. The OP_CALLF unary path lacks the RAY_IS_NULL(arg) guard that OP_CALL1 and the tree walker have.

    (set mk (fn [k] (fn [v] (count v))))
    (try ((mk 0) null) (fn [e] 'type_error))   ;; 1   (dev and 1f2a0e25: 'type_error)
    

    It affects 28 unary builtins, among them neg, abs, not, sqrt, count, sum, first, last, reverse, min, max, avg, asc, desc and strlen.

  4. Closure bodies that use several builtins got slower. Every builtin in a closure body now goes through OP_RESOLVE, which walks the scope frames and then scans the globals linearly. Median ms, 7c1d2d2 vs 1f2a0e2:

    • a closure with 4 builtins mapped over 1M: 593 vs 420 (+41%);
    • a closure while loop over 1M: 474 vs 390 (+22%);
    • a 5-statement closure called once, 200k closures: 291 vs 248 (+17%).

    Simple closure calls are still faster than dev: (times 1000000 (cadd 2)) takes 322 ms against 378 on dev.

  5. The literal cache bounds vector elements, not bytes. A string atom constant counts 0, so 300 literals each holding a 1.3 MB string keep 537 MB after their closures are gone (dev: 1.5 KB). Literals just under the 65536-element bound can pin about 128 MB in total.

  6. Two rebinding cases still bake the old builtin. Both happen on 1f2a0e2 as well.

    • The literal is baked when it is evaluated, not on its first call: (set g sum) (set f (fn [x] (g x))) (set g count) (f [1 2 3]) gives 6 (dev: 3, because dev compiles on the first call).
    • A function held in a dict and called by dotted name: (times 2 (do (set r (concat r ((fn [x] (D.f x)) [1 2 3]))) (set D (dict ['f] (list count))))) gives [6 6] (dev: [6 3]), and so does (set D.f count).

    Removing either generation bump alone, on rebind or on insert, fails no test.

  7. A pure helper reached through a let alias is treated as effectful, so the key runs per row. This is not new; dev rejects such a key.

    (set T (table [v] (list [1 2 3 4 5 6])))
    (set hk (fn [x] (> (- x (avg x)) 0)))
    (set kl (fn [v] (let g hk) (g v)))
    (at (select {n: (count v) by: (kl v) from: T}) 'n)   ;; [6], expected [3 3] as (hk v) gives
    

    At 1M rows this key takes 754 ms against 5 ms for the direct key. map or fold over a list of pure helpers is treated the same way.

  8. A named function called from inside evaluated text still sees the caller's locals. (set p 100) (set rd (fn [] p)) ((fn [p] (eval (quote (rd)))) 1) gives 1 (dev: 100). The argument case is fixed; this is the same leak through the evaluated text.

Tests. These revert without any test failing:

  • the head lookup being global-only;
  • the 1024 symbol-walk guard;
  • ray_compile marking NLOCALS -1, and the -1 check in lambda_wants_compile;
  • the failed-template memo;
  • either generation bump alone;
  • the OP_CALLF binary result-rows flag;
  • expanding the grouped update once;
  • the per-call dag_declined flag (the reused-address case it fixes has no test).

Regression run (2×24-thread box). 7c1d2d2 did the load plus 3 rounds of 43 queries. It is within noise of dev, and the only output difference from v2.11.0 is the requested column order of the 4-key group.

@ser-vasilich

Copy link
Copy Markdown
Collaborator

Names in queries: a column vs a local or a global. This is not a defect in this PR, but #744 sets part of the rule, so it is better decided before it merges. With T = (table [x y] (list [3 1 4 2] [30 10 40 20])):

A parameter vs a column of the same name. Most paths let the parameter win; several let the column win:

(do (let y 5) (count (select {from: T where: (> y 15)})))            ;; 3: the count fast path reads the column
(do (let y 5) (count (at (select {from: T where: (> y 15)}) 'x)))    ;; 0: the materialised select reads the local
((fn [y] (select {c: (sum x) by: {y: y} from: T})) [10 10 30 30])    ;; 4 groups: the column
((fn [y] (select {c: (sum x) by: {k: y} from: T})) [10 10 30 30])    ;; 2 groups: the local

The column also wins in by: [y], asc: y and update … by: y, and after an earlier (let y …) inside a do in the query (the planner drops the let).

Helper bodies (this PR). A free name in a helper now resolves to the calling query's source column before the global, on the inlined and the bytecode paths alike. Around 25 assertions pin this, e.g. the suffix tests. So the same helper means different things depending on the table:

(set y 5)
(set hy (fn [a] (+ a y)))
(select {s: (hy x) from: T})                             ;; [33 11 44 22]: the column y
(select {s: (hy x) from: (table [x] (list [3 1 4 2]))})  ;; [8 6 9 7]: the global y

This is dynamic scope for columns: adding a column to a table changes what an existing helper computes, and an opaque helper may read any column, which blocks projection pushdown. In q, a function never sees the calling select's columns or the caller's locals.

A proposal for one rule:

  • Helpers and closures are lexical. They see their parameters, their own lets, their captures and globals. Columns reach them only as arguments.
  • In the query text, a name that is both a column and an outside local is an error, as in PL/pgSQL's default since 9.0: "'y is both a column and a local; write 'y for the column or rename the local". Column over global stays as it is.
  • One place decides. A single name-binding pass at query entry, with the schema known, would decide this for every path (planner, eval fallback, count fast path, by-dict, update by:). Today each path re-decides.

How would you like to settle the helper rule for this PR? Keeping it means users start relying on column visibility in helpers. Dropping it now would leave the rest for a follow-up after 2.11.1, and we can take that implementation.

@singaraiona

Copy link
Copy Markdown
Collaborator Author

Decided, after checking every case above on this branch, on dev and on the pre-PR base: a column and a variable are the same kind of binding, and the binding made last wins. A query binds its source columns when it runs, so inside the query text a column shadows an outer local and a global of its name. A helper called from the query binds its parameters and lets later still, so those shadow the columns, and its other free names resolve to the column before the global. That is the rule this PR implements for helpers, on the inlined and the bytecode paths alike (the inlined path already behaved this way on dev; the PR made the bytecode path agree), and it stays. Columns are not dynamic scope in disguise here: they are the innermost bindings of the query, as a let just inside it would be.

Consequences for the list above. The paths where the local wins today, the materialised where: after an outer let and by: {k: y} with a parameter y, are the inconsistent ones; they are identical on dev and on the pre-PR base, so this PR neither introduced nor touches them. The "both a column and a local is an error" rule is not adopted: shadowing resolves the name. A single name-binding pass at query entry is the right place to make every path agree, and I will take it, with those two paths, in a follow-up after 2.11.1 unless you want them in this PR.

@ser-vasilich

Copy link
Copy Markdown
Collaborator

Agreed that the binder goes after 2.11.1. One correction before it is planned. Today the local wins on most paths, not only the two above. A plain projection already reads the parameter:

((fn [y] (select {s: (sum y) from: T})) 5)   ;; [20] on dev and on this branch

The same holds for where:, by: y, computed keys and update values. Several tests pin this as deliberate:

  • test/rfl/regress/issue_394.rfl:68-73: "Lambda parameters and let bindings take precedence over query columns".
  • query/eval_fallback_sym_passthrough.rfl:42-44.
  • query/concat_scope.rfl:21-22, added in this PR: "Lexical bindings win over a same-named source column".
  • query/concat_operands.rfl:34-35.

The column wins only on the count fast path, by: {y: y}, by: [y], asc:/desc: and update … by:. So "the column shadows an outer local" flips the common case, and those tests with it.

For #772, the no-output by: y follows the current majority (the local), the same as the with-output form. That way the binder can switch every path at once.

@singaraiona

Copy link
Copy Markdown
Collaborator Author

Correction taken, and confirmed on this branch and on dev: a projection, where:, by: y, computed keys and update values read the parameter today, the tests you list pin it (the one in concat_scope.rfl came with this PR because that is what the engine does on every path it covers), and the column wins only on the count fast path, by: {y: y}, by: [y], asc:/desc: and update … by:. So the rule stated above is the target, not a description of today: the binder flips the common case, and those tests with it, in one change after 2.11.1. This PR leaves every one of those paths as it found it, and the no-output by: y in #772 following the current majority is right, for the same reason: one switch, every path at once.

@singaraiona
singaraiona merged commit f91f52f into dev Oct 10, 2026
10 checks passed
singaraiona added a commit that referenced this pull request Oct 11, 2026
singaraiona added a commit that referenced this pull request Oct 12, 2026
…sion aggregate (#760)

* fix(query): a multi-key select with a distinct count beside an expression aggregate (#756)

A select grouped by two or more keys failed with `name` when it held a
`(count (distinct x))` beside an aggregate over an expression, such as
`(sum (if (== st 'bad) 1 0))`.  The distinct count is not a DAG aggregate,
so with more than one key the select fell to the eval-level grouping,
which evaluated an aggregate's argument with no column in scope — and
which is serial, 8x slower than the parallel path for the shapes that did
not fail.

The eval-level grouping now evaluates an aggregate's argument over the
selected rows with the columns bound, as a row-wise projection
(group_key_eval), since the eval-level `if` is scalar.

A plain multi-key by: stays on the DAG path when the outputs the DAG
cannot serve are distinct counts and literal broadcasts: the post-group
scatter maps rows to groups through the composite key (rgid_build_multikey,
a parallel tuple probe that applies the selection), and the existing
per-group kernels serve the distinct counts.

Two shapes the issue's workarounds exposed go the same way.  An aggregate
over an `if` dropped the group onto the legacy engine, 6x slower under a
where: selection: the v2 expression bridge now materializes an input the
element-wise compiler declines through the DAG executor.  A distinct count
alone took the two-pass rewrite — group by (keys, value), then count —
which is a radix grouping of every tuple when that composite is not dense
(1.8 GB and 3x the time at 10 M rows with 45 groups of 3 M values); the
rewrite is now gated on a sampled estimate of the group and value counts
(agg_group_card_estimate) and the select otherwise groups by the keys and
counts over the slices.

Closes #756.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(query): guard the distinct-count gate's division for static analysis

* fix(query): -0.0 keys in the distinct-count probe, a sampled gate, by-dict aliases

Review round on #760.

- The row→group probe (key_read_i64, KEY_READ) reads an F32/F64 key's
  -0.0 as +0.0, as the grouping does (group_key_f64_bits, #407).  A -0.0
  row missed the merged zero group and was dropped from every per-group
  kernel — n fell to 0 beside a right count — on the single-key path
  before, and on the composite path since 7c17cf7.
- agg_group_card_estimate samples 65,536 rows at a fixed stride (the
  description's claim; the code read 64 blocks of 1,024) and raises the
  sample's distinct count by the Chao1 estimator from the values seen
  once and twice, capped at the sampled span for an integer key and the
  domain count for a SYM key.  Skewed and clustered keys estimated low
  before, so the gate took the slice dedup where the rewrite is 2–4x
  faster: a single-key where: count-distinct over a skewed SYM key ran
  405 ms against 99; it is 45 ms now.  The set is sized to the sample,
  so a 10-row select plans in 4 µs, not 80.
- The gate no longer prefers the rewrite under fewer groups than
  workers.  At 2 to 9 groups of 3 M to 6 M values on 28 workers the
  slice dedup takes the same time or less at 40% of the memory, and the
  issue's 45 groups at -c 46 take 42 ms / 512 MB against the rewrite's
  89 ms / 1375 MB.
- The two-pass rewrite keeps a by-dict's aliases, so `{a: k1 b: k2}`
  names its columns a, b on every route; it declines an alias naming
  another input column, as the by-dict materialization does.
- ray_execute resets the pool's cancel flag at the outermost call only:
  the grouped expression bridge's nested execution would have dropped a
  cancel that arrived before it, and the workers would have run the
  remaining tickets.

Tests: -0.0 keys through the probe (F64, F32, one key and a composite,
where:, the distinct count alone); a 60 K-group composite under where:,
where the probe's selection feeds the global-hash kernel; aliases on
both routes, with desc:/take: and where:.  Each fails with its fix
reverted.  Full ASan/UBSan suite 4,111 of 4,111.

* fix(query): where: stays alias-blind to a by-dict's keys

`(select {from: T by: {a: k1} where: (!= k1 'a) n: (count v)})` returned
no rows.  The eager by-dict path adds each alias as a column of the
table the rest of the select is compiled over, and a literal symbol
naming a from-table column resolves to that column (B3 Part 2), so `'a`
read the new alias column and the filter compared k1 with itself.  The
two-pass distinct-count rewrite and the deferred parted route add no
column and kept the symbol, so the same where: meant different things
on different routes.

where: names source columns only, like by: and the sort keys.  Before
the aliases are added, every literal in it naming an alias the table
does not already hold is wrapped in `(quote …)` — the compiler folds a
quote call to the symbol; only the tick atom takes the column rule — and
the select runs again over the rewritten dict, as the materialized
fallback does.  Outputs are untouched: a literal naming an earlier
projection resolves to it by design.

Tests in literal_col_ref.rfl: the alias as a quoted literal in where:
with count, sum, asc:/desc:/take:, two aliases, the rewrite route for
comparison, a vector literal and a source-column literal (both
unchanged), and a trivial self-alias.  They fail with the wrap disabled.
Full ASan/UBSan suite 4,111 of 4,111.

* fix(query): guard the cardinality estimate's division for static analysis

* test(query): the composite select's columns in declaration order, as #744 orders grouped outputs

* fix(group): F32 keys through the shared column reader

read_col_i64 had no F32 arm, so an F32 group key fell to its one-byte
default: the parallel group hash table's builders hashed and stored a byte
of each key, and a multi-core group by an F32 column over more than a
morsel of rows emitted garbage keys and split or merged groups — on a
plain count, on a distinct count beside the key, and for a composite key
with an F32 member once #760's probe admitted F32.  The reader now decodes
the 4-byte lane, with -0.0 canonicalised to +0.0 as the F64 key readers
do, and write_col_i64 stores it back.  F32 stays off the packed-key
whitelist: a packed region is raw bit identity, as for F64.

test/rfl/query/group_f32_key.rfl runs 100 K rows through every shape at
the suite's two workers; its first assertion fails without the arm.

* fix(query): the fused kernel's alias, from: evaluated once, a dead decline

The fused single-key distinct-count kernel named its key by the source
column (K_syms[0]) where every other route names a by-dict's key by its
alias, so `{g: k1}` came out as `k1` from 262,144 rows with workers and
as `g` below.  The where: shield's re-run took the from: expression, so
`(do (set C (+ C 1)) T)` ran twice; it now takes the table from: evaluated
to.  The rewrite's decline of an alias equal to the count's name changed
nothing on any route — the key comes out under the name first and the
count second everywhere — and is removed; the output is pinned instead,
with the duplicate-alias error on the rewrite's route.

* perf(query): the distinct-count gate reads the key and (key, value) tuples

agg_group_card_sample replaces the estimator's body: one strided pass
over the columns, prefetched, 32,768 rows, one row per window at a hashed
offset (a fixed position read a key whose period divides the stride as one
value), that also hashes the key tuple and the (key, value) tuple into two
more sets — the groups a composite forms, which the product of its
columns overstates, and the tuples the rewrite's (K, X) pass would keep.
A sorted prefix defeats a stride, so when the sampled tuples never
decrease, blocks of adjacent rows are read as well and the lower reading
stands.  Planning costs 0.6 ms of 10.5 on the dense 2-key shape at 10 M
rows, from 1.8.

The gate keeps its rules — dense (K, X) → rewrite; up to 256 K groups
otherwise → slice dedup; past 1 M groups → rewrite — and between 256 K
and 1 M groups asks the tuple estimate: the rewrite when its (K, X) pass
dedups, the slice dedup when the tuples are nearly the rows.  A sorted
750 K × 20 composite now takes the slice dedup at 138 ms / 849 MB against
the rewrite's 175 / 1596 (15 M rows, 28 workers); the other measured
shapes route as before.

test_agg_contract.c's card_estimate pins the Chao1 correction, the span
and domain caps, the tuple against the product, and the blocks on a
sorted prefix; each fails with its hunk reverted.

* fix(agg): the estimator samples each window at a hashed offset, caps SYM at its codes' span

The sampled row of window i was i * stride plus the high bits of i times
the golden ratio modulo the stride, an offset nearly linear in i: the
sampled rows stayed an arithmetic progression, and a key whose period is
near the stride was read at a nearly fixed phase.  At 10 M rows the
(k1, k2, x) tuples of (% i 1000), (% i 1009) and (% i 50) estimated at
ten million (1,009,000 true; no tuple seen twice) and the groups of
(% i 1000), (% i 1013) at 106,015 (1,013,000 true; too many seen twice),
so the gate sent the usual periodic test data to the slice dedup at
twice the rewrite's time.  The offset is now the splitmix64 finaliser of
i: the collisions are a random sample's, and both land within an eighth
of the count.

A SYM key's cap was the process-wide domain's count, which grows with
every symbol interned and moved the estimate with it.  It is now the
span of the sampled codes, as for an integer key: fixed once the
column's symbols are interned, and never above the domain's count.

card_estimate pins the tuple cap (2,002 tuples against a Chao1 of
500,502), a key whose period equals the stride (32; 1 at a fixed
offset), the two periodic composites at 10 M rows, the permutation
(i x 7919) mod N, and the SYM estimate before and after another symbol
is interned.  Each fails with its hunk reverted.

Refs #756, #760.

* test(query): the distinct-count route gate under test

cd_two_pass_preferred moves from query.c into cdfuse.c as
ray_cd_two_pass_preferred, declared in cdfuse.h beside the fused kernel
it routes around, so the contract tests can call it.  agg_contract/
cd_route turns each rule on by itself: the dense check (45 x 100 takes
the rewrite, 45 x a million the slices), the 256 K floor (100 K groups
whose tuples would dedup to a fifth take the slices), the band (524,288
groups of 19 rows: tuples a twentieth of the rows take the rewrite,
every row distinct the slices; both again over a sorted key read through
the blocks; the periodic 1000 x 1009 composite takes the rewrite), the
million-group rule, and the two declines (an F64 key takes the rewrite,
an F64 value the slices).  Removing the dense check, the floor or the
million-group rule, or pinning the band either way, fails the test.

Refs #756, #760.

* fix(query): the distinct-count gate sees the rows a where: keeps and the largest group

On the published 100 M-row dataset, a distinct count of users by phone
model behind `(!= MobilePhoneModel "")` took the slices at 2x the
rewrite's time, and the two-key form the same.  The gate estimated over
the whole table, where the empty model is the dominant value; the filter
keeps 5.56 M rows, and of those one model holds 5.1 M.  The slice dedup
(cdpg_buf_par_fn) runs one group per task, so that group ran on one
worker while the rewrite's (K, X) pass spread every row.

The gate now samples the predicate: 262,144 table rows at the
estimator's hashed stride, the columns the predicate names gathered from
them (typed loads with reads in flight; a byte-wise copy took 25 ms) into
a table of that size, the predicate run over it as the select runs it,
and the survivors handed to the estimator with the population they stand
for (query_cd_gate_where_rows; 2.4-2.9 ms at 100 M rows).  Running the
predicate in full cost 18 ms of a 90 ms select whenever the slices were
then chosen and evaluated it again.  A predicate naming a text, nested or
parted column, or keeping every sampled row, leaves the gate on the whole
table.

The estimator takes the row list and its population, keeps a count per
sampled value and reports the largest group's rows; a group holding more
than CD_SCATTER_SKEW (8) workers' shares of the rows takes the rewrite.
Synthetic 100 M rows with 12 M surviving a filter, 28 workers: a value on
half the rows took 180 ms on the slices against 136 on the rewrite, one
on 8% of them 107 against 138, equal groups 89 against 140.  The real
queries: 51-52 ms from 99-108 (dev 48) and 58 from 101-103 (dev 55).

card_estimate pins the largest group's rows, a selection whose dominant
value is filtered, and a list standing for a population; cd_route the
dominant group on 28 and on 2 workers, the filtered key over the table
and over the survivors, and the sampled predicate itself.  Removing the
skew rule, ignoring the rows, the count or the population, or disabling
the predicate sample fails them.

Refs #756, #760.

* perf(query): confirm a sampled group count in full before the slices

A distinct count of users by search phrase behind `(!= SearchPhrase "")`
took the slices at 2x the rewrite's time: 6.02 M phrases over the 13.2 M
surviving rows, most on one row and a few on thousands.  The Chao1 takes
the phrases seen twice for the whole tail's repeats and lands in the low
hundreds of thousands, under the 256 K floor.

Before the slices are chosen on a count above the sample's size — any
larger count is the Chao1's correction, not the sample's evidence — the
key columns are counted approximately in full (ray_count_distinct_approx,
parallel, 12 ms at 100 M rows): a column past a million groups takes the
rewrite.  Over the whole table, an upper bound that errs towards the
rewrite.  The real query: 150-152 ms from 314-318 (dev 143-148).

cd_route pins a heavy-tailed key the Chao1 reads as ~100 K of 5 M values;
the test fails with the confirmation removed.

Refs #756, #760.

* fix(query): release the gate's sampled table, gather SYM at its width

The filter over the gate's sampled table returns that table retained,
as the rewrite's own where: pre-pass returns the source table, and
neither reference was released: every gated where: select kept its
262,144-row sample, a few MB per query on a long-running server.  Both
are released now.

A SYM predicate column was gathered into a 64-bit vector by its
narrower source width (a mapped table's columns are 8, 16 or 32-bit; an
in-memory one 64), so the predicate read packed codes and garbage, kept
every sampled row, and the gate fell back to the whole-table estimate.
The gather allocates at the source's width.

cd_route runs eight gate samples under the memory trace and holds
nothing afterwards (33.5 MB with the release reverted), and writes the
key at 32 bits over the same domain and checks the survivors against the
key's integer twin (no survivors list with the width reverted).

Refs #756, #760.

* perf(store): request many short page runs over the pool

ray_col_want_rows lists the runs of pages its rows touch and, from 256
of them, asks for them over the pool's threads instead of one request
at a time on the calling thread: a sparse sample of a large mapped
column marks one page per row, and each request costs a few
microseconds of the kernel's — 14,000 to 34,000 of them on the
distinct-count queries of the published 100 M-row dataset. Few or long
runs still go through ray_pool_want, which splits a long range itself.

* perf(query): the distinct-count gate asks for its pages before it reads them

Cold, the gate cost more than the select: the predicate sample read
262,144 rows a stride apart — every page of a 400 MB mapped column, one
page fault at a time on the main thread, 0.8–3.4 s — and the estimator
then read the key and value columns at the survivors, a fault each. The
select's own scan asks for its pages ahead through the pool, and the
other samplers do the same before their scattered reads; the round 3–4
estimator, the predicate sample and the full count did not.

- The predicate sample lists its rows and asks for their pages first
  (ray_col_want_rows: at this stride the column whole, in parallel), and
  gathers the rows over the pool's threads, one task per range.
- The estimator lists the rows it reads — the strided sample and the
  sorted-prefix blocks — and asks for them: the sampled rows' pages, or
  the column whole when the rows the estimate describes fall on every
  page of it (a row per page or more: the select reads it whole then,
  and the stream runs while the pass waits on its first pages).
- The full count asks for the key column whole before the parallel pass.
- ray_vm_willneed_bytes counts the bytes asked for at the one place
  requests are made, for the pool's trace summary and the test.

On the published 100 M-row dataset at 28 workers, first run after the
page cache is evicted: the phone-model query 1240 → 297 ms (dev 272),
the two-key one 1496 → 352 (317), the search phrase 1338 → 452 (378).
Warm the gate is 2 ms on the first two and 10 on the third, 6.7 of them
the full count.

Test cd_gate_read_ahead evicts a splayed table it writes and checks, for
the predicate sample, the estimate and the gate over a sparse selection,
that each asks for the bytes it reads and takes at most a handful of
major faults; each of four reverts fails on the bytes asked for.

* fix(query): release the filter result in the count-distinct rewrite

A lone (count (distinct x)) under a where: and a by: takes
try_count_distinct_v2_rewrite. Its where: branch ran the filter node and
kept the result, the input table with a reference of its own, releasing
it only on error. Each such query left one extra reference on its table,
so the table was never freed and the next insert into it copied every
column.

Release it once the filter has run. The constant node keeps the graph's
reference until ray_graph_free, and the group reads the rows from
g_in->selection.

Fixes #757

* perf(query): the gate asks for the span its sample covers, from the chunks the zone gate keeps

The estimator asked for the key and value columns whole whenever the
selection averaged a row per page, without looking at where the sampled
rows were: a 1% window of a time-ordered table pulled 92 MB where 2 was
needed. It now takes the first and last sampled row and asks for that
span as one range when the selection keeps a row per page of it or more,
and the sampled rows' pages otherwise.

The predicate sample read every page of the filter column. It is drawn
now from the chunks the select's own zone gate lets through
(scan_gate_where), so a slice of a sorted column samples its one or two
chunks — every row of them, the survivors and the population exact —
and the key and value columns follow through the span. A predicate the
gate says nothing about samples the whole table as before.

ray_col_want_rows splits its page runs over ray_pool_dispatch_n, a task
per 256 runs: the plain dispatch cut at 8,192 elements, so thousands of
runs stayed on one or a few tasks.

agg_est_sample_row took a whole-row stride, nrows / sample, and so read
sample * stride rows of a selection, the last fifth of 41,943 rows under
a sample of 32,768 unread; its windows are fractional now.

Test cd_gate_read_ahead gains the sorted-column window and the
contiguous-slice estimate; the sparse step moves to a 272 MB column. Seven
reverts each fail on the bytes asked for.

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Ihor Filimonov <254675014+ihrfv@users.noreply.github.com>
singaraiona added a commit that referenced this pull request Oct 12, 2026
* fix(store): a replaced splayed table keeps one copy of itself (#713)

A table replaced twice or more held about three copies on disk: the first
write's root column files, never read again after the first publish, and
the previous generation, kept for a reader that resolved .current before
the swap.

- The first publish removes the columns the retired root schema names,
  with their .link sidecars, then .legacy.d.  Only those names go: the
  root also holds the symfile, the lock, and for a table written into a
  database root, the partitions.  A cleanup cut short is finished by the
  next publish, which also cleans stores written by earlier versions.
- Publication keeps only the selected generation.  Instead of the writer
  keeping the previous one, a reader whose generation is removed before it
  opened every file resolves again and reads the selected one
  (splay_load_impl, ray_read_splayed_dom, and ray_splay_has_schema for the
  partition checks, where a false "no table" would let .db.parted.fill
  write an empty table over a real one).  Mapped files stay readable.

No setting: the previous generation protected nothing the retry does not.

* fix(query): exclude null symbols from not-in (#739)

* perf(index): store hash slot table and group offsets as 32-bit

A hash index over a column below 2^32 rows keeps its slot table (group
id + 1) and its CSR group offsets as unsigned 32-bit values instead of
64-bit.  Both are bounded by the row count, so nothing is lost; the two
arrays take half the memory and half the bytes on disk.  Readers go
through one width-aware accessor; the append carry widens the source
arrays and re-narrows the result.

An index with a narrow array is persisted as layout generation 2.  The
mapper accepts generation 1 (all 64-bit, as written so far) and
generation 2, and rejects a generation-1 region that carries a narrow
array, so the column loads unindexed rather than misreading it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(heap): count off-heap allocations toward the spill watermark (#712)

The anonymous watermark that routes new memory to spill files saw only
the heap's pools and direct blocks.  Everything allocated with
ray_sys_alloc stayed outside it: the string arenas of the symbol table
and of the column domains, their hash tables, and index-build scratch.
During a load of a wide table with many distinct strings that is most of
the footprint.  Sampled in a container limited to 15 GB with -m 9G, the
watermark counter stayed at 1.9 GB while sys allocations grew to
15 GB, and the process was OOM-killed.

ray_sys_alloc now goes through ray_heap_sys_map: every block counts
toward the watermark, and a block of 1 MiB or more that would cross it
is mapped over an unlinked spill file instead, like a direct block.  The
file's descriptor is closed at once, so spilled blocks hold no fds.

Past the watermark, a pool or direct block that could not get a spill
file (no writable spill directory, disk full) failed the allocation.
With more counted and a lower default that point came much earlier; such
a block, like a sys block, now comes from RAM instead.

The default watermark was the whole RAM the process may use (physical
RAM, or a cgroup limit when that is smaller), so spilling began only once
nothing was left.  The page cache needs room from the same memory: the
mapped inputs, the dirty pages of the files being written and those of
the spill files, all charged to the limit under a cgroup.  The default now
stops a quarter below it, at least 3 GiB and at most half.  The budgets a
query sizes its strategies by (the direct-block cache, the dense and mode
grouping gates, the fused cumulative-distribution gate) keep the whole
limit: ray_heap_mem_budget is the -m value when one is set, else the RAM
the process may use.  Sized by the lowered watermark they picked slower
plans on a 32 GB machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(index): store hash index row ids as 32-bit

The CSR row-id array of a hash index (one entry per indexed row, the
largest of its arrays) is stored as unsigned 32-bit values below 2^32
rows, like the slot table and offsets.  Over a column of unique keys an
index now takes 16 bytes per row (row id, offset, key) plus 4 per slot,
instead of 24 plus 8.

Row slices leave the index as ray_idx_rows_t, a pointer with its width,
read through ray_idx_rows_at:

- ray_index_hash_group and ray_idx_slice_t carry it; the rowsel builder
  is specialised per width, and ray_index_rowsel_from_rows builds a
  selection straight from an index slice.
- The filter-plus-group kernel widens a narrow task chunk once into a
  per-worker buffer, so its accumulation loops are unchanged.
- The as-of join verify takes the slices directly instead of packing
  pointers into an int64 array.

Indexes mapped from generation-1 files keep their 64-bit rows and are
read through the same accessors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(arena): grow chunks so a large arena can spill (#712)

An arena allocated every chunk at its initial size: 64 KiB for a column
domain's string atoms.  A domain with millions of distinct strings was
then hundreds of thousands of small ray_sys_alloc blocks, and blocks
below 1 MiB are never mapped over a spill file, so the atoms of a large
load stayed in RAM past the watermark.  In a 15 GB container with -m 9G
the counted footprint reached 16.5 GB and the load stalled at the
cgroup limit.

Chunks now double from the initial size up to 16 MiB.  A large arena is
a few big chunks that spill past the watermark; a small one keeps its
small first chunk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(index): keep the wide arrays when narrowing is interrupted

Narrowing a just-built hash index's arrays runs as a pool dispatch, and
an interrupt (Ctrl-C, a client's cancel) makes a dispatch skip its
tasks.  Landing after the build's last interrupt check, it left the
32-bit copies unfilled and installed them; a load could then save that
index, and lookups returned wrong rows.  The narrowing now keeps the
64-bit array it was copying when the dispatch was cancelled.

The append carry built 64-bit arrays and narrowed them again on every
append; it now builds them in the width they end in and copies a narrow
source as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(heap): hand memory back between statements when it is short

A free block keeps its pages and a standard pool stays mapped after a
statement, so a repeated query reuses them without faulting; a server
gave them back only after ten idle seconds.  Where memory is short this
turned against the next query.  What the previous one left resident
starved the page cache, and a kept pool still counts toward the spill
watermark, so the next query's new memory went to spill files.  In a
container limited to 4 GB a heavy grouping ran in 22 s on a fresh server
and in 430 to 870 s on the second and third runs.

At a statement boundary of a server request or the REPL, once the
anonymous footprint is past the watermark (the next query's memory
would spill, and the room the watermark leaves the page cache is being
eaten), empty pools of every heap, standard ones included, are now
unmapped (their bytes leave the watermark count) and the pages of the
remaining free blocks released.  The repeated runs above take as long as
the first.  Below the watermark nothing changes: the kept memory is what
a repeated query reuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(index): build hash indexes partition by partition within the memory budget

The hash index build kept every row's group id, key word, partition slot
and the output arrays live at once, about 58 bytes per row at random, so a
100M-row column needed ~6 GB of private memory and on a small machine paged
it through the spill file at random.

Numeric and SYM columns are now indexed by a partitioned builder.  Key
words and row ids are bucketed by the top bits of the key's mixed hash;
partitions are deduped in batches whose working set fits a quarter of the
heap watermark; their groups, row offsets and rows are written in order.
Groups are numbered in ascending order of the mixed hash and a key's home
slot is the top bits of that hash, so the slot table is written in one
forward sweep and every array follows from the data alone, whatever the
partition count, budget or core count.  Only a batch is accessed at
random; the bucketed input and the outputs are streamed, and past the
watermark their heap blocks are file-backed.  An interrupt anywhere in
the build leaves nothing allocated and the column unindexed.

The new tables are marked RAY_MARK_HASH_HIGH and persisted as layout
generation 3.  Readers take the home slot from the table's own rule, so
generation 1 and 2 tables keep loading; older binaries load a generation-3
column unindexed.  STR columns keep the serial walk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(heap): relieve only when it brings the footprint back under the watermark

The statement-boundary relief scanned every freelist once per pool (its
cost grew with the square of the heap: hundreds of milliseconds per
statement on a fragmented heap of a few hundred MB), ran at every boundary
while live data held the footprint past the watermark (unmapping pools,
draining the direct cache and releasing pages that the next statement
mapped and faulted straight back), walked the heaps of threads that may
still be allocating, and ignored empty spill pools.

Empty pools are now found per heap with a cheap filter (a free block of
pool order or above must exist; then one header read per cascade offset)
and confirmed by one walk of the freelists that sums free bytes per
candidate.  Relief acts only when the next pool would not fit under the
watermark and handing back the empty pools and the direct cache brings it
under again; otherwise the memory stays for reuse.  When it acts it also
unmaps empty spill pools, whose page cache counts against a container's
limit.  Relief, the idle decay and gc's pool pass visit only the caller's
heap and the parked pool workers' heaps.  Without a file spill (Windows)
relief does nothing and memory is left to the idle decay.  The REPL's ^C
path, a script run with a file argument, timer callbacks and .sys.gc now
end at the same boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(scan): read ahead the columns a pass reads on cold storage (#747)

* fix(store): a reader keeps up with a table that is being rewritten (#748)

* fix(store): a reader keeps up with a table that is being rewritten

Only the selected generation of a splayed table is kept, so a reader can
see the generation it resolved removed before it has opened all its
files.  It then resolves again and retries, but gave up after eight
attempts.  A reader slower to open its files than the writer is to
publish (many columns, slow storage, a busy CPU) failed with an I/O
error after eight saves, although every failure was only the table
moving on.  splay/generation_reader_races hit it: under a loaded CPU it
failed 29 times in 30.

Each retry follows a completed publish, so the reader is no longer
counted out: it retries while the table keeps resolving to a different
generation, at least eight times and for up to 60 seconds, and then
reports that the table kept changing rather than the error of its last
attempt.  An interrupt stops the retries with a cancel error.  The
symbol domain of the previous attempt stays open across a retry, so a
large symfile is extended rather than read again on every attempt; read
again each time, it could take longer than the writer's interval and
lock the reader out.  Under the CPU load above the race test passes 30
times in 30.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(store): a retry that finds no symfile lets go of the domain it held

A retry that found the symbol file missing or unreadable left the
domain an earlier attempt held in place and then overwrote the only
reference to it, so the domain and its mapping stayed cached for the
life of the process (a later save to that symfile path then failed).
Each attempt now releases the domain held from the one before it,
after reopening the symfile when there is one.

New test splay/generation_retry_releases_domain drives a read through a
FIFO .current served by a child process: the first attempt fails after
opening the domain, the symfile is removed before the retry, and the
domain must not stay cached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* perf(parquet): import column by column when memory is short, through an import symbol dictionary (#753)

* perf(parquet): import a splayed table column by column when memory is short

The direct import ran one task per column chunk with row groups outermost,
so every worker wrote all of the table's column files at once and kept
every column's state live together, and the hash indexes waited for the
end and re-read their columns.  On a machine whose memory cannot hold the
output that is a scattered write pattern, and the re-read goes to disk.

The import now runs in passes.  When the process may use less than twice
the output's bytes it goes column by column (a pass is one column, or a
few when the file has fewer row groups than the pool has workers):
numeric and temporal columns first, each closed as soon as its pass ends
and its hash index built right away while the file is still in the page
cache; symbol columns after, smallest first, one by one below half the
output's bytes of memory and together above.  With more memory one pass
takes every column, as before.  RAY_PQ_PASS_COLS forces the columns per
pass.

Read-ahead follows the passes: a task requests its own chunk and the same
column's chunks of the next row groups (RAY_PQ_PREFETCH), up to a share of
the memory budget.  In one pass each written chunk's writeback starts as
it is written (ray_file_writeback_start), so the final sync finds little.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(sym): build the import's symbol file as the strings arrive

The import interned its strings into a file domain that keeps every one as
an arena atom (about a hundred bytes each with its index), behind one lock,
and wrote the symbol file only at the end: a rewrite of the whole
vocabulary through a temporary file, then a sync.  On large
vocabularies that is several gigabytes of anonymous memory, which a small
machine pages in and out, and tens of seconds of writing after the last
column.

A converter that builds its symbol file alone now uses an import domain
(ray_sym_domain_create_import, table/symimp.c): interned strings are
appended straight into the mapped symbol file as its records; in memory
there are only the records' offsets and a hash index in 1024 shards.  A
hit is found without a lock, and a batch's new strings are added together
under one, so they get neighbouring positions.  Position 0 stays "".

While the passes run, a background thread appends what is new to the
symbol file every quarter second and starts its writeback
(ray_sym_domain_flush_append writes the tail in place, and is what the
import domain's flush does), so the final flush only syncs.  The direct
Parquet import uses the import domain; the spooled path keeps the
ordinary one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(parquet): keep the one-pass import as it was; harden the import dictionary

One pass over every column (the layout with memory to spare) closed and
hash-indexed each numeric column on the main thread as the pass ended,
one after another, instead of finishing them on the pool and building
their indexes in one parallel wave after the symbol domain is released:
a table of wide numeric columns took twice as long.  That pass now leaves
the columns to the common finish, as the row-group import does; only the
column-by-column passes close and index each column as they go.  A file
with fewer row groups than the pool has workers, imported column by
column, put its symbol columns in the numeric pass; they get passes of
their own.  RAY_PQ_PASS_COLS sets the symbol passes too.  Output sizes
from the file's metadata are summed without overflow.

The symbol file appender slept a quarter second between flushes and the
import joined it at the end, so every import with a symbol column took a
quarter second longer: it now sleeps in slices that see the stop.  A
failed flush stops the import at the next pass instead of after the
last.

When the import dictionary could not be set up after creating its file,
the empty or zero-filled file stayed and the fallback to the ordinary
domain rejected it ("oom"): the file is removed first.  The file grows by
fallocate where the filesystem supports it, so a full disk fails the
intern instead of faulting a write through the mapping, and from 1 MiB
instead of 64; growth stops at the mapping's reserve rather than failing
short of it.  The file is created 0666 under the umask, as the ordinary
domain's is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(sym): free the import dictionary's replaced tables; look up a batch's repeats once

A shard table that grew was kept until the end of the import, since a
lookup without the lock might still be reading it, so the dictionary held
its tables about twice over.  On a machine with little memory that left
the page cache too small for the symbol file's records, which every
lookup reads to confirm a match, and the import of a large vocabulary
became a stream of random reads.

Lookups now register in the parity of an epoch for the span of their
lock-free probing; a replaced table is retired in the current epoch and
freed, under the lock, once no lookup of an older epoch is left.  Equal
strings of a batch are looked up once, compared with the batch's own
bytes, and the symbol file's mapping is marked random so a lookup that
misses the page cache reads one page rather than a read-ahead window.
Peak anonymous memory of a 2.5M-string import: 200 MB to 120 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(sym): the import dictionary gives back the tables it replaced

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(parquet): finish the one-pass import's columns while the symbol file is still written

With memory to spare the import runs one pass over every column, then
stopped the symbol file appender and synced the file before closing and
indexing the columns.  On a disk busy writing back the column data the
appender's last writeback request waits behind it, and the import sat
idle for that wait, then for the symbol file's sync, before any column
work began.

The one-pass import now finishes its columns (on the pool) and builds
their hash indexes in waves before it stops the appender, so that wait
overlaps the work; a symbol column's header takes the domain's final
count, which no longer changes once the pass is done.  A column whose
hash index ray_col_stream_hash_all built no longer asks for one, so the
common finish after it has nothing left to do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sym): random advice only on the written part of the import's symbol file

Marking the whole mapping random made every new page of the file being
appended a separate major fault, taken under the lock that serialises
the appends (a few percent slower with memory to spare).  The written
part is marked random at each background sync instead, and the tail
keeps the default.  Replaced tables are freed after the lock is dropped
rather than under it.  A batch's dedupe table is cleared for the batch's
size, not for the largest batch.  The reclaim test counts table sizes in
the 4 KiB units ray_sys_get_stat uses whatever the page size, so it holds
on 16 KiB pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(parquet): a chunk whose final flush fails fails the import

In the one-pass layout a chunk's stream is flushed before its writeback
is started, and a failed flush only skipped the writeback: the stream
had dropped the bytes it still buffered, the close after it succeeded,
and the import published a table with those rows zeroed.  A failed flush
is now the task's error, so nothing is published, and the writeback
starts only after a flush that succeeded.

New test parquet/chunk_flush_error (Linux): a file size limit inside the
buffered tail of the first chunk makes that flush fail; the import must
report it and publish nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* perf(query): isolate sparse group-count worker slices (#741)

* perf(query): isolate sparse group counters by cache line

* test(query): cover sparse count stride through reachable queries

* fix(query): compare ordered symbols by lexical value (#743)

* fix(query): compare ordered symbols by lexical value (#738)

* perf(query): bound symbol ordering setup to scanned ids

* perf(query): compose safe nested selects before planning (#754)

* perf(query): compose safe nested selects before planning

* fix(query): bound nested select planning and preserve overflow semantics

* test(query): cover empty grouped nested selects and large script rewrites

* test(query): verify scoped sources shadow global tables

* fix(query): preserve materialized product and filtered reduction semantics

* fix(ci): start select merge smoke fuzzing from seeds

* fix(query): align membership, concat, and grouped fallback semantics (#744)

* fix(query): align collection membership across evaluation paths

* fix(query): address membership and concat review regressions

* fix(query): preserve concat aggregate slots and scalar broadcasting

* fix(query): close filtered count and concat fallback gaps

* fix(query): preserve per-row enlist constructors

* fix(query): preserve collection concat across query scopes

* test(pool): drain pending reads before cache eviction

* test(query): account for unmerged parallel aggregate variability

* fix(group): fold keyless reduction partials in chunk order

Scalar reductions and the keyless select scan kept one accumulator per
worker while the pool hands out tasks dynamically, so F64 sum, avg,
var and prod changed from run to run. Partials are now kept per chunk,
with chunk boundaries derived from the row count only, and folded in
chunk order, which gives the same bits at every core count.

* fix(query): preserve concat operands through helper fallbacks

* fix(query): preserve materialized columns for nested sort

* test(pool): batch page reads while settling mappings

* test(query): restore bounded aggregate comparison after reduction fix

* fix(query): preserve helper globals during fallback expansion

* fix(query): preserve helper scope across fallback aliases

* fix(query): preserve fallback text and grouped output layout

* fix(query): retain column provenance in opaque helpers

* test(pool): require worker participation before asserting reclaim

* fix(query): align helper literals, concat operands and grouped layout across paths

The collection-concat detector recursed without end on an opaque helper
around row text; it now inspects the unexpanded call's arguments. A
grouped output named like its key kept only the key; the layout pass
treats the last column of a repeated name as the output. Grouped
fallback outputs follow the requested order, as planned outputs do.

A concat operand the planner has no node for (a reordered, indexed or
cast column) is a whole column in projections, so select agrees with
update, where and by; a STR column's cell keeps its type row by row.
Helper purity covers every non-restricted, non-special-form builtin
outside system namespaces. A literal symbol is the symbol inside a named
helper's body on every path, while inline lambdas keep the query's
column rule; a named helper's free names never see its caller's formals
or a derived alias. Aggregate classification skips the trial compile for
bare columns and concat-free column expressions.

* fix(eval): run query helpers as bytecode with row provenance on the VM stack

A helper called from a query fallback ran on the tree walker so concat
could tell column parameters from bound collections. The tree walker
ignores return, binds lazy arguments it cannot read twice, stops near 62
scopes and is about three times slower on recursion.

Helpers run as bytecode again. Each VM stack slot carries a row flag:
parameter loads take it from the call, let stores and loads copy it,
builtins the planner lowers row by row keep it, collection verbs clear
it, nested compiled calls pass it on and return it with their result,
and concat consults it. A call frame marks the lexical boundary: the
query's aliases and locals are hidden, a free name that is a source
column binds on first use to the row's cell or the whole column, and
anything else is a global. The tree walker stays for closures and for
lambdas written inline in a query, whose literals follow the column
rule; it now forces lazy parameters at binding. An opaque helper under
a grouped aggregate evaluates per group. call_lambda is the direct
bytecode path again outside queries.

* fix(eval): key provenance caches on the symbol epoch and carry opaque helper results

The row-wise builtin table, the helper purity tables and the per-object
verdict cache are rebuilt when the symbol-table epoch changes, so a
runtime re-init cannot leave a stale id or address behind. The compiled
call path computes a result flag only for builtins, a scoped window
clears the flag of a local it rebound, and the call-frame binding comment
states that only an allocation failure leaves a name unbound.

At the query level an opaque helper given row data yields row data, so a
concat over its result keeps text semantics. A source column shadowing a
global helper of the same name is documented and tested.

* refactor(eval): drop the dead inline-lambda bytecode gate in query calls

A lambda written inline in a query captures the query's bindings and is
therefore a closure, which is never compiled; the tree walker handles it
with the query's literal rule regardless. Pin that rule with a lambda the
planner does not substitute.

* fix(eval): keep a helper's free source column on the query's rows

A source column bound on demand into a helper's call frame carried its
row flag only on the first read; the frame now records where those
bindings start and every lookup of one sets the flag. The binding itself
is the query's own: the row's cell, the group's slice or the whole
column, taken from the frames below the call frame, or sliced by the
group's rows when the per-group fallback did not bind that column for its
own expression. While a projection binds output aliases, the source table
decides, as before.

* feat(query): group on a helper the planner cannot lower

A by: key that is an opaque helper is evaluated once over the table,
where its column arguments keep their rows, and grouped as a hidden
column named after the expression, as other computed keys are.

* fix(eval): carry row provenance through the tree walker

Every evaluation publishes whether its value derives from the active
query's rows: a query frame's column binding, a helper's source column,
a row-wise builtin over such a value, or a compiled helper's result. The
concat hook and the helper call site read that flag instead of
re-deriving it from the syntax, which also removes a DAG compile per
row from the call site; the syntactic analysis remains for the result
of an interpreted closure.

Inside a function, concat joins text only when an operand carries row
provenance; otherwise it keeps collection semantics, whatever called
it. Helper purity is an allowlist again, and a name standing for a user
function is never pure. A helper's free source column is the query's
binding for it, never a caller's local, and a nested query inside a
helper restores the enclosing group's rows. Reordering and indexing
keep row provenance in the VM; a bound vector returned by a helper does
not. Every grouped result keeps the requested output order, and a
purity lookup no longer binds a column into a helper's call frame as a
side effect, which the planner then mistook for a lexical local.

* perf(string): build a two-cell text concat as an atom

Row-by-row text concat of two cells produced a one-row vector and
unpacked it; it now builds the SYM or STR atom directly.

* fix(eval): keep row provenance on every binding, through forms, and in by keys

Scope frames carry a row-provenance flag per binding, so a let local in
an interpreted lambda, a parameter, a scoped window's locals and a
helper's source column all report their own provenance; the separate
call-scope bookkeeping is gone, and a call frame records whether it was
made by name for the literal rule. Flags are tri-state in the VM and the
tree walker: a try handler's value or an interpreted closure's result is
unknown and falls back to the syntactic analysis, so a bound constant is
not row data and a row value through a handler still is.

Only control forms pass their operand's provenance through; a nested
select, update or join builds a new value. A null text atom follows the
kernel's null rule row by row. An opaque helper used as the by: key is
evaluated over the rows where: keeps, row by row when its body holds a
condition over one value, grouped under the planned key's name, and
with no outputs the key replaces the source column it is named after.

* fix(group): pad keyless chunk accumulators to 128-byte line pairs

Blocks of chunks scanned at the same time sat on neighbouring 64-byte
lines, which the adjacent-line prefetcher moves as a pair, so they still
bounced between cores on every row of the generic kernel. Five
aggregates over 70K rows on a two-socket box took 5.7 ms against 0.58 ms
before the chunked fold.

* fix(eval): scope provenance to the innermost query and carry it through closures

A lookup that passes a query frame reports the binding below as plain
data, so a nested query inside a helper no longer takes the caller's
row value as its own row data. A closure's captures keep their flags,
and a try handler's own evaluation reports its value's provenance, so a
row value through a handler used inside the helper stays row data. set
passes its value's provenance through; a lambda called inside a named
helper's body is that body's text, so its quoted symbols stay symbols.
A null cell is the empty text the vector kernel reads, while a null
literal still nulls the row. An opaque by: key runs row by row only for
a condition over one value, looking helpers up lexically before
globally; a let, a sequence or a loop keeps the key whole.

* fix(env): size a closure's capture for its provenance entry

The key vector of a captured scope was sized for the bindings alone,
and the provenance entry appended after them wrote one slot past it.
Regressions create closures inside a query with several live row
bindings, directly in a per-row expression and inside an inline lambda.

* fix(env): resolve a closure's capture provenance at call time

A closure stores no row provenance: it is relative to a query. When the
closure is called while the frames it captured from are live, each
capture takes the provenance its binding has at that moment, relative
to the innermost query, and a compiled frame publishes its slots for a
closure whose scoped window has already closed. An escaped closure's
captures are plain data. The stored entry, its spellable key, its
serialization and the intern per closure call are gone.

The opaque by: key gate leaves and/or to elementwise evaluation, treats
an if over an aggregate condition as one value for the column, and
resolves names inside a global helper's body globally. A null STR
literal follows the kernel's null rule on both paths.

* fix(env): keep a closure's capture flags in the query frame it was made under

The flags are recorded in the innermost query frame when the closure is
made, keyed by the capture object, and consulted only while that frame
is the innermost query; they are freed with the frame. Nothing about
provenance is stored in the closure, matched by object identity, or
limited in count, and the compiled frame publishes nothing.

An opaque by: key is evaluated over the whole column and again row by
row only if a conditional tested a vector derived from the rows during
that run. Two STR literals join as strings on both paths. A try fallback
value keeps the provenance its expression had, in both interpreters. A
closure is never compiled when called through another function, so it
keeps its environment.

* feat(eval): compile closures, loading captures as flagged constants

A closure compiles like any lambda; its captures are loaded by OP_LOADCAP
as fixed values. The capture row flags live on the lambda in two new
slots that are never serialized, together with the serial of the query
frame the closure was made under, and apply only while a frame with that
serial is the innermost query. No record is keyed by address, nothing is
kept per closure in a frame, lookups are constant time, and a recursive
closure called from a compiled helper keeps bytecode's depth. A lambda
made inside a query is query text and stays on the tree walker, which
keeps the query's literal rule; the tree walker reports its result's
provenance like the VM does, so a try handler of either kind agrees.

An opaque by: key is evaluated again row by row when any conditional
tested a vector during the whole-column run, or when that run failed.

* perf(eval): clear only the VM header per compiled call

Every stack, return and trap slot is written before it is read and the
locals are set explicitly, so zeroing the whole exec block per call was
wasted work that outweighed a small body.

* fix(eval): compile a closure only when its captures have the expected shape

A closure compiles only with SYM keys and a LIST of values of equal
length; anything else stays on the tree walker, whose capture binding
reports the type error. The deserializer rejects a lambda whose closure
is not such a dict, and the compiled capture loads check the list type.

* test(serde): pin the closure shape check and size lambdas by their slot layout

The deserializer's rejection of a closure whose values are not a LIST now
has a test that fails when the check is reverted. Three tests built lambdas
with seven pointer slots while the heap walks every slot of the current
layout on retain and free; they and the shallow object-size report use
LAMBDA_SLOTS.

* fix(mem): count a closure's captures in both object-graph walkers

.mem.objsize stopped at a lambda's debug slot and the heap's retained
count skipped the capture-flag vector; both now walk the closure and
capture-flag slots. objsize.rfl pins that a closure outweighs the same
lambda without one.

* fix(eval): bind a closure's captures as locals of its compiled body

A compiled closure resolved names differently from the tree walker: a
let of a captured name allocated a new slot, a capture named like a
builtin resolved to the builtin, a captured self was the closure's
maker, a caller's local of the same name stood in for a capture inside
a window, and a closure with more than 255 captures read globals. The
compiler now binds every capture to a local slot in a prologue, a local
shadows special forms and builtins, self is never captured and loads
through OP_LOADSELF (also for a bare self in any compiled body), and a
closure past the slot limit stays on the tree walker.

A closure compiles on its second call, so one made and called once keeps
no bytecode; capture row flags are kept only for lambdas made inside a
query. A by: key body with an effect is evaluated row by row from the
start instead of whole first, so the effect runs once per row; a failed
whole-column run clears the raise payload and trace it leaves behind.

* test(eval): pin the capture-flag release, the self exclusion and the query-text lambda rule

The flag-release probe makes a lambda per row; the compiler no longer
skips a self capture itself, so the env exclusion is the one mechanism;
a lambda with nothing to capture written in query text keeps the column
rule for its quoted symbols.

* perf(eval): bind a compiled closure's captures with one opcode

OP_BINDCAPS stores every capture into its slot from one constant map
instead of a load and a store per capture, so a closure call pays one
dispatch for its prologue. The flag-release probe's lambda now captures
a row-flagged local, so a leaked flag vector shows.

* fix(eval): count a closure call once when deciding to compile

The VM's call opcode decided not to compile a closure on its first call,
then fell back to the tree-walk entry, which decided again and compiled.
The entry now takes the decision from its caller.

* fix(eval): return ends a lambda on the tree walker too

A closure compiles on its second call, so a return in its body gave the
identity on the first (interpreted) call and the early exit on the
compiled one. Inside a lambda the tree walker's return now parks the
value on the VM and raises a return marker that every form propagates;
the lambda's call boundary turns it back into the value, try lets it
through, and the VM's error path treats a marker from a tree-walked
special form as the frame's return. Outside any lambda return is still
its value.

* fix(query): never inline a closure into a query

The fallback inliner skipped a closure only once it had compiled, and a
closure now compiles on its second call, so a query inlined an uncompiled
closure's body and a captured name read a source column or a global of
that name; the planner's own inlining had the same hole. Neither inlines
a lambda with a closure. The window sync covers every slot of a frame,
and a swallowed whole-column key failure drops a pending return value.

* test(query): a closure as the by: key, pure and with an effect

* fix(agg): flag a grouped key column as nullable only when a null key was emitted

A fused where: can remove every null row of the key column, yet the
grouped result inherited the source column's may-have-nulls flag, while
the same query over the filtered table materialized first carried none,
so one result had two spellings on the wire (the nested select
differential fuzzer caught it). The key gather and the dense key emit
keep the flag only when a gathered key is null; symbol keys keep it as
before.

* feat(eval): compile a fn literal once as a template and share it across its closures

A closure's first call ran on the tree walker and later calls compiled,
so every engine difference showed up as call-count dependence, and each
closure compiled its own copy of the body. A fn literal inside a compiled
body is now compiled once, as a template, when the enclosing function
compiles; OP_MKCLOSURE makes each closure share that bytecode and capture
only the enclosing slots the body mentions, with their row flags.
OP_BINDCAPS binds captures by name, so a let that had not run leaves its
slot empty, and a read of an empty let slot (OP_LOADENVG) falls back to
the global, as the tree walker does. Closures no longer compile on their
second call; a lazy value is materialized when captured.

The by: key purity check follows a callee wherever it is bound (a global,
a parameter or let of an enclosing frame, a capture, a lambda literal
bound earlier in the body); a computed or unknown callee is an effect,
and builtins are pure unless they are system, I/O or a few that read
outside their arguments.

* perf(agg): settle a grouped key's null flag with a vectorised, pool-split scan

The per-key scan behind the flag fix was serial and showed on large
groupings. The scan is now a block-wise sentinel compare the compiler
vectorises, split across the pool for a large column, and shared by the
three emitters and by the legacy wide-key path, which seeded the flag
from the source column and could only ever set it; an F64 key, alone or
beside another key, now agrees with the materialized reference too.

* fix(eval): materialize a lazy slot when a compiled closure captures it

A lazy handle is single-use: materializing frees its graph in place, so a
closure dict holding the handle would fail the closure's second call.
Rayfall call sites materialize lazy arguments, but a caller of the C API
reaches OP_MKCLOSURE with one; lang/closure_lazy_capture_vm passes a real
lazy handle through call_lambda and calls the closure three times. The
Rayfall shape that claimed to cover this never captured a lazy and is
gone. A zero-parameter literal ([] parses as an empty I64 vector) now gets
a template too instead of a private compile per closure.

* fix(query): a text cell reduced from a column is a cell of the rows for concat

A grouped (concat (first s) 'zz) gave null for an empty symbol in a
grouped update and failed to produce one value per group in a grouped
select, while the ungrouped projection read the empty cell as empty text.
The planner's concat kernel takes a cell mask: an operand that reduces a
text column (first, last, min, max) is a cell, so a null there is empty
text and a STR cell keeps STR output. The grouped select's
arithmetic-of-aggregates wrappers run with the group result as the active
table and the hidden aggregate columns flagged as row-derived.

* fix(eval): an unbound capture, eval in closures, deep literals, lexical capture, parameters in the purity walk

A capture whose let had not run read as 0: capture slots are no longer
fixed, so an empty one falls back to the global or a name error like any
let slot. eval and resolve in a compiled body run inside a scope window
over its locals, so a closure's eval sees its captures. Template
compilation stops nesting at 32 levels and leaves deeper literals to the
dynamic path, which compiles one level per call. A tree-walked lambda
call marks its frames as the lexical boundary for capture, so a caller's
locals are not captured whether the maker compiles or not. The by: key
purity walk binds a callee's parameters to the call's argument text, so an
effect reached through a parameter counts and a pure helper passed as one
keeps the whole column.

* perf(eval): share one compiled template per literal for closures made by interpreted code

A closure made by the tree walker (the top level, a lambda that could not
compile, a literal inside a special form the compiler hands to the tree
walker) compiled its own copy of the body on its first call. ray_fn now
keeps a per-thread table of templates keyed by the literal's parameter
and body nodes (retained, so an address stays the entry's own) and its
capture names; a hit shares the bytecode, a miss compiles once. The table
is dropped with the runtime. Tests pin the sharing, two capture sets from
one literal, the block-boundary case of the key null scan and the
interpreted lazy capture.

* fix(eval): match a closure template on every body node

Two literals built for eval can share their parameter, first and last
nodes and differ in between; the template table compared only those, so
the second literal ran the first one's bytecode. An entry keeps every
body node (literals over eight forms are not cached) and a hit requires
all of them. A thread whose table outlived its runtime forgets its
entries instead of releasing objects of a destroyed heap, and ray_fn uses
the compiler's own closure shape gate.

* perf(eval): cheaper closure creation on the interpreted path

A template entry remembers the symbols its literal mentions, so a hit
costs a pass over the dict keys instead of a walk of the body and
allocates nothing; the capture walk gathers row flags in a local buffer
and builds the flag vector only when one is set. Closures made and never
called by an interpreted maker: 249 ms per 200k before, 202 after (dev
121); the fn literal inside or: 136 to 98 ms per 200k.

* fix(eval): closure bodies resolve free names when they run; eval's window wraps only the call

The eval/resolve window opened before the arguments were compiled, so a
return, a let or a call inside an argument ran in the window and a
return leaked the frame; it now opens right before the call and closes
right after it. A closure body resolves the builtins it names when it
runs instead of baking the one the template saw, and a top-level
literal's baked template is recompiled once a function-valued global was
rebound (ray_env_fn_generation). The VM's general call applies the same
elementwise map, concat rule and provenance join as the dedicated
opcodes. The symbol walk and the compiler stop at 1024 and 2048 levels; a
literal holding more than 65536 vector elements is not kept in the
template table; a body that did not compile is marked and never retried.
The by: key purity walk binds a passed lambda's parameters as unknown,
lets a nested literal see the enclosing body's parameters, and resolves
an argument's name where the call was written. A grouped update expands
its helper expression once instead of per group.

* fix(query): the grouped update's planner skip is a flag of the call, not a remembered expression

A remembered expression pointer could match a fresh expression at a
reused address and skip the planner for it, turning a schema error into
a name error. The flag now belongs to one grouped update. The OP_RET
window unwind was unreachable once eval's window wraps only the call and
is gone; the deep-literal test runs at the depth the sanitizer build's
recursive release survives.

* test(lang): pin the compiler's depth guard by result, within every platform's stack

The regression test for the depth guard built a 12000-level literal and
relied on the sanitizer build surviving its recursive release; the macOS
one does not. The body is now a chain of nested ifs: compiled it runs as
jumps and returns the parameter, on the tree walker it recurses and stops
at the eval depth limit. 600 and 1500 levels return 1, 2500 (past the 2048
guard, within the bytecode jump range) is a limit error; with the guard
removed it compiles and returns 1. The syntax page states the limit; the
control-flow page's anchor to the lambdas section now resolves.

* fix(query): concat cell flags cover every operand, and the planner takes 255

The text kernel read its per-operand cell flags from a 64-bit mask that was
written for the first 64 operands only, so operand 64 shifted by 64. The
flags are one byte per operand for all 255 positions, in the planned node
and the two-operand path alike. The planner folded a query concat into one
node up to 16 operands and left longer forms to the binary builtin, which
rejected them; it now takes the executor's 255, so one limit governs the
form. Tests run 65-operand concats: a cell at position 0 with a null
literal at 64 nulls the row, a cell at 64 is a cell (empty text, STR
output), a column joins every row.

---------

Co-authored-by: Serhii Savchuk <ser.vasilich@hotmail.com>

* fix(query): sort and take a no-output grouping on a computed key (#772)

* fix(query): sort and take a no-output grouping on a computed key

A grouped select with a computed by: key and no outputs falls back to
eval-level grouping and returned its first-of-group rows directly, so
asc:, desc: and take: were dropped and groups came back in first-seen
order. It now goes through apply_sort_take, as the plain-key branch
does.

* fix(query): gather a no-output computed-key grouping's columns by type

The no-output computed-key branch built its result columns cell by cell
through store_typed_elem, which has no STR or LIST case: STR cells were
left unset (empty, or strings left from an earlier result, and sorting
them aborted on an unset string pool) and a LIST column failed. The key
and source columns are now gathered at each group's first row with
ray_group_gather, which builds every column type, and a failed gather
returns its error.

* fix(query): keep a LIST-valued computed key and reject a misaligned one

A computed `by:` key with no outputs gathered its key column only when
the key was a typed vector, so a LIST-valued key lost both the key and
its source column, in the grouped and in the empty-result path.

A key whose length is neither the row count nor 1 was grouped as is,
and the gathered columns were then read at group rows past their end.
It now fails with `length`.

* fix(group): release the partial list when a LIST gather append fails

ray_group_gather's LIST arm assigned the result of ray_list_append back
to its output, so a failed append dropped the list it had built so far.

* fix(query): group a no-output select by a global's value, not as a column

A bare `by:` name that is not a column of the table, such as a global
vector, was still taken for a column on the no-output path, and the
missing column was dereferenced.  Such a name now takes the computed-key
path, as the grouping before it already evaluated it.

* fix(query): release the where selection on the no-output grouping path

With `where:` and `by:`, the filter keeps an extra reference to its row
selection that is released only at the end of the select.  The
no-output grouping returns earlier, so each call leaked the selection.

* fix(query): group a no-output select by a local that shadows a column

A `by:` name bound both as a local and as a column was grouped by the
local's value, but the no-output path then read the column's key slot,
which the grouping never filled, and returned garbage or crashed on a
LIST column.  Such a key now takes the computed-key path, and a missing
key column is an error instead of a read of unset memory.

* fix(query): release a by-dict's symbol vector on the no-output paths

The vector of by-dict or quoted key names was released only at the end
of the select, so the no-output grouping and the eval-level grouping,
which return earlier, leaked it on every call.

* fix(query): release the where selection on every exit of a select

The filter's retained row selection was released only on the success
path at the end of the select, so every error return with `where:` and
`by:` leaked it.  The select body now hands the selection to a thin
wrapper that releases it whichever way the body returns.

* fix(eval): free the error a `try` catches

Neither `try` implementation freed the caught error, and a rethrow left
its trace frames behind.  Inside a function, the compiled path also
handed a runtime error object to the handler; it now passes 0, as the
top level does and the error guide documents.

* fix(query): group a no-output select by an atom as one group

An atom `by:` key, such as a global scalar, failed on the no-output
path with a type error.  It is now broadcast to one group, as a
length-1 key is.  A function used as a key now fails with a domain
error that says so, instead of a vector-type error.

* fix(query): keep every column of a no-output by-dict select with where

With no outputs, the where prefilter narrowed the table to the columns
the clauses read, so the result lost the other columns and their order.
The prefilter now projects only when there are outputs.

* fix(query): run a no-output grouped select over a parted table

Every no-output grouped select without `where:` failed on a parted
table with a vector-type error.  It now flattens the table before the
paths split.

* fix(query): release the by-dict symbol vector on every exit of a select

The by-dict and quoted key name vector joins the where selection in the
select wrapper, so the error returns that skipped its release no longer
leak it, and the per-return releases go away.

* fix(query): free compile errors and failed fast-path results

A key the compiler rejected left its error on the graph, and freeing
the graph released it with ray_release, which skips errors, so it
leaked.  Failed top-k, fused take, vector allocation and asof operand
results were dropped the same way; all now go to ray_error_free.

* perf(query): gather only the kept rows of a parted no-output select

Flattening the whole parted table to return one row per group cost the
full table in memory.  The select now runs on a narrow table that
flattens only the columns its clauses read and stands in a row number
for every other column, then gathers just the kept rows from the
partitions.  Tables wider than the gather's column limit still flatten.

* fix(query): count quoted key and sort names as columns on parted tables

The parted no-output select gathered only the columns its clauses
referenced, and skipped quoted symbols, so `by: 's` or `desc: 'h` was
grouped or sorted by a row-number stand-in.

* perf(query): carry one row number through a parted no-output select

Each unreferenced column was a row-number stand-in of its own, so a key
with many groups gathered one row vector per column and could use more
memory than flattening the table.  The narrow table now holds the
referenced columns and one hidden row number, and the result is rebuilt
in the flattened layout from the kept rows.

* fix(query): drop the filter's reference when where: keeps every row

When every row passed, the filter returned the input table itself with
an extra reference, which the no-output and the eval-level grouping
never released, so the whole input leaked on every call.

* test(query): cover the try trace clear and two error and parted cases

A caught error raised through a nested call, a sorted parted select
whose every row is its own group, and a failing asof operand on a
parted table now each have a check.

* fix(group): tell a null key from a function key

The function-key check also caught the null and lazy types.  A null key
now has its own message, a lazy key is materialised first, and only
the function types report a function.

* fix(query): materialise a lazy computed key and keep where on a parted key

A lazy computed key, such as a moving max, failed the no-output path's
grouping.  On a parted table, a where: on the key column was deferred
past the grouping, which a no-output select never reaches, so its rows
went unfiltered into the row path.

* fix(query): sort a parted table without by: by gathering the kept rows

A sort without `by:` that neither streaming path took fell through to
the flat sort, which read parted columns as plain ones and failed with a
type error, and its take form leaked.  The sort now runs over the sort
keys and a row number, then gathers the kept rows from the partitions
with the row gatherer the no-output grouping uses.  Shapes it declines
flatten the table first.  The tests that pinned the type error compare
with the flattened table instead.

* refactor(query): drop the redundant key-name entry of the parted grouping

The derived key name already covers `key`, so the separate entry did
nothing.  A test now pins the key-name guard itself.

* fix(query): decline the parted row gather when __row is a user name

The parted no-output grouping and sort carry a hidden `__row` column and
declined only when the table had one.  A by-dict alias or a bound local
or global of that name shadowed it, giving an error or silently grouping
by row number.  Any such use now falls back to the flattened table.

* refactor(group): drop the unreachable lazy-key materialise

Every expression key reaches this check already materialised; the
computed-key paths in query.c do the work and stay.

* fix(query): keep parted sorts bounded and decline output alias chains

A parted sort without by: flattened the table whenever a sort key was
not output under its own name, though the narrow sort reads keys as
columns either way.  It now declines only when an output names another
output's alias, which the flat path reads as the alias and the gather
read as the source column, a silent wrong result.

* fix(query): stream a parted top-k only when the sort key is its own column

The parted top-k accepted a sort key that is an output alias of another
column: each partition sorted by the column, the merge by the output,
so the take kept the wrong rows.

* fix(query): flatten a parted no-output select whose clauses run code

The parted row gather builds a narrow table from the columns its clauses
name, so a column read inside a helper, or a name built at run time,
was missing from it or resolved to the hidden row column.  Clauses that
call a lambda, call a computed head, read a container that may hold
lambdas, or evaluate text now take the flattened table.

* refactor(query): drop the bound-name check of the parted row column

With code-running clauses declined, a bound `__row` can only be shadowed
through a name the select writes, which the clause check covers.  A
test pins a helper that reads a bound `__row`.

* fix(exec): keep no rows for a sort with take: 0

The sort-and-head fusion passed the take to the sort, which reads a
limit of 0 as no limit, so a sorted select with `take: 0` returned every
row.  The fusion now applies only to a positive take.

* fix(query): check each by-dict value on its own for code in a parted select

The run-code check passed a by-dict's value list through the call-head
rule, so a dict whose first value was a builtin call looked like a
computed call and flattened the whole parted table.

* fix(query): follow a symbol binding to a lambda when checking for code

A dict literal stores its values unevaluated, so `{f: hy}` holds the
symbol `hy`, and calling `(D.f 0)` reaches the lambda through one more
lookup.  The parted run-code check stopped at the symbol and took the
bounded path, which then missed the helper's columns.  A binding that is
a symbol naming a lambda now counts as code, for plain and dotted names.

* fix(query): evaluate a collection concat by: key over the whole column

The planner keeps concat for text, so a bare by: key that concatenates
collections no longer lowers.  Only helper keys fell back to the
whole-column evaluation; a bare concat key failed with "failed to compile
group key expression", even when aligned with the rows.  Route keys that
call concat to the same fallback: an aligned one groups, a misaligned one
is refused with length.

* test(mem): measure top-level try per iteration, not per statement

A handler written as a lambda literal adds an entry to the closure
template cache once per statement (bounded at 256 entries).  Run each
top-level try statement 1000 and 4000 times and require the same cost,
so the probe still catches a per-iteration leak of the caught error.

* fix(query): evaluate any by: key the planner cannot lower over the whole column

A bare computed by: key with no helper, such as (reverse x), (asc x) or
(til (count x)), failed with "failed to compile group key expression".
Only helper and concat keys took the whole-column fallback.  Try to lower
every computed key and send any key that does not lower to the same
fallback: it groups as its lambda form does, honours where:, desc: and
take:, and a key of another length is refused with length.  A text
concat still lowers and stays on the planned path.

* fix(query): evaluate an unlowered by: key over parted columns flattened

The whole-column key fallback evaluated the key over a parted table's
partition columns and failed with "key expression must yield one value
per row".  With outputs, flatten the columns the select reads (the whole
table when a clause runs code whose reads are unseen) before evaluating
the key.  With no output, leave the select to the first-rows path, and
when that path declines, run the select again over the flattened table
so the key reaches the fallback there.

* fix(query): report an unknown column in an unlowered by: key as schema

A key that lowers to the planner reports an unknown column as schema
("column 'c' not found"); one evaluated over the whole column reported
the evaluator's name error.  Before evaluating, look for a free name in
the key that is no column and no bound name, outside any lambda written
in it, and report it as schema.  An unknown function stays a name error.

* perf(group): check for a function or null key inside the scalar-key branch

The check that refuses a by: key bound to a function or to null sat as
its own early return in exec_group_run, with a release loop of its own.
It slowed computed keys on that path by 5-12% at 1M rows (bare bool,
mod, like and if keys), through the code built for the function rather
than the check itself.  Make it part of the existing scalar-key branch,
which already returns its errors, and build the error in a cold
out-of-line helper.  The keys time as on dev again; the errors are
unchanged.

* fix(query): let and set in a do bind their name for an unlowered key

The unknown-column check before the whole-column key evaluation reported
a name the key binds itself, `(do (let q x) (reverse q))` or
`(do (set q x) (reverse q))`, as an unknown column.  A two-argument let
or set now binds its name for the forms after it, and only its value is
checked.  The four-form let the check assumed is no let form.

* fix(query): evaluate an unlowered by-dict value over parted columns flattened

A by-dict value the planner cannot lower, a helper `{k: (fk)}` or a
builtin with no kernel `{k: (reverse i)}`, failed over a parted table:
the parted by-dict deferral compiled it ("failed to compile group key
expression"), and the eager path evaluated it over the partition
columns.  The deferral now takes only values that lower; the eager path
first flattens the columns the select reads (the whole table with no
output, or when a clause runs code whose reads are unseen).

The where: prefilter before a computed key no longer narrows the table
to the columns the clauses name when a clause calls a helper: the
helper's own column reads failed with a name error, flat or parted.

* fix(query): keep projecting a where: prefilter that reads a container

The where: prefilter before a computed key stopped narrowing the table
whenever a clause named a variable bound to a list, dict or table, since
it used the parted gather paths' test, where such a container may hold
lambdas.  A where: reading a config dict then filtered every column of a
wide table.  The prefilter now declines only for code that reads columns
the clauses do not name: a lambda, a name bound to one or to its name,
a computed callee, eval, parse, resolve, load or value.  The parted
gather paths keep the container rule.

* fix(query): know code and binders by the builtin, not by the call's spelling

Two checks in the by: key paths matched names as written.

The unknown-column check before the whole-column key evaluation followed
let and set only as siblings in one form, so a name bound in a nested
do or an if, `(f (do (do (set q x)) q))`, was refused as an unknown
column.  The check exists only to pick the error code: it now runs only
on keys that bind no name and run no code, and leaves every other key to
the evaluator.

The test for code whose column reads a clause does not show matched
fn/eval/parse/resolve/load/value by the spelling of the call head, so a
builtin called by another name, `(set getv resolve)` then
`{k: (getv rname)}`, took the parted narrow-copy path and read its
private row-number column.  Names are now resolved through locals,
globals, dotted paths and one name hop, and a builtin is known by the
name it was registered under.  A name of a column of the table is the
column.

* fix(query): resolve clause names as the evaluator does, objects included

The checks that classify a clause's names and nodes read a name as a
column before looking for a lexical local, so a parameter or let that
hides a column was taken for the column:
- a local lambda named after a column, `(run P (fn [] y))` with
  `{k: (x)}`, was not seen as code, and the parted narrow copy left out
  the column it reads;
- a binder passed as a parameter, `(run set)` with `(x q y)`, was not
  seen as one, and the unknown-column check refused the key;
- the parted sort path gathered the column for an output `x: x` that
  reads a parameter x.
A lexical local now comes first, then the query column, then a global.

A builtin placed in a built query as the object itself, `(list resolve
rname)`, was not recognised as code (and `set`, `let` or `quote` as a
binder); the checks now look at the object as well as at names.

* fix(query): sort a parted table by gathering only stored output columns

The parted sort path took any output name that reads a column, a dotted
field of one included (`month: date.mm`), and then looked for a stored
column of that exact name to gather, failing with "lost a column of a
parted table".  An output it gathers must be a stored column, and one no
lexical local hides; any other leaves the select to the flat sort.

* perf(import): bound parquet symbol import and splayed hash-index I/O (#773)

* perf(parquet): import dictionary counters under RAY_CSV_TRACE

Each symbol pass reports the strings it interned, batch-local repeats,
global probes, hits, inserts, slots read, false tag matches, table
growth and the dictionary sizes, so the import's access pattern can be
measured on real data.

* perf(store): allocate SYM stream lookup tables on first remap

A SYM column writer allocated and cleared its runtime-to-file lookup
tables at open, though a writer fed only chunks already in its target
domain never reads them. They are now made by the first chunk that needs
a remap; the parquet imp…
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.

2 participants