Skip to content

feat(expr): partition struct validity as a first-class coordinate#8874

Draft
joseph-isaacs wants to merge 1 commit into
developfrom
claude/partitioning-validity-coordinate-uolmq0
Draft

feat(expr): partition struct validity as a first-class coordinate#8874
joseph-isaacs wants to merge 1 commit into
developfrom
claude/partitioning-validity-coordinate-uolmq0

Conversation

@joseph-isaacs

Copy link
Copy Markdown
Contributor

Rationale for this change

Partitioning a nullable struct scope decomposes it into n+1 coordinates (n fields plus the struct's own validity), but only the n fields had expression-level names: replace_root_fields rewrote $ into pack(f: $.f, …), whose validity is hard-wired lit(true). The validity coordinate was dropped from the term and re-applied by StructReader as a positional side-channel — projection-only, at a syntactically guessed position. Consequences, each covered by a new failing-first test:

  • Validity observers were constant-folded through pack's always-valid claim: is_not_null($) filtered to all-true, is_null($) projected wrongly.
  • The filter path never read the validity child, so predicates on fields matched the bytes stored under null rows.
  • Validity-independent expressions were over-masked: projecting lit(1) over a nullable struct returned null at null rows.
  • The side-channel guessed mask placement from the root expression's shape (is_pack_merge), which cannot be correct for every expression.
  • The validity child was addressed by the user-space name "validity", collidable with a real field of that name.

This PR makes the validity coordinate part of the expression term, so it is routed, read, and re-applied by the same generic partitioning machinery as fields. It is the first part of the plan discussed for #1907; follow-ups (generic mask-distribution over non-null-sensitive infallible fns, and(m, mask(x, m)) → and(m, x) to restore full filter pushdown over nullable structs, a per-fn is_null_sensitive audit including GetItem, and sub-expression dedup in the partition splitter) restore pushdown depth and are pure optimizations — this PR is the correctness core.

What changes are included in this PR?

vortex-array

  • replace_root_scope (expr/transform/replace.rs): expands a nullable struct scope with the identity $ == mask(pack(f: $.f, …), is_not_null($)), so the validity is read and re-applied exactly where $ occurred. Non-nullable scopes expand to the plain pack as before, and the existing mask(x, lit(true)) simplification collapses the identity for scopes that turn out non-nullable.
  • StructPart { Field(FieldName), Validity } + make_struct_part_annotator (expr/analysis/struct_part.rs): annotates is_not_null($) as the validity coordinate and fields as before; partitions are routed by annotation, not by field name, removing the "validity" name collision.
  • is_null / is_not_null absorb a child's vtable-derived validity expression via the new ScalarFnRef::validity_opt (the Option-returning form; the existing validity() fallback of is_not_null(child) would make the rewrite a self-referential no-op). With Mask::validity() = and(child_validity, m), is_not_null(mask(pack(..), V)) folds to V.
  • get_item distributes over mask (get_item(f, mask(s, m)) == mask(get_item(f, s), m)), keeping single-field projections narrow (they read the field plus the validity child, not every field).

vortex-layout

  • StructReader partitions with the StructPart annotator, routes Validity partitions to child 0, and steps validity partitions into the bool child's scope (is_not_null($) → $). The projection-time validity side-channel (validity_fut + is_pack_merge guessing) is deleted: the mask node in the partitioned root re-applies validity at struct level through the existing Mask kernel, and the existing null_as_false at the filter boundary converts validity-induced nulls to excluded rows. The filter and pruning paths now see the validity coordinate through the same partitioning as projection.

Tests (the five new StructReader tests fail before this change; verified against the parent commit)

  • filter is_not_null($) over a nullable struct returns the struct validity instead of all-true
  • filter eq($.a, 7) does not match a row whose stored bytes satisfy the predicate under a null struct row
  • projection is_null($) returns the negated validity instead of a mis-masked constant
  • projection pack(x: lit(1)) is not nulled at null struct rows
  • a real field named "validity" projects as the field while is_not_null($) still routes to the struct's validity

Plus expression-level tests for the expansion identity, the absorb rules, the get_item/mask distribution, and partitioning and(is_not_null($), $.a > 5) into exactly {Validity} and {Field(a)} coordinates.

Checks run: cargo nextest run -p vortex-array -p vortex-layout -p vortex-file -p vortex-scan -p vortex-datafusion (3611 tests pass), cargo test --doc -p vortex-array, cargo +nightly fmt --all, cargo clippy --all-targets on the affected crate closure (clean). Not run: workspace-wide --all-features clippy (the sandbox lacks protoc for lance-encoding and network access for the vortex-duckdb build script — failures are environmental and unrelated to this change).

What APIs are changed? Are there any user-facing changes?

  • New public APIs: expr::transform::replace_root_scope, expr::analysis::{StructPart, make_struct_part_annotator}, ScalarFnRef::validity_opt. No existing signatures changed (replace_root_fields is kept).
  • Behavioral changes are bug fixes visible to readers of nullable structs: filters now respect struct-level nulls (previously they could match garbage bytes under null rows), is_null($)/is_not_null($) observe real validity, and total expressions are no longer masked. Filters over nullable structs currently evaluate at struct level (partitions per referenced field + validity) rather than fully pushed down; pushdown is restored by the follow-up rewrite rules.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Fo6HoW3WV5UAKwVoymZjyf


Generated by Claude Code

Partitioning a nullable struct scope decomposes it into n+1 coordinates,
but only the n fields had expression-level names: `replace_root_fields`
rewrote `$` into `pack(f: $.f, ...)`, whose validity is hard-wired
`lit(true)`. The validity coordinate was dropped from the term, then
re-applied by the struct reader as a positional side-channel (projection
only), which:

- constant-folded validity observers (`is_not_null($)` -> `lit(true)`,
  `is_null($)` -> `lit(false)`) through pack's always-valid claim,
- let filter predicates match the bytes stored under null rows (the
  filter path never read the validity child at all),
- masked validity-independent expressions (`lit(1)` came back null),
- guessed the mask position from the root expression's shape
  (`is_pack_merge`), which is wrong for some expressions,
- addressed the validity child by the user-space name "validity",
  collidable with a real field of that name.

This change gives the validity coordinate a name in the term and routes
it like any field:

- `replace_root_scope` expands a nullable struct scope with the identity
  `$ == mask(pack(f: $.f, ...), is_not_null($))`, so the validity is
  read and re-applied exactly where `$` occurred. For non-nullable
  scopes the expansion is unchanged, and `mask(x, lit(true))`
  simplification collapses the identity back to the plain pack.
- `StructPart { Field, Validity }` annotations (and
  `make_struct_part_annotator`) route the coordinate by annotation, not
  by field name.
- `is_null`/`is_not_null` absorb a child's derived validity expression
  (via the new vtable-level `ScalarFnRef::validity_opt`), so
  `is_not_null(mask(pack(..), V))` folds to `V` instead of `lit(true)`.
- `get_item` distributes over `mask`, keeping single-field projections
  narrow: `get_item(f, mask(s, m)) == mask(get_item(f, s), m)`.
- `StructReader` routes `Validity` partitions to child 0 and the
  projection-time validity side-channel is deleted; the `mask` node in
  the partitioned root re-applies the validity at struct level via the
  existing Mask kernel, and `null_as_false` already handles the filter
  boundary.

The new struct reader tests fail before this change: filters observed
garbage under null rows and folded away null-checks, projections masked
literals and mis-evaluated `is_null($)`, and a field named "validity"
shadowed the struct's own validity.

Follow-ups (tracked separately): generic mask-distribution over
non-null-sensitive infallible fns, `and(m, mask(x, m)) -> and(m, x)` to
restore full filter pushdown, per-fn `is_null_sensitive` audit
(including `GetItem`), and sub-expression dedup in the partition
splitter.

Fixes #1907.

Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fo6HoW3WV5UAKwVoymZjyf
@codspeed-hq

codspeed-hq Bot commented Jul 21, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 25.72%

⚡ 2 improved benchmarks
✅ 1794 untouched benchmarks
⏩ 46 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation copy_nullable[65536] 1.4 ms 1 ms +31.91%
Simulation copy_non_nullable[65536] 1,094.9 µs 913.8 µs +19.81%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/partitioning-validity-coordinate-uolmq0 (66a2395) with develop (5746d55)

Open in CodSpeed

Footnotes

  1. 46 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Polar Signals Profiling Results

Latest Run

Status Commit Job Attempt Link
🟢 Done 66a2395 1 Explore Profiling Data

Powered by Polar Signals Cloud

@github-actions

Copy link
Copy Markdown
Contributor

Benchmarks: Vortex queries 📖

Verdict: No clear signal (low confidence)
Attributed Vortex impact: +2.0%
Engines: DataFusion No clear signal (+8.3%, low confidence) · DuckDB No clear signal (-4.0%, low confidence)
Vortex (geomean): 0.979x ➖
Parquet (geomean): 0.970x ➖
Shifts: Parquet (control) -3.0% · Median polish -2.3%

How to read Verdict and Engines
  • Verdict: Overall PR-level signal after subtracting baseline drift estimated from Parquet control rows. It can be Likely improvement, Likely regression, or No clear signal.
  • Engines: Per-engine attribution. DataFusion is compared against DataFusion/Parquet controls; DuckDB is compared against DuckDB/Parquet controls. This answers whether each engine improved or regressed independently.
  • Confidence: Based on directional consistency, share of rows above the noise floor, and control-run noise.

datafusion / vortex-file-compressed (1.021x ➖, 0↑ 0↓)
name PR 66a2395 (ns) base 5746d55 (ns) ratio (PR/base)
vortex_q00/datafusion:vortex-file-compressed 9537578 9410240 1.01
vortex_q01/datafusion:vortex-file-compressed 6186878 6017158 1.03
datafusion / parquet (0.943x ➖, 0↑ 0↓)
name PR 66a2395 (ns) base 5746d55 (ns) ratio (PR/base)
vortex_q00/datafusion:parquet 19650676 21234103 0.93
vortex_q01/datafusion:parquet 4475843 4660522 0.96
duckdb / vortex-file-compressed (0.958x ➖, 0↑ 0↓)
name PR 66a2395 (ns) base 5746d55 (ns) ratio (PR/base)
vortex_q00/duckdb:vortex-file-compressed 10048353 10242605 0.98
vortex_q01/duckdb:vortex-file-compressed 6020542 6436835 0.94
duckdb / parquet (0.997x ➖, 0↑ 0↓)
name PR 66a2395 (ns) base 5746d55 (ns) ratio (PR/base)
vortex_q00/duckdb:parquet 23338523 23381736 1.00
vortex_q01/duckdb:parquet 9408241 9440194 1.00

No file size changes detected.

@joseph-isaacs
joseph-isaacs marked this pull request as draft July 21, 2026 10:50
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.

Expression partitioning to respect scope validity

1 participant