Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 153 additions & 22 deletions crates/iceberg/src/expr/visitors/inclusive_metrics_evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -437,28 +437,17 @@ impl BoundPredicateVisitor for InclusiveMetricsEvaluator<'_> {
return ROWS_MIGHT_MATCH;
}

if let Some(lower_bound) = self.lower_bound(field_id) {
if lower_bound.is_nan() {
// NaN indicates unreliable bounds. See the InclusiveMetricsEvaluator docs for more.
return ROWS_MIGHT_MATCH;
}

if !literals.iter().any(|datum| datum.ge(lower_bound)) {
// if all values are less than lower bound, rows cannot match.
return ROWS_CANNOT_MATCH;
}
}

if let Some(upper_bound) = self.upper_bound(field_id) {
if upper_bound.is_nan() {
// NaN indicates unreliable bounds. See the InclusiveMetricsEvaluator docs for more.
return ROWS_MIGHT_MATCH;
}

if !literals.iter().any(|datum| datum.le(upper_bound)) {
// if all values are greater than upper bound, rows cannot match.
return ROWS_CANNOT_MATCH;
}
let lower_bound = self.lower_bound(field_id);
let upper_bound = self.upper_bound(field_id);

// A NaN bound is unreliable on that side only. Drop it to unbounded
// so a valid bound can still prune.
if !super::any_literal_in_bounds(
super::finite_bound(lower_bound),
super::finite_bound(upper_bound),
literals,
) {
return ROWS_CANNOT_MATCH;
}

ROWS_MIGHT_MATCH
Expand Down Expand Up @@ -1551,6 +1540,59 @@ mod test {
);
}

#[test]
fn test_integer_in_straddling_bounds() {
let result = InclusiveMetricsEvaluator::eval(
&r#in_int("id", &[INT_MIN_VALUE - 25, INT_MAX_VALUE + 25]),
&get_test_file_1(),
true,
)
.unwrap();
assert!(!result, "Should skip: id in (5, 104), bounds are [30, 79]");
}

#[test]
fn test_float_in_nan_upper_bound_prunes_below_lower() {
let result = InclusiveMetricsEvaluator::eval(
&r#in_float("no_nans", &[2.0, 3.0]),
&get_test_file_float_nan_upper(),
true,
)
.unwrap();
assert!(
!result,
"Should skip: NaN upper is unbounded, both literals are below lower 4.0"
);
}

#[test]
fn test_float_in_nan_lower_bound_prunes_above_upper() {

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.

These pin the skip direction, which is exactly what I asked for last round — thanks.

One small gap: every NaN test here asserts a prune, so a bug that dropped the valid bound instead of the NaN one would sail through — keeping a NaN lower makes ge(NaN) false for every literal, which also prunes. A companion that should not prune — NaN lower, upper = 3.0, IN (2.0, 4.0) → might-match — would pin the wrong-side case. Low priority, but cheap.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added. Inclusive now has test_float_in_nan_lower_bound_does_not_prune_when_a_literal_is_inside_upper (NaN lower, upper = 3.0, IN (2.0, 4.0) must read), and the row-group evaluator has the matching case.

let result = InclusiveMetricsEvaluator::eval(
&r#in_float("no_nans", &[2.0, 3.0]),
&get_test_file_float_nan_lower(),
true,
)
.unwrap();
assert!(
!result,
"Should skip: NaN lower is unbounded, both literals are above upper 1.0"
);
}

#[test]
fn test_float_in_nan_lower_bound_does_not_prune_when_a_literal_is_inside_upper() {
let result = InclusiveMetricsEvaluator::eval(
&r#in_float("no_nans", &[2.0, 4.0]),
&get_test_file_float_nan_lower_upper_3(),
true,
)
.unwrap();
assert!(
result,
"Should read: NaN lower is unbounded, 2.0 is inside upper 3.0"
);
}

#[test]
fn test_integer_not_in() {
let result = InclusiveMetricsEvaluator::eval(
Expand Down Expand Up @@ -1880,6 +1922,16 @@ mod test {
filter.bind(schema.clone(), true).unwrap()
}

fn in_float(reference: &str, float_literals: &[f32]) -> BoundPredicate {
let schema = create_test_schema();
let filter = Predicate::Set(SetExpression::new(
In,
Reference::new(reference),
FnvHashSet::from_iter(float_literals.iter().copied().map(Datum::float)),
));
filter.bind(schema.clone(), true).unwrap()
}

fn not_in_int(reference: &str, int_literals: &[i32]) -> BoundPredicate {
let schema = create_test_schema();
let filter = Predicate::Set(SetExpression::new(
Expand Down Expand Up @@ -2093,6 +2145,85 @@ mod test {
content_size_in_bytes: None,
}
}

fn get_test_file_float_nan_upper() -> DataFile {

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.

Two optional test follow-ups, neither blocking. These NaN-bound fixtures pair a NaN bound with nan_value_counts: 0 — a deliberately impossible "broken writer" state, which is exactly the case worth exercising, but a one-line comment saying so would stop a future reader from treating it as a valid fixture. And the NaN coverage here is all f32/Float; since finite_bound also handles Double, a double-typed case (plus a both-bounds-NaN case) at the evaluator level would round it out — the helper unit tests cover those arms, the evaluators don't.

DataFile {
content: DataContentType::Data,
file_path: "/test/path".to_string(),
file_format: DataFileFormat::Parquet,
partition: Struct::empty(),
record_count: 10,
file_size_in_bytes: 10,
column_sizes: Default::default(),
value_counts: HashMap::from([(9, 10)]),
null_value_counts: HashMap::from([(9, 0)]),
nan_value_counts: HashMap::from([(9, 0)]),
lower_bounds: HashMap::from([(9, Datum::float(4.0_f32))]),
upper_bounds: HashMap::from([(9, Datum::float(f32::NAN))]),
key_metadata: None,
split_offsets: None,
equality_ids: None,
sort_order_id: None,
partition_spec_id: 0,
first_row_id: None,
referenced_data_file: None,
content_offset: None,
content_size_in_bytes: None,
}
}

fn get_test_file_float_nan_lower() -> DataFile {
DataFile {
content: DataContentType::Data,
file_path: "/test/path".to_string(),
file_format: DataFileFormat::Parquet,
partition: Struct::empty(),
record_count: 10,
file_size_in_bytes: 10,
column_sizes: Default::default(),
value_counts: HashMap::from([(9, 10)]),
null_value_counts: HashMap::from([(9, 0)]),
nan_value_counts: HashMap::from([(9, 0)]),
lower_bounds: HashMap::from([(9, Datum::float(f32::NAN))]),
upper_bounds: HashMap::from([(9, Datum::float(1.0_f32))]),
key_metadata: None,
split_offsets: None,
equality_ids: None,
sort_order_id: None,
partition_spec_id: 0,
first_row_id: None,
referenced_data_file: None,
content_offset: None,
content_size_in_bytes: None,
}
}

fn get_test_file_float_nan_lower_upper_3() -> DataFile {
DataFile {
content: DataContentType::Data,
file_path: "/test/path".to_string(),
file_format: DataFileFormat::Parquet,
partition: Struct::empty(),
record_count: 10,
file_size_in_bytes: 10,
column_sizes: Default::default(),
value_counts: HashMap::from([(9, 10)]),
null_value_counts: HashMap::from([(9, 0)]),
nan_value_counts: HashMap::from([(9, 0)]),
lower_bounds: HashMap::from([(9, Datum::float(f32::NAN))]),
upper_bounds: HashMap::from([(9, Datum::float(3.0_f32))]),
key_metadata: None,
split_offsets: None,
equality_ids: None,
sort_order_id: None,
partition_spec_id: 0,
first_row_id: None,
referenced_data_file: None,
content_offset: None,
content_size_in_bytes: None,
}
}

fn get_test_file_2() -> DataFile {
DataFile {
content: DataContentType::Data,
Expand Down
48 changes: 35 additions & 13 deletions crates/iceberg/src/expr/visitors/manifest_evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,9 @@ impl BoundPredicateVisitor for ManifestFilterVisitor<'_> {
_predicate: &BoundPredicate,
) -> Result<bool> {
let field = self.field_summary_for_reference(reference);
// A missing lower_bound covers all-null, all-NaN, and mixed null+NaN
// summaries (Iceberg spec). Those cases prune here and never reach
// any_literal_in_bounds.
if field.lower_bound.is_none() {
return ROWS_CANNOT_MATCH;
}
Expand All @@ -409,20 +412,15 @@ impl BoundPredicateVisitor for ManifestFilterVisitor<'_> {
return ROWS_MIGHT_MATCH;
}

if let Some(lower_bound) = &field.lower_bound {
let lower_bound =
ManifestFilterVisitor::bytes_to_datum(lower_bound, &reference.field().field_type);
if literals.iter().all(|datum| &lower_bound > datum) {
return ROWS_CANNOT_MATCH;
}
}
let lower_bound = field.lower_bound.as_ref().map(|bound| {
ManifestFilterVisitor::bytes_to_datum(bound, reference.field().field_type.as_ref())
});
let upper_bound = field.upper_bound.as_ref().map(|bound| {
ManifestFilterVisitor::bytes_to_datum(bound, reference.field().field_type.as_ref())
});

if let Some(upper_bound) = &field.upper_bound {
let upper_bound =
ManifestFilterVisitor::bytes_to_datum(upper_bound, &reference.field().field_type);
if literals.iter().all(|datum| &upper_bound < datum) {
return ROWS_CANNOT_MATCH;
}
if !super::any_literal_in_bounds(lower_bound.as_ref(), upper_bound.as_ref(), literals) {

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.

Optional, non-blocking: this is the one of the three evaluators that doesn't wrap its bounds in finite_bound, and that's correct — partition-summary bounds are the min/max of non-NaN values per spec, so there's no NaN to drop here.

Sitting right next to the other two call sites though, the omission reads like an oversight, and the next person is going to either file a bug or add a spurious finite_bound. A one-liner — something like // summary bounds are finite per spec; NaN is tracked separately via contains_nan — closes that off. wdyt?

return ROWS_CANNOT_MATCH;
}

ROWS_MIGHT_MATCH
Expand Down Expand Up @@ -1371,6 +1369,30 @@ mod test {
Ok(())
}

#[test]
fn test_in_straddling_bounds() -> Result<()> {
let case_sensitive = true;
let schema = create_schema()?;
let manifest_file = create_manifest_file(create_partitions());

let filter = Predicate::Set(SetExpression::new(
PredicateOperator::In,
Reference::new("id"),
FnvHashSet::from_iter(vec![
Datum::int(INT_MIN_VALUE - 25),
Datum::int(INT_MAX_VALUE + 25),
]),
))
.bind(schema.clone(), case_sensitive)?;
assert!(
!ManifestEvaluator::builder(filter)
.build()
.eval(&manifest_file)?,
"Should not read: id in (5, 104), summary is [30, 79]"
);
Ok(())
}

#[test]
fn test_not_in() -> Result<()> {
let case_sensitive = true;
Expand Down
103 changes: 103 additions & 0 deletions crates/iceberg/src/expr/visitors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
// specific language governing permissions and limitations
// under the License.

use fnv::FnvHashSet;

use crate::spec::Datum;

pub(crate) mod bloom_filter_evaluator;
pub(crate) mod bound_predicate_visitor;
pub(crate) mod expression_evaluator;
Expand All @@ -27,3 +31,102 @@ pub(crate) mod rewrite_not;
pub(crate) mod row_group_metrics_evaluator;
pub(crate) mod strict_metrics_evaluator;
pub(crate) mod strict_projection;

/// Returns true if any literal could match the inclusive `[lower, upper]` range.
/// Missing bounds are treated as unbounded on that side.
///
/// `(None, None)` returns true because no bound is available to prune against.
pub(crate) fn any_literal_in_bounds(
lower: Option<&Datum>,
upper: Option<&Datum>,
literals: &FnvHashSet<Datum>,
) -> bool {
match (lower, upper) {
(Some(lower), Some(upper)) => literals
.iter()
.any(|datum| datum.ge(lower) && datum.le(upper)),
(Some(lower), None) => literals.iter().any(|datum| datum.ge(lower)),
(None, Some(upper)) => literals.iter().any(|datum| datum.le(upper)),
(None, None) => true,

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.

The (None, None) => true arm is right for the metrics evaluators — no bounds means we can't prune. For the manifest path a missing lower bound means the summary is all-null and IN should prune, which is the opposite. I'm assuming that case is already caught upstream before we reach the helper?

If so, a one-line note on that precondition here would keep a future refactor from silently flipping manifest pruning without any test catching it. wdyt?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes: if field.lower_bound is missing, ManifestEvaluator::in already returns cannot-match (all-null summary) and never reaches the helper. (None, None) is only a "no stats, cannot prune" signal for the metrics evaluators. I put that precondition on the helper so a later refactor cannot swap the two meanings.

}
}

/// Drops a NaN bound so that side is treated as unbounded.
///
/// A NaN min or max is unreliable, but the other bound may still prune.
/// Inclusive evaluators in Java and PyIceberg bail to might-match when
/// either bound is NaN. Dropping only the NaN side is a deliberate
/// divergence: `total_cmp` treats NaN as the maximum, so the remaining

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.

Optional doc polish — the total_cmp line explains why NaN sorts to the max, but the load-bearing assumption is really that the retained bound is trustworthy. Worth a sentence naming that: we drop the NaN side and trust the finite one, which is sound as long as the writer's non-NaN bound is itself reliable. Keeps a future reader from seeing the NaN min and assuming the whole stat block is poisoned. Purely a comment tweak, not a gate.

/// finite bound is still a valid prune.
pub(crate) fn finite_bound(bound: Option<&Datum>) -> Option<&Datum> {

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.

This is the NaN-as-unbounded behavior I asked for last round, and it's right.

One thing worth a note while we're here: finite_bound now prunes in a spot Java and PyIceberg's inclusive evaluators don't — both bail to might-match the moment either bound is NaN, so for lower = NaN, upper = 1.0, IN (2.0, 3.0) we skip where they'd read. It's a legitimate improvement (total_cmp makes NaN the max, so dropping it and trusting the finite side is sound), just more aggressive than the reference clients. A one-line comment marking it as a deliberate divergence would save the next reader the double-take.

Related, and not for this PR: the straddling duality I mentioned last round still holds, but this new NaN tightening is inclusive-only — StrictMetricsEvaluator::not_in still bails on NaN, so the pair isn't symmetric on that edge. It's safe (strict stays conservative), so a follow-up to give not_in the same finite_bound treatment is plenty. wdyt?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added that note on finite_bound. On strict::not_in, I would rather leave the NaN tightening as a follow-up. Strict staying conservative is safe, and that path deserves the same kind of pinning tests we added here.

bound.filter(|datum| !datum.is_nan())
}

#[cfg(test)]
mod tests {
use super::*;

fn floats(vals: &[f32]) -> FnvHashSet<Datum> {
vals.iter().copied().map(Datum::float).collect()
}

#[test]
fn both_bounds_require_a_literal_inside_the_range() {
let lower = Datum::float(4.0_f32);
let upper = Datum::float(6.0_f32);
assert!(!any_literal_in_bounds(
Some(&lower),
Some(&upper),
&floats(&[2.0, 8.0])
));
assert!(any_literal_in_bounds(
Some(&lower),
Some(&upper),
&floats(&[2.0, 5.0])
));
}

#[test]
fn lower_only_prunes_literals_below_the_bound() {
let lower = Datum::float(4.0_f32);
assert!(!any_literal_in_bounds(
Some(&lower),
None,
&floats(&[2.0, 3.0])
));
assert!(any_literal_in_bounds(
Some(&lower),
None,
&floats(&[2.0, 4.0])
));
}

#[test]
fn upper_only_prunes_literals_above_the_bound() {
let upper = Datum::float(1.0_f32);
assert!(!any_literal_in_bounds(
None,
Some(&upper),
&floats(&[2.0, 3.0])
));
assert!(any_literal_in_bounds(
None,
Some(&upper),
&floats(&[0.5, 3.0])
));
}

#[test]
fn neither_bound_cannot_prune() {
assert!(any_literal_in_bounds(None, None, &floats(&[2.0, 3.0])));
}

#[test]
fn finite_bound_drops_nan() {
let nan = Datum::float(f32::NAN);
let finite = Datum::float(4.0_f32);
assert!(finite_bound(Some(&nan)).is_none());
assert_eq!(finite_bound(Some(&finite)), Some(&finite));
assert!(finite_bound(None).is_none());
}
}
Loading
Loading