zeroshade commented on code in PR #2117:
URL: https://github.com/apache/iceberg-go/pull/2117#discussion_r4198576048


##########
table/in_predicate_extrema_test.go:
##########
@@ -139,6 +141,279 @@ func TestManifestEvaluatorInPredicateExtrema(t 
*testing.T) {
        }
 }
 
+func TestInclusiveMetricsEvaluatorInPredicateExtrema(t *testing.T) {
+       encode := func(value int32) []byte {
+               encoded, err := iceberg.NewLiteral(value).MarshalBinary()
+               require.NoError(t, err)
+
+               return encoded
+       }
+
+       schema := iceberg.NewSchema(1, iceberg.NestedField{ID: 1, Name: 
"value", Type: iceberg.PrimitiveTypes.Int32})
+       expr := iceberg.IsIn(iceberg.Reference("value"), int32(1), int32(100))
+       eval, err := newInclusiveMetricsEvaluator(schema, expr, true, true)
+       require.NoError(t, err)
+
+       tests := []struct {
+               name       string
+               lowerBound []byte
+               upperBound []byte
+               want       bool
+       }{
+               {name: "lower bound above set", lowerBound: encode(101), 
upperBound: encode(200), want: false},
+               {name: "upper bound below set", lowerBound: encode(-10), 
upperBound: encode(0), want: false},
+               {name: "sparse set has no member in range", lowerBound: 
encode(40), upperBound: encode(60), want: false},
+               {name: "overlapping range contains member", lowerBound: 
encode(50), upperBound: encode(100), want: true},
+               {name: "upper bound equals minimum literal", upperBound: 
encode(1), want: true},
+               {name: "lower bound equals maximum literal", lowerBound: 
encode(100), want: true},
+               {name: "missing bounds", want: true},
+               {name: "upper bound below set without lower bound", upperBound: 
encode(0), want: false},
+               {name: "lower bound above set without upper bound", lowerBound: 
encode(101), want: false},
+               {name: "disjoint lower bound avoids malformed upper bound", 
lowerBound: encode(101), upperBound: []byte{1}, want: false},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       file := inclusiveMetricsInTestFile(t, tt.lowerBound, 
tt.upperBound)
+                       got, err := eval(file)
+                       require.NoError(t, err)
+                       assert.Equal(t, tt.want, got)
+               })
+       }
+
+       t.Run("NaN bounds fail open", func(t *testing.T) {
+               encodeFloat := func(value float64) []byte {
+                       encoded, err := 
iceberg.NewLiteral(value).MarshalBinary()
+                       require.NoError(t, err)
+
+                       return encoded
+               }
+               nan := encodeFloat(math.NaN())
+               finiteLower := encodeFloat(0)
+               finiteUpper := encodeFloat(3)
+               floatSchema := iceberg.NewSchema(1, iceberg.NestedField{
+                       ID: 1, Name: "value", Type: 
iceberg.PrimitiveTypes.Float64,
+               })
+               floatEval, err := newInclusiveMetricsEvaluator(
+                       floatSchema, iceberg.IsIn(iceberg.Reference("value"), 
float64(1), float64(2)), true, true,
+               )
+               require.NoError(t, err)
+
+               for _, bounds := range []struct {
+                       name         string
+                       lower, upper []byte
+               }{
+                       {name: "both NaN", lower: nan, upper: nan},
+                       {name: "NaN lower finite upper", lower: nan, upper: 
finiteUpper},
+                       {name: "finite lower NaN upper", lower: finiteLower, 
upper: nan},
+               } {
+                       t.Run(bounds.name, func(t *testing.T) {
+                               got, err := 
floatEval(inclusiveMetricsInTestFile(t, bounds.lower, bounds.upper))
+                               require.NoError(t, err)
+                               assert.True(t, got)
+                       })
+               }
+       })
+
+       t.Run("oversized set keeps existing fallback", func(t *testing.T) {
+               values := make([]int32, inPredicateLimit+1)
+               for i := range values {
+                       values[i] = int32(i)
+               }
+               largeEval, err := newInclusiveMetricsEvaluator(
+                       schema, iceberg.IsIn(iceberg.Reference("value"), 
values...), true, true,
+               )
+               require.NoError(t, err)
+
+               disjointLower := int32(inPredicateLimit + 1000)
+               got, err := largeEval(inclusiveMetricsInTestFile(
+                       t, encode(disjointLower), encode(disjointLower+1),
+               ))
+               require.NoError(t, err)
+               assert.True(t, got)

Review Comment:
   This now pins the size guard, but nothing in `table` checks that 
`inclusiveMetricsEval` takes the fast path at all. When I routed `VisitBound` 
through `iceberg.VisitBoundPredicate`, which never calls `VisitInWithExtrema`, 
the full `./table` suite still passed. The direct `VisitInWithExtrema` calls 
only test the method on its own, and the dispatch test lives in `iceberg`. So 
if the unexported `boundSetExtremaExprVisitor` signature drifts, or 
`VisitBound` changes, the optimization would disappear without any test 
failing. That is the part of `@laskoviymishka`'s test request that's still 
open. The manifest evaluator is covered by `large IN below lower bound` because 
its extrema check runs before `inPredicateLimit`. Doing the same here costs 
O(1) per file. It also lets IN lists over 200 values prune data files by their 
bounds (today they get no metrics pruning), and lets this subtest assert 
`false`, which checks the dispatch end to end. If you'd rather keep the guard 
first to match Ja
 va's `InclusiveEvalVisitor.in`, that's reasonable, but then the path-taken 
check needs another assertion at the evaluator level.



##########
table/evaluators.go:
##########
@@ -1153,23 +1174,57 @@ func (m *inclusiveMetricsEval) VisitIn(t 
iceberg.BoundTerm, s iceberg.Set[iceber
                return rowsMightMatch
        }
 
-       values := s.Members()
-       if lowerBound, ok := m.boundFor(t, m.lowerBounds[fieldID]); ok {
+       hasExtrema := minLit != nil && maxLit != nil
+       var extremaCmp func(iceberg.Literal, iceberg.Literal) int
+       if hasExtrema {
+               extremaCmp = getCmpLiteral(minLit)
+       }
+
+       lowerBound, hasLowerBound := m.boundFor(t, m.lowerBounds[fieldID])
+       if hasLowerBound {
                if m.isNan(lowerBound) {
                        return rowsMightMatch
                }
+               if hasExtrema && extremaCmp(lowerBound, maxLit) > 0 {
+                       // Preserve this lower-bound short circuit before 
decoding the upper
+                       // bound: malformed upper metrics must not matter once 
lower proves
+                       // the set disjoint.
+                       return rowsCannotMatch
+               }
+       }
+
+       var (
+               upperBound    iceberg.Literal
+               hasUpperBound bool
+       )
+       if hasExtrema {
+               upperBound, hasUpperBound = m.boundFor(t, 
m.upperBounds[fieldID])
+               if hasUpperBound {
+                       if m.isNan(upperBound) {
+                               return rowsMightMatch
+                       }
+                       if extremaCmp(upperBound, minLit) < 0 {
+                               return rowsCannotMatch
+                       }
+               }
+       }
 
+       values := s.Members()
+       if hasLowerBound {
                values = removeBoundCheck(lowerBound, values, 1)
                if len(values) == 0 {
                        return rowsCannotMatch
                }
        }
 
-       if upperBound, ok := m.boundFor(t, m.upperBounds[fieldID]); ok {
-               if m.isNan(upperBound) {
+       if !hasExtrema {
+               upperBound, hasUpperBound = m.boundFor(t, 
m.upperBounds[fieldID])
+               if hasUpperBound && m.isNan(upperBound) {
                        return rowsMightMatch
                }
+       }

Review Comment:
   On the straighten-`visitIn` thread: this second fetch exists only so that 
the `!hasExtrema` path decodes the upper bound after the lower scan. Through 
`Eval` and `TestRowGroup`, that path is only reachable for geometry/geography 
sets, because every orderable set gets extrema when it is bound. For geo sets, 
`removeBoundCheck` panics on any bound anyway. You can declare `upperBound, 
hasUpperBound := m.boundFor(...)` once, right after the lower block, followed 
by the NaN check and `if hasExtrema && extremaCmp(upperBound, minLit) < 0`. 
That gives the single flow `@laskoviymishka` asked for without changing any 
reachable result. I tried that shape and the full `./table` suite passes, 
including `disjoint lower bound avoids malformed upper bound`. Up to you and 
`@laskoviymishka`; not blocking.



##########
table/evaluators.go:
##########
@@ -1153,23 +1174,57 @@ func (m *inclusiveMetricsEval) VisitIn(t 
iceberg.BoundTerm, s iceberg.Set[iceber
                return rowsMightMatch
        }
 
-       values := s.Members()
-       if lowerBound, ok := m.boundFor(t, m.lowerBounds[fieldID]); ok {
+       hasExtrema := minLit != nil && maxLit != nil
+       var extremaCmp func(iceberg.Literal, iceberg.Literal) int
+       if hasExtrema {
+               extremaCmp = getCmpLiteral(minLit)
+       }

Review Comment:
   `getCmpLiteral` returns the closure built in `getCmp`, and that closure 
escapes (`go build -gcflags=-m ./table` reports `func literal escapes to heap` 
at evaluators.go:292). Because it is built up front, every file evaluated 
against an IN predicate now pays a 24-byte heap allocation. That includes files 
that fall through to the member scan and files with no bounds for the column. 
Compared with `main` (Go 1.27.1, darwin/arm64, `GOMAXPROCS=1`, n=6, benchstat): 
in=10/overlap goes from 20k to 30k allocs/op and is 6.6% slower; 
in=10/sparse-gap is 5.0% slower (p=0.002); in=200/overlap goes from 30k to 40k 
allocs/op. below-set stays at 40k allocs/op, where `52e7e97` (the commit the 
description's table was measured at) had 30k. The `compareBoundLiterals` helper 
removed in `c477a9e` compared values without building a closure, and it matched 
`main` on overlap, so bringing it back (or any compare that doesn't return a 
func value) fixes this. Please refresh the table in the description at hea
 d afterwards.



##########
bound_set_extrema_test.go:
##########
@@ -0,0 +1,115 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package iceberg
+
+import (
+       "encoding/binary"
+       "math"
+       "testing"
+
+       iceberginternal "github.com/apache/iceberg-go/internal"
+       "github.com/stretchr/testify/require"
+)
+
+func TestBoundSetExtremaUnavailableForGeoLiterals(t *testing.T) {
+       geoBytes := func(x, y float64) []byte {
+               data := make([]byte, 16)
+               binary.LittleEndian.PutUint64(data[:8], math.Float64bits(x))
+               binary.LittleEndian.PutUint64(data[8:], math.Float64bits(y))
+
+               return data
+       }
+
+       for _, tt := range []struct {
+               name string
+               typ  Type
+       }{
+               {name: "geometry", typ: GeometryType{}},
+               {name: "geography", typ: GeographyType{}},
+       } {
+               t.Run(tt.name, func(t *testing.T) {
+                       first, err := LiteralFromBytes(tt.typ, geoBytes(1, 2))
+                       require.NoError(t, err)
+                       second, err := LiteralFromBytes(tt.typ, geoBytes(3, 4))
+                       require.NoError(t, err)
+
+                       schema := NewSchema(1, NestedField{ID: 1, Name: "geo", 
Type: tt.typ})
+                       bound, err := BindExpr(
+                               schema,
+                               SetPredicate(OpIn, Reference("geo"), 
[]Literal{first, second}),
+                               true,
+                       )
+                       require.NoError(t, err)
+                       pred, ok := bound.(BoundPredicate)
+                       require.True(t, ok)
+                       extrema, ok := pred.(boundSetExtremaRef)
+                       require.True(t, ok)
+
+                       minLit, maxLit, hasExtrema := 
extrema.boundSetExtremaRef()
+                       require.False(t, hasExtrema)
+                       require.Nil(t, minLit)
+                       require.Nil(t, maxLit)
+               })
+       }
+}
+
+type extremaDispatchProbe struct {
+       BoundBooleanExprVisitor[bool]
+       visitedIn      bool
+       visitedExtrema bool
+       minLit         Literal
+       maxLit         Literal
+}
+
+func (v *extremaDispatchProbe) VisitIn(BoundTerm, Set[Literal]) bool {
+       v.visitedIn = true
+
+       return false
+}
+
+func (v *extremaDispatchProbe) VisitInWithExtrema(_ BoundTerm, _ Set[Literal], 
minLit, maxLit Literal) bool {
+       v.visitedExtrema = true
+       v.minLit = minLit
+       v.maxLit = maxLit
+
+       return true
+}
+
+func TestVisitBoundPredicateRefDispatchesSetExtrema(t *testing.T) {

Review Comment:
   `TestVisitBoundPredicateRefPassesInExtrema` in 
`bound_set_extrema_internal_test.go` already checks this: the borrowed dispatch 
passes the extrema to `VisitInWithExtrema`, and `VisitBoundPredicate` falls 
back to `VisitIn`. `TestLiteralSetExtrema/geo has no ordering` covers the geo 
case one level down. I'd drop this test. If you want the geo check at bind 
time, add it to that existing internal test file rather than a second file for 
the same unit.



##########
table/in_predicate_extrema_test.go:
##########
@@ -139,6 +141,279 @@ func TestManifestEvaluatorInPredicateExtrema(t 
*testing.T) {
        }
 }
 
+func TestInclusiveMetricsEvaluatorInPredicateExtrema(t *testing.T) {
+       encode := func(value int32) []byte {
+               encoded, err := iceberg.NewLiteral(value).MarshalBinary()
+               require.NoError(t, err)
+
+               return encoded
+       }
+
+       schema := iceberg.NewSchema(1, iceberg.NestedField{ID: 1, Name: 
"value", Type: iceberg.PrimitiveTypes.Int32})
+       expr := iceberg.IsIn(iceberg.Reference("value"), int32(1), int32(100))
+       eval, err := newInclusiveMetricsEvaluator(schema, expr, true, true)
+       require.NoError(t, err)
+
+       tests := []struct {
+               name       string
+               lowerBound []byte
+               upperBound []byte
+               want       bool
+       }{
+               {name: "lower bound above set", lowerBound: encode(101), 
upperBound: encode(200), want: false},
+               {name: "upper bound below set", lowerBound: encode(-10), 
upperBound: encode(0), want: false},
+               {name: "sparse set has no member in range", lowerBound: 
encode(40), upperBound: encode(60), want: false},
+               {name: "overlapping range contains member", lowerBound: 
encode(50), upperBound: encode(100), want: true},
+               {name: "upper bound equals minimum literal", upperBound: 
encode(1), want: true},
+               {name: "lower bound equals maximum literal", lowerBound: 
encode(100), want: true},
+               {name: "missing bounds", want: true},
+               {name: "upper bound below set without lower bound", upperBound: 
encode(0), want: false},
+               {name: "lower bound above set without upper bound", lowerBound: 
encode(101), want: false},
+               {name: "disjoint lower bound avoids malformed upper bound", 
lowerBound: encode(101), upperBound: []byte{1}, want: false},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       file := inclusiveMetricsInTestFile(t, tt.lowerBound, 
tt.upperBound)
+                       got, err := eval(file)
+                       require.NoError(t, err)
+                       assert.Equal(t, tt.want, got)
+               })
+       }
+
+       t.Run("NaN bounds fail open", func(t *testing.T) {
+               encodeFloat := func(value float64) []byte {
+                       encoded, err := 
iceberg.NewLiteral(value).MarshalBinary()
+                       require.NoError(t, err)
+
+                       return encoded
+               }
+               nan := encodeFloat(math.NaN())
+               finiteLower := encodeFloat(0)
+               finiteUpper := encodeFloat(3)
+               floatSchema := iceberg.NewSchema(1, iceberg.NestedField{
+                       ID: 1, Name: "value", Type: 
iceberg.PrimitiveTypes.Float64,
+               })
+               floatEval, err := newInclusiveMetricsEvaluator(
+                       floatSchema, iceberg.IsIn(iceberg.Reference("value"), 
float64(1), float64(2)), true, true,
+               )
+               require.NoError(t, err)
+
+               for _, bounds := range []struct {
+                       name         string
+                       lower, upper []byte
+               }{
+                       {name: "both NaN", lower: nan, upper: nan},
+                       {name: "NaN lower finite upper", lower: nan, upper: 
finiteUpper},
+                       {name: "finite lower NaN upper", lower: finiteLower, 
upper: nan},
+               } {
+                       t.Run(bounds.name, func(t *testing.T) {
+                               got, err := 
floatEval(inclusiveMetricsInTestFile(t, bounds.lower, bounds.upper))
+                               require.NoError(t, err)
+                               assert.True(t, got)
+                       })
+               }
+       })
+
+       t.Run("oversized set keeps existing fallback", func(t *testing.T) {
+               values := make([]int32, inPredicateLimit+1)
+               for i := range values {
+                       values[i] = int32(i)
+               }
+               largeEval, err := newInclusiveMetricsEvaluator(
+                       schema, iceberg.IsIn(iceberg.Reference("value"), 
values...), true, true,
+               )
+               require.NoError(t, err)
+
+               disjointLower := int32(inPredicateLimit + 1000)
+               got, err := largeEval(inclusiveMetricsInTestFile(
+                       t, encode(disjointLower), encode(disjointLower+1),
+               ))
+               require.NoError(t, err)
+               assert.True(t, got)
+       })
+}
+
+func TestInclusiveMetricsEvaluatorInPredicateExtremaFastPathTypes(t 
*testing.T) {
+       decimal := func(value int64) iceberg.Decimal {
+               return iceberg.Decimal{Val: decimal128.FromI64(value), Scale: 2}
+       }
+
+       tests := []struct {
+               name           string
+               typ            iceberg.Type
+               expr           iceberg.BooleanExpression
+               minLit, maxLit iceberg.Literal
+               lower, upper   iceberg.Literal
+               want           bool

Review Comment:
   Every row here is disjoint and leaves `want` at its zero value, so `slow == 
fast` only checks the case where the file is pruned. Adding one overlapping row 
per type with `want: true` would let it catch a type-specific over-prune, which 
is the direction that drops rows.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to