Skip to content

fix(index): normalize same-column scalar index predicates#6782

Merged
wjones127 merged 2 commits into
lance-format:mainfrom
zouhuajian:fix/scalar-normalize
Jul 22, 2026
Merged

fix(index): normalize same-column scalar index predicates#6782
wjones127 merged 2 commits into
lance-format:mainfrom
zouhuajian:fix/scalar-normalize

Conversation

@zouhuajian

@zouhuajian zouhuajian commented May 14, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR builds on #7477 instead of introducing a separate scalar-index normalization path.

#7477 added ScalarIndexExpr::optimize(), merged half-open range predicates, and applied the optimizer before scalar-index execution. This PR extends that optimizer with the remaining rules and edge-case handling originally proposed in #6782:

  • accumulate planner-produced same-key ranges within one AND region and keep the tightest bounds
  • remove an exact IS NOT NULL term when another exact same-key predicate is already null-intolerant
  • preserve conservative boundaries around OR branches, NOT context, NULL values, index identity, recheck requirements, and fragment coverage

This refactoring follows the review request to describe the behavior as optimizer rules and reuse the optimizer introduced by #7477.

Behavior

Within one contiguous AND region:

  • compatible SargableQuery::Range predicates with the same column, index name, and index type are intersected
  • the merged range keeps needs_recheck = true if any input range requires recheck, preserving perf: merge half-open range queries on the same BTree index #7477 behavior
  • ranges merge only when their fragment coverage values match; differing or known-versus-unknown coverage remains separate
  • reverse bounds such as x >= 200 AND x <= 100 remain a valid empty range representation
  • ranges with incomparable values or NULL bounds remain separate
  • an exact IS NOT NULL predicate is removed only when another exact same-key predicate is null-intolerant
  • recheck predicates cannot remove IS NOT NULL
  • OR branches are optimized independently
  • range merging remains enabled inside NOT subtrees, while IS NOT NULL elimination is disabled there to preserve SQL NULL semantics

Complexity

Each AND region is flattened once and processed with keyed accumulator positions in a single pass. This replaces the previous pairwise scan with expected O(n) leaf processing and derives the tightest range across planner-produced compatible terms.

The implementation moves leaves into stable output slots instead of cloning complete ScalarIndexSearch values. The AND tree is rebuilt in balanced form to avoid deep planner-generated conjunctions.

Validation

Coverage includes:

  • tightest bounds across multiple same-key ranges
  • inclusive and exclusive bounds
  • empty ranges
  • needs_recheck propagation
  • matching, differing, and unknown fragment coverage
  • exact IS NOT NULL elimination
  • standalone NULL checks
  • different columns and index identities
  • IN predicates with and without NULL
  • != predicates
  • OR and NOT boundaries
  • parser-generated predicates
  • large balanced AND regions
  • a runnable public optimize() example

Validated locally with:

  • cargo test -p lance-index --lib
  • cargo test -p lance-index --doc 'scalar::expression::ScalarIndexExpr::optimize'
  • cargo test -p lance io::exec::scalar_index --lib
  • cargo test -p lance io::exec::count_pushdown --lib
  • cargo clippy -p lance-index --all-targets -- -D warnings

Summary by CodeRabbit

  • Bug Fixes
    • Improved scalar filter optimization to merge range predicates only when index/metadata context matches, reducing incorrect results.
    • Added safer handling for incomparable range bounds and ranges involving NULLs.
    • Strengthened optimization behavior within NOT expressions to preserve SQL NULL semantics, including more conservative IS NOT NULL removal.
    • Refined merged-range recheck behavior, including cases where the merged intersection becomes empty.
  • Tests
    • Updated and added unit tests for metadata gating, NULL-bound scenarios, and empty-intersection merging behavior.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@github-actions github-actions Bot added the bug Something isn't working label May 14, 2026
@zouhuajian

Copy link
Copy Markdown
Contributor Author

The metrics/logging added in #6759 makes this change easier to observe and validate: after same-column predicate normalization, the scalar index planner should select a much narrower candidate part range for bounded range queries.

@zouhuajian

Copy link
Copy Markdown
Contributor Author

@Xuanwo Could you help review this when you have time? Thanks.

@zouhuajian
zouhuajian force-pushed the fix/scalar-normalize branch from f72b67e to 99ed91e Compare May 28, 2026 09:10

@Xuanwo Xuanwo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  1. The range merge stops after pairing a bound with the first opposite bound instead of deriving the tightest range across the same-key conjunction. Filters with duplicate lower or upper bounds can still evaluate a wider bounded branch plus extra single-sided branches, leaving part of the broad-branch cost this change is meant to remove.
  2. The normalization pass repeatedly clones, flattens, linearly scans, and removes terms while the existing guard limits nesting depth rather than total AND terms. Large balanced filters can make scalar-index planning consume quadratic CPU and elevated memory before execution starts.

Comment thread rust/lance-index/src/scalar/expression.rs Outdated
Comment thread rust/lance-index/src/scalar/expression.rs Outdated
@github-actions github-actions Bot added the A-index Vector index, linalg, tokenizer label Jun 21, 2026
@zouhuajian
zouhuajian force-pushed the fix/scalar-normalize branch from e7c32a4 to 94911e1 Compare June 21, 2026 13:03
@zouhuajian
zouhuajian requested a review from Xuanwo June 24, 2026 08:09
@wjones127
wjones127 self-requested a review July 7, 2026 15:34

@wjones127 wjones127 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In #7477, we added a ScalarIndexExpr::optimize method. I think this would be a good case to re-use that. Would you be willing to refactor around using that? These all look like they could be described as optimizer rules.

Reuse ScalarIndexExpr::optimize to normalize same-key scalar predicates.

- merge compatible ranges into their tightest intersection
- preserve needs_recheck and fragment coverage semantics
- remove redundant exact IS NOT NULL predicates
- process AND regions in a single pass and rebuild balanced trees
- preserve OR, NOT, NULL, and incompatible-range boundaries
@zouhuajian
zouhuajian force-pushed the fix/scalar-normalize branch from 94911e1 to b98cfd8 Compare July 10, 2026 08:01
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Scalar index expression optimization now performs conservative, metadata-aware range rewrites, preserves NOT subtree semantics, handles NULL and incomparable bounds safely, and expands tests for fragment coverage and recheck behavior.

Changes

Scalar Index Optimization

Layer / File(s) Summary
Range keys and bound safety
rust/lance-index/src/scalar/expression.rs
Range grouping includes column, index name, and index type; bound tightening avoids incomparable or NULL-containing merges; query helpers preserve recheck metadata.
Metadata-aware optimizer validation
rust/lance-index/src/scalar/expression.rs
Tests update leaf collection for traversal context and cover fragment coverage gating, empty intersections, recheck propagation, and metadata-bearing range construction.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: westonpace, charleshuang119, wjones127

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: normalizing same-column scalar index predicates.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@zouhuajian

zouhuajian commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

In #7477, we added a ScalarIndexExpr::optimize method. I think this would be a good case to re-use that. Would you be willing to refactor around using that? These all look like they could be described as optimizer rules.

Thanks for the suggestion. I’ve refactored the changes to extend ScalarIndexExpr::optimize() introduced in #7477 instead of maintaining a separate normalization path.

The additional behaviors are now implemented as optimizer rules within each AND region, including redundant IS NOT NULL elimination and tighter same-key range accumulation.

@zouhuajian
zouhuajian requested a review from wjones127 July 10, 2026 08:21
Comment thread rust/lance-index/src/scalar/expression.rs Outdated
@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.55783% with 44 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance-index/src/scalar/expression.rs 93.55% 28 Missing and 16 partials ⚠️

📢 Thoughts on this report? Let us know!

@zouhuajian
zouhuajian requested a review from wjones127 July 14, 2026 02:53

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
rust/lance-index/src/scalar/expression.rs (3)

1624-1635: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Public optimize() doc lacks an example and cross-links.

The docstring on this public entry point describes range-merge/recheck/IS-NOT-NULL/NOT semantics but has no usage example and doesn't link ([...]) to the helper methods/types it relies on (e.g. ScalarIndexSearch, try_merge_range).
As per coding guidelines, "All public APIs must have documentation with examples, and documentation should link to relevant structs and methods."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-index/src/scalar/expression.rs` around lines 1624 - 1635, The
public ScalarIndexExpr::optimize documentation needs a usage example and links
to the relevant implementation symbols. Extend its doc comment with a concise
Rust example demonstrating optimization and intra-documentation links to
ScalarIndexSearch and try_merge_range, while preserving the existing behavioral
description.

Source: Coding guidelines


1624-1837: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Consider extracting the new AND-region normalization logic into a submodule.

optimize_with_context, optimize_and_tree, optimize_and_leaves, collect_and_leaves, rebuild_and_tree, and the key/merge helpers add a self-contained optimizer pass inline into an already very large file (tests alone run past line 5400). As per coding guidelines, "Extract substantial new logic such as bin packing or scheduling into dedicated submodules instead of inlining it into large files."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-index/src/scalar/expression.rs` around lines 1624 - 1837, Extract
the AND-region normalization pass from ScalarIndexExpr into a dedicated
submodule, including optimize_with_context, optimize_and_tree,
optimize_and_leaves, collect_and_leaves, rebuild_and_tree, the query-key
helpers, and try_merge_range. Keep ScalarIndexExpr::optimize as the public entry
point and preserve the existing optimization behavior and interfaces while
relocating the implementation out of the large expression module.

Source: Coding guidelines


1711-1727: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

collect_and_leaves skips optimization for nested Not leaves under And. other => leaves.push(other) bypasses optimize_with_context, so And(Not(And(...range...)), ...) never merges the inner range. Route the catch-all through optimize_with_context so every leaf gets the same context-aware rewrite.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-index/src/scalar/expression.rs` around lines 1711 - 1727, The
catch-all branch in collect_and_leaves currently inserts leaf expressions
without optimization. Update it to pass other through optimize_with_context
using is_not_null_elidable before adding it to leaves, while preserving the
existing recursive And handling and independently optimized Or handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@rust/lance-index/src/scalar/expression.rs`:
- Around line 1624-1635: The public ScalarIndexExpr::optimize documentation
needs a usage example and links to the relevant implementation symbols. Extend
its doc comment with a concise Rust example demonstrating optimization and
intra-documentation links to ScalarIndexSearch and try_merge_range, while
preserving the existing behavioral description.
- Around line 1624-1837: Extract the AND-region normalization pass from
ScalarIndexExpr into a dedicated submodule, including optimize_with_context,
optimize_and_tree, optimize_and_leaves, collect_and_leaves, rebuild_and_tree,
the query-key helpers, and try_merge_range. Keep ScalarIndexExpr::optimize as
the public entry point and preserve the existing optimization behavior and
interfaces while relocating the implementation out of the large expression
module.
- Around line 1711-1727: The catch-all branch in collect_and_leaves currently
inserts leaf expressions without optimization. Update it to pass other through
optimize_with_context using is_not_null_elidable before adding it to leaves,
while preserving the existing recursive And handling and independently optimized
Or handling.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 024970c2-f816-4409-8f35-d7dcf2426179

📥 Commits

Reviewing files that changed from the base of the PR and between b98cfd8 and 575c4d6.

📒 Files selected for processing (1)
  • rust/lance-index/src/scalar/expression.rs

@zouhuajian
zouhuajian force-pushed the fix/scalar-normalize branch from 575c4d6 to 418c2d4 Compare July 14, 2026 03:46

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rust/lance-index/src/scalar/expression.rs (1)

1625-1638: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a doc example for the public optimize() API.

The doc explains the NOT/OR/range/null-elimination semantics well and links to SargableQuery::Range and ScalarIndexSearch, but has no runnable example. Per the guideline that public APIs need documentation with examples, consider adding a short # Examples block showing ScalarIndexExpr::And(...).optimize() merging two ranges.

As per coding guidelines, "All public APIs must have documentation with examples, and documentation should link to relevant structs and methods" (**/*.{rs,md,py}).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-index/src/scalar/expression.rs` around lines 1625 - 1638, Add a
runnable # Examples section to the public ScalarIndexExpr::optimize
documentation, demonstrating ScalarIndexExpr::And(...) with two compatible range
queries and asserting that optimize() merges them. Reuse the existing
SargableQuery::Range and ScalarIndexSearch types and constructors visible in the
module, while preserving the current semantic documentation.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@rust/lance-index/src/scalar/expression.rs`:
- Around line 1625-1638: Add a runnable # Examples section to the public
ScalarIndexExpr::optimize documentation, demonstrating ScalarIndexExpr::And(...)
with two compatible range queries and asserting that optimize() merges them.
Reuse the existing SargableQuery::Range and ScalarIndexSearch types and
constructors visible in the module, while preserving the current semantic
documentation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 58a79994-c1fe-44f9-86aa-4420ab1528a8

📥 Commits

Reviewing files that changed from the base of the PR and between 575c4d6 and 418c2d4.

📒 Files selected for processing (1)
  • rust/lance-index/src/scalar/expression.rs

@zouhuajian
zouhuajian force-pushed the fix/scalar-normalize branch from 418c2d4 to 25552dd Compare July 22, 2026 08:46

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rust/lance-index/src/scalar/expression.rs (1)

1706-1734: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Range accumulator only ever retries merges against the first same-key leaf, missing intersections across mixed fragment-coverage groups.

range_positions maps each ScalarIndexQueryKey to a single fixed position (the first leaf seen for that key). When try_merge_range fails against that position (e.g. differing fragment_bitmap), the new leaf is pushed as a separate entry, but the map entry is not updated to also try that new leaf on subsequent occurrences of the same key. Concretely, with three same-key ranges L1(frag=None), L2(frag=A), L3(frag=A) in that order: L2 fails to merge with L1 (fragment mismatch) and is pushed standalone; L3 then only retries against L1 (still mismatched) and is pushed standalone too — L2 and L3, which share fragment_bitmap=A and could merge into one tighter range, are never tried against each other.

This is the same class of issue raised previously by Xuanwo ("stops after pairing a bound with the first opposite bound instead of deriving the tightest range across the same-key conjunction"), reappearing for the fragment-coverage-grouped case rather than the plain 2-range case. The existing test_optimize_respects_fragment_coverage_when_merging_ranges test only exercises the 2-leaf case and doesn't catch this.

♻️ Proposed fix: track all unmerged candidate positions per key
-        let mut range_positions = HashMap::new();
+        let mut range_positions: HashMap<ScalarIndexQueryKey, Vec<usize>> = HashMap::new();

         for leaf in leaves {
             ...
             if let Some(key) = leaf.range_query_key() {
-                match range_positions.entry(key) {
-                    Entry::Vacant(entry) => {
-                        entry.insert(optimized.len());
-                        optimized.push(leaf);
-                    }
-                    Entry::Occupied(entry) => {
-                        if !optimized[*entry.get()].try_merge_range(&leaf) {
-                            optimized.push(leaf);
-                        }
-                    }
-                }
+                let positions = range_positions.entry(key).or_default();
+                let merged = positions
+                    .iter()
+                    .any(|&pos| optimized[pos].try_merge_range(&leaf));
+                if !merged {
+                    positions.push(optimized.len());
+                    optimized.push(leaf);
+                }
             } else {
                 optimized.push(leaf);
             }
         }

Please add a 3-leaf test with mixed fragment-coverage groups (as above) alongside the fix, since the current tests don't cover this ordering.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-index/src/scalar/expression.rs` around lines 1706 - 1734, Update
the range accumulation logic in the optimizer around range_positions so each
ScalarIndexQueryKey tracks all unmerged candidate positions, not only the first
leaf. When try_merge_range fails due to differing fragment coverage, retain the
new leaf as a candidate and attempt subsequent same-key ranges against it as
well as existing candidates, while preserving separate groups that cannot merge.
Add a three-leaf test covering L1 with no fragment bitmap followed by L2 and L3
sharing fragment bitmap A, asserting L2 and L3 merge while remaining separate
from L1.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/lance-index/src/scalar/expression.rs`:
- Around line 1676-1682: Update optimize_and_tree to avoid the runtime .expect()
when rebuilding the AND tree. Preserve the invariant check with debug_assert!
that leaves is non-empty, then handle the Option returned by rebuild_and_tree
without introducing a release-build panic, using the existing Self return
contract.

---

Outside diff comments:
In `@rust/lance-index/src/scalar/expression.rs`:
- Around line 1706-1734: Update the range accumulation logic in the optimizer
around range_positions so each ScalarIndexQueryKey tracks all unmerged candidate
positions, not only the first leaf. When try_merge_range fails due to differing
fragment coverage, retain the new leaf as a candidate and attempt subsequent
same-key ranges against it as well as existing candidates, while preserving
separate groups that cannot merge. Add a three-leaf test covering L1 with no
fragment bitmap followed by L2 and L3 sharing fragment bitmap A, asserting L2
and L3 merge while remaining separate from L1.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6fa5a374-3fa4-40f9-ba4f-387d5868a683

📥 Commits

Reviewing files that changed from the base of the PR and between 418c2d4 and 25552dd.

📒 Files selected for processing (1)
  • rust/lance-index/src/scalar/expression.rs

Comment thread rust/lance-index/src/scalar/expression.rs
@wjones127
wjones127 merged commit a1946af into lance-format:main Jul 22, 2026
36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-index Vector index, linalg, tokenizer bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants