diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/AggregateFunction.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/AggregateFunction.java index 6a5e8c7fdc3ff..09b98b3568cd0 100644 --- a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/AggregateFunction.java +++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/AggregateFunction.java @@ -47,6 +47,11 @@ public enum AggregateFunction { // so resolution goes via name lookup in fromNameOrError. STATS(Type.STATE_EXPANDING, SqlKind.OTHER), + // EXTENDED_STATS — superset of STATS adding sum_of_squares, variance/std_deviation + // triplets, and a nested std_deviation_bounds struct. Decomposition is handled by + // OpenSearchExtendedStatsReduceRule during HEP, same model as STATS. + EXTENDED_STATS(Type.STATE_EXPANDING, SqlKind.OTHER), + // Statistical — fixed-size state, multi-pass or running stats. Handled by // OpenSearchAggregateReduceRule (once FUNCTIONS_TO_REDUCE is extended to include them) // — no intermediateFields here either. @@ -211,6 +216,7 @@ public SqlAggFunction toSqlAggFunction() { case AVG -> SqlStdOperatorTable.AVG; case APPROX_COUNT_DISTINCT -> SqlStdOperatorTable.APPROX_COUNT_DISTINCT; case STATS -> OpenSearchAggregateOperators.STATS; + case EXTENDED_STATS -> OpenSearchAggregateOperators.EXTENDED_STATS; default -> throw new IllegalStateException("No SqlAggFunction mapping for: " + this); }; } diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/OpenSearchAggregateOperators.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/OpenSearchAggregateOperators.java index a614a5b3c680c..ebc94164e5af5 100644 --- a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/OpenSearchAggregateOperators.java +++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/OpenSearchAggregateOperators.java @@ -31,6 +31,12 @@ * Decomposed at HEP planning time by * {@code OpenSearchStatsReduceRule} into primitive COUNT/MIN/MAX/SUM aggregate calls * plus a Project that builds the struct, so no executor ever sees STATS. + *
  • {@link #EXTENDED_STATS} — superset of {@link #STATS}, returning a 13-field struct + * that adds {@code sum_of_squares}, the {@code variance}/{@code std_deviation} + * triplets, and the nested 6-field {@code std_deviation_bounds} struct. Decomposed + * at HEP planning time by {@code OpenSearchExtendedStatsReduceRule} into the same + * four primitives as STATS plus {@code SUM(x*x)} (computed via a Project beneath the + * rebuilt aggregate), with the variance/stddev/bounds derived in the assembly Project.
  • * * * @opensearch.internal @@ -50,6 +56,34 @@ private OpenSearchAggregateOperators() {} public static final String STATS_FIELD_SUM = "sum"; public static final String STATS_FIELD_AVG = "avg"; + /** + * Field names of the struct returned by {@link #EXTENDED_STATS}. Order matches legacy + * OpenSearch {@code InternalExtendedStats.toXContent}: count/min/max/avg/sum from + * {@code InternalStats}, then sum_of_squares, variance triplet, std_deviation triplet, + * and the nested {@code std_deviation_bounds} struct (with the six bound sub-fields). + */ + public static final String EXTENDED_STATS_FIELD_SUM_OF_SQUARES = "sum_of_squares"; + public static final String EXTENDED_STATS_FIELD_VARIANCE = "variance"; + public static final String EXTENDED_STATS_FIELD_VARIANCE_POPULATION = "variance_population"; + public static final String EXTENDED_STATS_FIELD_VARIANCE_SAMPLING = "variance_sampling"; + public static final String EXTENDED_STATS_FIELD_STD_DEVIATION = "std_deviation"; + public static final String EXTENDED_STATS_FIELD_STD_DEVIATION_POPULATION = "std_deviation_population"; + public static final String EXTENDED_STATS_FIELD_STD_DEVIATION_SAMPLING = "std_deviation_sampling"; + public static final String EXTENDED_STATS_FIELD_STD_DEVIATION_BOUNDS = "std_deviation_bounds"; + public static final String EXTENDED_STATS_BOUND_UPPER = "upper"; + public static final String EXTENDED_STATS_BOUND_LOWER = "lower"; + public static final String EXTENDED_STATS_BOUND_UPPER_POPULATION = "upper_population"; + public static final String EXTENDED_STATS_BOUND_LOWER_POPULATION = "lower_population"; + public static final String EXTENDED_STATS_BOUND_UPPER_SAMPLING = "upper_sampling"; + public static final String EXTENDED_STATS_BOUND_LOWER_SAMPLING = "lower_sampling"; + + /** + * Sigma multiplier used when computing {@code std_deviation_bounds}. Hardcoded to match + * the legacy {@code InternalExtendedStats} default; the bounds are + * {@code avg ± SIGMA * std_deviation}. + */ + public static final double EXTENDED_STATS_DEFAULT_SIGMA = 2.0; + /** * Return-type inference for {@link #STATS}. Produces a struct whose component types match * what Calcite's built-in COUNT/MIN/MAX/SUM aggregates would produce for the same operand, @@ -58,6 +92,9 @@ private OpenSearchAggregateOperators() {} */ private static final SqlReturnTypeInference STATS_RETURN_TYPE_INFERENCE = OpenSearchAggregateOperators::inferStatsReturnType; + private static final SqlReturnTypeInference EXTENDED_STATS_RETURN_TYPE_INFERENCE = + OpenSearchAggregateOperators::inferExtendedStatsReturnType; + /** * STATS bundle aggregate. Decomposed at HEP planning time — never reaches the executor. */ @@ -75,6 +112,25 @@ private OpenSearchAggregateOperators() {} ) { }; + /** + * EXTENDED_STATS bundle aggregate. Superset of {@link #STATS} — adds sum_of_squares, + * variance/std_deviation triplets, and a nested std_deviation_bounds struct. Decomposed + * at HEP planning time — never reaches the executor. + */ + public static final SqlAggFunction EXTENDED_STATS = new SqlAggFunction( + "EXTENDED_STATS", + null, + SqlKind.OTHER, + EXTENDED_STATS_RETURN_TYPE_INFERENCE, + null, + OperandTypes.NUMERIC, + SqlFunctionCategory.USER_DEFINED_FUNCTION, + false, + false, + Optionality.FORBIDDEN + ) { + }; + private static RelDataType inferStatsReturnType(SqlOperatorBinding opBinding) { RelDataTypeFactory typeFactory = opBinding.getTypeFactory(); RelDataType inputType = opBinding.getOperandType(0); @@ -103,4 +159,50 @@ private static RelDataType inferStatsReturnType(SqlOperatorBinding opBinding) { .add(STATS_FIELD_SUM, sumType) .build(); } + + private static RelDataType inferExtendedStatsReturnType(SqlOperatorBinding opBinding) { + RelDataTypeFactory typeFactory = opBinding.getTypeFactory(); + RelDataType inputType = opBinding.getOperandType(0); + + RelDataType countType = typeFactory.createSqlType(SqlTypeName.BIGINT); + RelDataType nullableInput = typeFactory.createTypeWithNullability(inputType, true); + + RelDataType sumType = ReturnTypes.AGG_SUM.inferReturnType(opBinding); + if (sumType == null) { + sumType = nullableInput; + } else { + sumType = typeFactory.createTypeWithNullability(sumType, true); + } + + RelDataType doubleNullable = typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.DOUBLE), true); + + // Nested std_deviation_bounds struct. + RelDataType boundsType = typeFactory.createTypeWithNullability( + typeFactory.builder() + .add(EXTENDED_STATS_BOUND_UPPER, doubleNullable) + .add(EXTENDED_STATS_BOUND_LOWER, doubleNullable) + .add(EXTENDED_STATS_BOUND_UPPER_POPULATION, doubleNullable) + .add(EXTENDED_STATS_BOUND_LOWER_POPULATION, doubleNullable) + .add(EXTENDED_STATS_BOUND_UPPER_SAMPLING, doubleNullable) + .add(EXTENDED_STATS_BOUND_LOWER_SAMPLING, doubleNullable) + .build(), + true + ); + + return typeFactory.builder() + .add(STATS_FIELD_COUNT, countType) + .add(STATS_FIELD_MIN, nullableInput) + .add(STATS_FIELD_MAX, nullableInput) + .add(STATS_FIELD_AVG, doubleNullable) + .add(STATS_FIELD_SUM, sumType) + .add(EXTENDED_STATS_FIELD_SUM_OF_SQUARES, sumType) + .add(EXTENDED_STATS_FIELD_VARIANCE, doubleNullable) + .add(EXTENDED_STATS_FIELD_VARIANCE_POPULATION, doubleNullable) + .add(EXTENDED_STATS_FIELD_VARIANCE_SAMPLING, doubleNullable) + .add(EXTENDED_STATS_FIELD_STD_DEVIATION, doubleNullable) + .add(EXTENDED_STATS_FIELD_STD_DEVIATION_POPULATION, doubleNullable) + .add(EXTENDED_STATS_FIELD_STD_DEVIATION_SAMPLING, doubleNullable) + .add(EXTENDED_STATS_FIELD_STD_DEVIATION_BOUNDS, boundsType) + .build(); + } } diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/PlannerImpl.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/PlannerImpl.java index 49d196a03b711..f0697cd127de5 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/PlannerImpl.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/PlannerImpl.java @@ -29,6 +29,7 @@ import org.apache.logging.log4j.Logger; import org.opensearch.analytics.planner.rel.OpenSearchDistributionTraitDef; import org.opensearch.analytics.planner.rules.OpenSearchAggregateReduceRule; +import org.opensearch.analytics.planner.rules.OpenSearchExtendedStatsReduceRule; import org.opensearch.analytics.planner.rules.OpenSearchStatsReduceRule; import org.opensearch.analytics.planner.rules.OpenSearchAggregateRule; import org.opensearch.analytics.planner.rules.OpenSearchAggregateSplitRule; @@ -187,6 +188,7 @@ private static RelNode decomposeAggregates(RelNode input, RuleProfilingListener // so the AVG decomposition rule does not see — and does not need to see — STATS' avg // path. Other AVG/STDDEV/VAR aggCalls in the same Aggregate are still picked up. builder.addRuleInstance(OpenSearchStatsReduceRule.INSTANCE); + builder.addRuleInstance(OpenSearchExtendedStatsReduceRule.INSTANCE); builder.addRuleInstance(new OpenSearchAggregateReduceRule()); HepPlanner planner = new HepPlanner(builder.build()); if (listener != null) { diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchExtendedStatsReduceRule.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchExtendedStatsReduceRule.java new file mode 100644 index 0000000000000..cf213c9cd333b --- /dev/null +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchExtendedStatsReduceRule.java @@ -0,0 +1,424 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.analytics.planner.rules; + +import org.apache.calcite.plan.RelOptRule; +import org.apache.calcite.plan.RelOptRuleCall; +import org.apache.calcite.rel.RelCollations; +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rel.core.AggregateCall; +import org.apache.calcite.rel.logical.LogicalAggregate; +import org.apache.calcite.rel.logical.LogicalProject; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.rel.type.RelDataTypeField; +import org.apache.calcite.rex.RexBuilder; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.SqlAggFunction; +import org.apache.calcite.sql.fun.SqlStdOperatorTable; +import org.apache.calcite.sql.type.SqlTypeName; +import org.apache.calcite.sql.type.SqlTypeUtil; +import org.opensearch.analytics.spi.OpenSearchAggregateOperators; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * HEP rule that decomposes the {@link OpenSearchAggregateOperators#EXTENDED_STATS EXTENDED_STATS} + * bundle aggregate into five primitive aggregates ({@code SUM(x)}, {@code MIN(x)}, + * {@code MAX(x)}, {@code SUM(x*x)}, {@code COUNT(x)}) plus a {@link LogicalProject} that + * derives {@code avg}, the variance/std_deviation triplets, and the nested + * {@code std_deviation_bounds} struct. + * + *

    Why decompose at HEP time? Same as {@code OpenSearchStatsReduceRule}: + * {@code EXTENDED_STATS} has no executor implementation in this codebase and isn't a + * Calcite standard {@link org.apache.calcite.sql.SqlKind}. Doing the rewrite during HEP + * keeps the rest of the planner unaware of the bundle aggregate; everything downstream + * sees only primitives that already have full support. + * + * @opensearch.internal + */ +public final class OpenSearchExtendedStatsReduceRule extends RelOptRule { + + public static final OpenSearchExtendedStatsReduceRule INSTANCE = new OpenSearchExtendedStatsReduceRule(); + + /** Synthetic name prefix for the squared-value columns added beneath the rebuilt aggregate. */ + private static final String SQUARED_COLUMN_NAME_PREFIX = "$ext_stats_sq_"; + + private OpenSearchExtendedStatsReduceRule() { + super(operand(LogicalAggregate.class, any()), "OpenSearchExtendedStatsReduceRule"); + } + + @Override + public boolean matches(RelOptRuleCall call) { + LogicalAggregate aggregate = call.rel(0); + for (AggregateCall aggCall : aggregate.getAggCallList()) { + if (isExtendedStats(aggCall.getAggregation())) { + return true; + } + } + return false; + } + + @Override + public void onMatch(RelOptRuleCall call) { + LogicalAggregate aggregate = call.rel(0); + RexBuilder rexBuilder = aggregate.getCluster().getRexBuilder(); + RelDataTypeFactory typeFactory = aggregate.getCluster().getTypeFactory(); + + int groupCount = aggregate.getGroupCount(); + boolean hasEmptyGroup = aggregate.getGroupSet().isEmpty(); + + Expansion expansion = expandAggCalls(aggregate, hasEmptyGroup, groupCount, rexBuilder); + + LogicalAggregate newAgg = LogicalAggregate.create( + expansion.aggInput, + aggregate.getHints(), + aggregate.getGroupSet(), + aggregate.getGroupSets(), + expansion.newCalls + ); + + RelNode project = buildAssemblyProject(aggregate, newAgg, expansion.originalToNew, groupCount, rexBuilder, typeFactory); + call.transformTo(project); + } + + /** + * Expands each EXTENDED_STATS aggregate call into five primitives ({@code SUM(x)}, + * {@code MIN(x)}, {@code MAX(x)}, {@code SUM(x_squared)}, {@code COUNT(x)}, in that + * emission order); leaves non-EXTENDED_STATS calls untouched. + * + *

    The {@code x_squared} input is computed by a {@link LogicalProject} inserted + * beneath the rebuilt aggregate, which appends one squared-value column per + * unique EXTENDED_STATS source column. The Project is shared by every aggregate that + * needs that derivation. Putting the multiplication into a Project beneath rather than + * into the aggregate call's {@code rexList} matters because + * {@code DistributedAggregateRewriter} preserves {@code rexList} verbatim into the + * FINAL aggregate after Volcano split — and at FINAL time the rexList's input refs + * would point at the wrong columns of the gathered partial output, producing + * semantically incorrect results. With the Project beneath, FINAL's argList rebases + * naturally to the column where PARTIAL's {@code sum_of_squares} output lands. + * + *

    Same SUM-first emission rationale as {@code OpenSearchStatsReduceRule} — keeps + * {@code typeMatchesInferred} happy after Volcano split when FINAL re-infers types + * against PARTIAL's first column. + */ + private static Expansion expandAggCalls( + LogicalAggregate aggregate, + boolean hasEmptyGroup, + int groupCount, + RexBuilder rexBuilder + ) { + RelNode input = aggregate.getInput(); + + Map srcColToSquaredCol = new LinkedHashMap<>(); + int origColCount = input.getRowType().getFieldCount(); + int nextSquaredCol = origColCount; + for (AggregateCall original : aggregate.getAggCallList()) { + if (isExtendedStats(original.getAggregation())) { + int srcCol = original.getArgList().get(0); + if (!srcColToSquaredCol.containsKey(srcCol)) { + srcColToSquaredCol.put(srcCol, nextSquaredCol++); + } + } + } + + RelNode aggInput = input; + if (!srcColToSquaredCol.isEmpty()) { + aggInput = buildSquaredProject(input, aggregate.getHints(), srcColToSquaredCol, rexBuilder); + } + + List newCalls = new ArrayList<>(); + List originalToNew = new ArrayList<>(aggregate.getAggCallList().size()); + for (AggregateCall original : aggregate.getAggCallList()) { + if (isExtendedStats(original.getAggregation())) { + int srcCol = original.getArgList().get(0); + int squaredCol = srcColToSquaredCol.get(srcCol); + + int sumIdx = newCalls.size(); + newCalls.add(makePrimitive(SqlStdOperatorTable.SUM, original, aggInput, hasEmptyGroup, groupCount, List.of(srcCol))); + int minIdx = newCalls.size(); + newCalls.add(makePrimitive(SqlStdOperatorTable.MIN, original, aggInput, hasEmptyGroup, groupCount, List.of(srcCol))); + int maxIdx = newCalls.size(); + newCalls.add(makePrimitive(SqlStdOperatorTable.MAX, original, aggInput, hasEmptyGroup, groupCount, List.of(srcCol))); + int sumOfSqIdx = newCalls.size(); + newCalls.add( + makePrimitive(SqlStdOperatorTable.SUM, original, aggInput, hasEmptyGroup, groupCount, List.of(squaredCol)) + ); + int countIdx = newCalls.size(); + newCalls.add(makePrimitive(SqlStdOperatorTable.COUNT, original, aggInput, hasEmptyGroup, groupCount, List.of(srcCol))); + originalToNew.add(new int[] { countIdx, minIdx, maxIdx, sumIdx, sumOfSqIdx }); + } else { + originalToNew.add(new int[] { newCalls.size() }); + newCalls.add(original); + } + } + return new Expansion(aggInput, newCalls, originalToNew); + } + + /** + * Builds a {@link LogicalProject} that exposes every original input column at its + * original position and appends one {@code x*x} column per entry in + * {@code srcColToSquaredCol}. Identity refs for the original columns mean any + * non-EXTENDED_STATS aggregate call's existing argList continues to resolve correctly. + */ + private static RelNode buildSquaredProject( + RelNode input, + List hints, + Map srcColToSquaredCol, + RexBuilder rexBuilder + ) { + int origColCount = input.getRowType().getFieldCount(); + int totalCols = origColCount + srcColToSquaredCol.size(); + List projects = new ArrayList<>(totalCols); + List fieldNames = new ArrayList<>(totalCols); + + for (int i = 0; i < origColCount; i++) { + RelDataTypeField f = input.getRowType().getFieldList().get(i); + projects.add(rexBuilder.makeInputRef(f.getType(), i)); + fieldNames.add(f.getName()); + } + for (Map.Entry entry : srcColToSquaredCol.entrySet()) { + int srcCol = entry.getKey(); + RelDataTypeField f = input.getRowType().getFieldList().get(srcCol); + RexNode argRef = rexBuilder.makeInputRef(f.getType(), srcCol); + RexNode squared = rexBuilder.makeCall(SqlStdOperatorTable.MULTIPLY, argRef, argRef); + projects.add(squared); + fieldNames.add(SQUARED_COLUMN_NAME_PREFIX + srcCol); + } + return LogicalProject.create(input, hints, projects, fieldNames); + } + + /** + * Result of {@link #expandAggCalls}: the input to attach to the rebuilt aggregate + * (either the original input or a Project that adds squared-value columns), the new + * aggCall list, and a per-original-call mapping to indices in the new list + * ({@code [countIdx, minIdx, maxIdx, sumIdx, sumOfSquaresIdx]} for EXTENDED_STATS, + * single-element for everything else). + */ + private record Expansion(RelNode aggInput, List newCalls, List originalToNew) { + } + + /** + * Builds the Project on top of {@code newAgg} that produces the original aggregate's row + * type: group-by columns pass through unchanged; non-EXTENDED_STATS aggregate columns are + * identity refs into {@code newAgg}'s output; EXTENDED_STATS aggregate columns are + * reassembled struct expressions via {@link #buildExtendedStatsStruct}. + */ + private static RelNode buildAssemblyProject( + LogicalAggregate originalAgg, + LogicalAggregate newAgg, + List originalToNew, + int groupCount, + RexBuilder rexBuilder, + RelDataTypeFactory typeFactory + ) { + List originalFields = originalAgg.getRowType().getFieldList(); + List newAggFields = newAgg.getRowType().getFieldList(); + List projects = new ArrayList<>(originalFields.size()); + List fieldNames = new ArrayList<>(originalFields.size()); + + for (int i = 0; i < groupCount; i++) { + projects.add(rexBuilder.makeInputRef(newAggFields.get(i).getType(), i)); + fieldNames.add(originalFields.get(i).getName()); + } + + for (int origIdx = 0; origIdx < originalAgg.getAggCallList().size(); origIdx++) { + AggregateCall original = originalAgg.getAggCallList().get(origIdx); + int[] mapping = originalToNew.get(origIdx); + fieldNames.add(originalFields.get(groupCount + origIdx).getName()); + + if (isExtendedStats(original.getAggregation())) { + projects.add(buildExtendedStatsStruct(original, mapping, newAggFields, groupCount, rexBuilder, typeFactory)); + } else { + int newColPos = groupCount + mapping[0]; + projects.add(rexBuilder.makeInputRef(newAggFields.get(newColPos).getType(), newColPos)); + } + } + + return LogicalProject.create(newAgg, originalAgg.getHints(), projects, fieldNames); + } + + /** + * Builds the 13-field struct returned by EXTENDED_STATS, including the nested 6-field + * std_deviation_bounds struct. Field order matches legacy OpenSearch + * {@code InternalExtendedStats.toXContent}: count, min, max, avg, sum, sum_of_squares, + * variance, variance_population, variance_sampling, std_deviation, + * std_deviation_population, std_deviation_sampling, std_deviation_bounds. + * + *

    Variance and std_deviation triplets duplicate the population variant because legacy + * defines {@code variance} as an alias for {@code variance_population} (same value), and + * likewise for std_deviation. The sampling variants use Bessel's correction ({@code n-1} + * denominator) and return NULL when {@code count <= 1}. + */ + private static RexNode buildExtendedStatsStruct( + AggregateCall original, + int[] mapping, + List newAggFields, + int groupCount, + RexBuilder rexBuilder, + RelDataTypeFactory typeFactory + ) { + RelDataType extStatsType = original.getType(); + List fields = extStatsType.getFieldList(); + if (fields.size() != 13) { + throw new IllegalStateException( + "EXTENDED_STATS return type must declare 13 fields " + + "(count, min, max, avg, sum, sum_of_squares, variance, variance_population, variance_sampling, " + + "std_deviation, std_deviation_population, std_deviation_sampling, std_deviation_bounds), got: " + + extStatsType + ); + } + + int newCountPos = groupCount + mapping[0]; + int newMinPos = groupCount + mapping[1]; + int newMaxPos = groupCount + mapping[2]; + int newSumPos = groupCount + mapping[3]; + int newSumOfSqPos = groupCount + mapping[4]; + + RexNode countRef = ensureType(rexBuilder, newAggFields.get(newCountPos).getType(), newCountPos, fields.get(0).getType()); + RexNode minRef = ensureType(rexBuilder, newAggFields.get(newMinPos).getType(), newMinPos, fields.get(1).getType()); + RexNode maxRef = ensureType(rexBuilder, newAggFields.get(newMaxPos).getType(), newMaxPos, fields.get(2).getType()); + RexNode sumRef = ensureType(rexBuilder, newAggFields.get(newSumPos).getType(), newSumPos, fields.get(4).getType()); + RexNode sumOfSqRef = ensureType(rexBuilder, newAggFields.get(newSumOfSqPos).getType(), newSumOfSqPos, fields.get(5).getType()); + + RelDataType doubleNullable = typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.DOUBLE), true); + RexNode rawSum = rexBuilder.makeInputRef(newAggFields.get(newSumPos).getType(), newSumPos); + RexNode rawCount = rexBuilder.makeInputRef(newAggFields.get(newCountPos).getType(), newCountPos); + RexNode rawSumOfSq = rexBuilder.makeInputRef(newAggFields.get(newSumOfSqPos).getType(), newSumOfSqPos); + RexNode sumD = rexBuilder.makeCast(doubleNullable, rawSum); + RexNode countD = rexBuilder.makeCast(doubleNullable, rawCount); + RexNode sumOfSqD = rexBuilder.makeCast(doubleNullable, rawSumOfSq); + + RexNode avg = rexBuilder.makeCall(SqlStdOperatorTable.DIVIDE, sumD, countD); + + // variance_population = (sum_of_squares - sum*sum/count) / count. + RexNode sumSq = rexBuilder.makeCall(SqlStdOperatorTable.MULTIPLY, sumD, sumD); + RexNode sumSqOverCount = rexBuilder.makeCall(SqlStdOperatorTable.DIVIDE, sumSq, countD); + // Centered sum of squares — shared numerator for both variance variants. + // variance_population divides this by count; variance_sampling divides by (count - 1). + RexNode centeredSumOfSquares = rexBuilder.makeCall(SqlStdOperatorTable.MINUS, sumOfSqD, sumSqOverCount); + RexNode variancePop = rexBuilder.makeCall(SqlStdOperatorTable.DIVIDE, centeredSumOfSquares, countD); + + // variance_sampling = CASE WHEN count > 1 THEN centeredSumOfSquares / (count - 1) ELSE NULL. + RexNode oneLong = rexBuilder.makeExactLiteral(BigDecimal.ONE, typeFactory.createSqlType(SqlTypeName.BIGINT)); + RexNode oneDouble = rexBuilder.makeApproxLiteral(BigDecimal.ONE, doubleNullable); + RexNode countGt1 = rexBuilder.makeCall(SqlStdOperatorTable.GREATER_THAN, rawCount, oneLong); + RexNode countMinus1 = rexBuilder.makeCall(SqlStdOperatorTable.MINUS, countD, oneDouble); + RexNode varianceSampValue = rexBuilder.makeCall(SqlStdOperatorTable.DIVIDE, centeredSumOfSquares, countMinus1); + RexNode nullDouble = rexBuilder.makeNullLiteral(doubleNullable); + RexNode varianceSamp = rexBuilder.makeCall(SqlStdOperatorTable.CASE, countGt1, varianceSampValue, nullDouble); + + // POWER(v, 0.5) avoids a separate SQRT op (mirrors Calcite's stock AggregateReduceFunctionsRule). + RexNode half = rexBuilder.makeApproxLiteral(BigDecimal.valueOf(0.5), doubleNullable); + RexNode stdDev = rexBuilder.makeCall(SqlStdOperatorTable.POWER, variancePop, half); + RexNode stdDevSamp = rexBuilder.makeCall(SqlStdOperatorTable.POWER, varianceSamp, half); + + // Bounds: avg ± SIGMA * std_deviation. SIGMA defaults to 2.0 (legacy InternalExtendedStats). + RexNode sigma = rexBuilder.makeApproxLiteral( + BigDecimal.valueOf(OpenSearchAggregateOperators.EXTENDED_STATS_DEFAULT_SIGMA), + doubleNullable + ); + RexNode offsetPop = rexBuilder.makeCall(SqlStdOperatorTable.MULTIPLY, sigma, stdDev); + RexNode offsetSamp = rexBuilder.makeCall(SqlStdOperatorTable.MULTIPLY, sigma, stdDevSamp); + RexNode boundUpper = rexBuilder.makeCall(SqlStdOperatorTable.PLUS, avg, offsetPop); + RexNode boundLower = rexBuilder.makeCall(SqlStdOperatorTable.MINUS, avg, offsetPop); + RexNode boundUpperSamp = rexBuilder.makeCall(SqlStdOperatorTable.PLUS, avg, offsetSamp); + RexNode boundLowerSamp = rexBuilder.makeCall(SqlStdOperatorTable.MINUS, avg, offsetSamp); + + // Population bounds reuse boundUpper/boundLower since std_deviation_population + // == std_deviation in InternalExtendedStats. + RelDataType boundsType = fields.get(12).getType(); + RexNode boundsStruct = rexBuilder.makeCall( + boundsType, + SqlStdOperatorTable.ROW, + List.of(boundUpper, boundLower, boundUpper, boundLower, boundUpperSamp, boundLowerSamp) + ); + + RexNode avgRef = rexBuilder.makeCast(fields.get(3).getType(), avg); + RexNode varianceRef = rexBuilder.makeCast(fields.get(6).getType(), variancePop); + RexNode variancePopRef = rexBuilder.makeCast(fields.get(7).getType(), variancePop); + RexNode varianceSampRef = rexBuilder.makeCast(fields.get(8).getType(), varianceSamp); + RexNode stdDevRef = rexBuilder.makeCast(fields.get(9).getType(), stdDev); + RexNode stdDevPopRef = rexBuilder.makeCast(fields.get(10).getType(), stdDev); + RexNode stdDevSampRef = rexBuilder.makeCast(fields.get(11).getType(), stdDevSamp); + + return rexBuilder.makeCall( + extStatsType, + SqlStdOperatorTable.ROW, + List.of( + countRef, + minRef, + maxRef, + avgRef, + sumRef, + sumOfSqRef, + varianceRef, + variancePopRef, + varianceSampRef, + stdDevRef, + stdDevPopRef, + stdDevSampRef, + boundsStruct + ) + ); + } + + /** + * Builds a primitive aggregate call (COUNT/MIN/MAX/SUM) over {@code aggInput} with the + * given {@code argList}. Empty {@code rexList} because primitive aggregates have no + * per-call expressions; the {@code SUM(x_squared)} variant is realised by pointing + * {@code argList} at a derived column built by an upstream Project, not by stuffing the + * multiplication into rexList. The original call's distinct/approximate/ignoreNulls/filter + * are propagated; {@code null} for type and name lets Calcite infer them. + */ + private static AggregateCall makePrimitive( + SqlAggFunction op, + AggregateCall original, + RelNode input, + boolean hasEmptyGroup, + int groupCount, + List argList + ) { + return AggregateCall.create( + op, + original.isDistinct(), + original.isApproximate(), + original.ignoreNulls(), + List.of(), + argList, + original.filterArg, + null, + RelCollations.EMPTY, + groupCount, + input, + null, + null + ); + } + + /** + * Returns a {@link RexNode} of {@code targetType}: an input ref at {@code pos}, optionally + * wrapped in a CAST when source and target differ modulo nullability. + */ + private static RexNode ensureType(RexBuilder rexBuilder, RelDataType sourceType, int pos, RelDataType targetType) { + RexNode ref = rexBuilder.makeInputRef(sourceType, pos); + if (SqlTypeUtil.equalSansNullability(rexBuilder.getTypeFactory(), sourceType, targetType)) { + return ref; + } + return rexBuilder.makeCast(targetType, ref); + } + + private static boolean isExtendedStats(SqlAggFunction op) { + return op == OpenSearchAggregateOperators.EXTENDED_STATS; + } +} diff --git a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/ExtendedStatsAggregatePlanShapeTests.java b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/ExtendedStatsAggregatePlanShapeTests.java new file mode 100644 index 0000000000000..016ed47095d1d --- /dev/null +++ b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/ExtendedStatsAggregatePlanShapeTests.java @@ -0,0 +1,151 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.analytics.planner; + +import org.apache.calcite.rel.RelCollations; +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rel.core.AggregateCall; +import org.apache.calcite.rel.logical.LogicalAggregate; +import org.apache.calcite.util.ImmutableBitSet; +import org.opensearch.analytics.planner.rel.OpenSearchAggregate; +import org.opensearch.analytics.planner.rel.OpenSearchExchangeReducer; +import org.opensearch.analytics.planner.rel.OpenSearchProject; +import org.opensearch.analytics.spi.OpenSearchAggregateOperators; + +import java.util.List; + +/** + * Plan-shape tests for the EXTENDED_STATS aggregate after the full {@code PlannerImpl} + * pipeline (HEP decompose → marking → CBO). + * + *

    By the time these assertions run, {@code OpenSearchExtendedStatsReduceRule} has + * expanded EXTENDED_STATS into the five primitive aggregate calls plus a Project that + * builds the 13-field struct (count, min, max, avg, sum, sum_of_squares, variance triplet, + * std_deviation triplet, std_deviation_bounds nested struct). Subsequent phases operate + * on that primitive plan exactly the way they operate on any other SUM/COUNT aggregate. + */ +public class ExtendedStatsAggregatePlanShapeTests extends PlanShapeTestBase { + + public void testExtendedStatsSingleField_1shard_yieldsProjectOverSingleAggregate() { + RelNode scan = stubScan(mockTable("test_index", "status", "size")); + AggregateCall ext = makeExtendedStatsCall(scan, 1, "ext_size"); + RelNode plan = LogicalAggregate.create(scan, List.of(), ImmutableBitSet.of(), null, List.of(ext)); + + RelNode result = runPlanner(plan, singleShardContext()); + + // Top-level: ExchangeReducer wrapping a Project that builds the struct from a + // single-mode Aggregate's primitives. Peel the ER to inspect Project + Aggregate. + RelNode top = unwrapRootReducer(result); + assertTrue("Top must be OpenSearchProject", top instanceof OpenSearchProject); + OpenSearchProject project = (OpenSearchProject) top; + assertEquals(1, project.getProjects().size()); + + RelNode child = unwrapExchange(project.getInput()); + assertTrue("Project input must be OpenSearchAggregate", child instanceof OpenSearchAggregate); + OpenSearchAggregate agg = (OpenSearchAggregate) child; + assertEquals("Decomposed to 5 primitive calls", 5, agg.getAggCallList().size()); + } + + public void testExtendedStatsSingleField_2shard_splitsIntoPartialFinalAroundProject() { + RelNode scan = stubScan(mockTable("test_index", "status", "size")); + AggregateCall ext = makeExtendedStatsCall(scan, 1, "ext_size"); + RelNode plan = LogicalAggregate.create(scan, List.of(), ImmutableBitSet.of(), null, List.of(ext)); + + RelNode result = runPlanner(plan, multiShardContext()); + + // Plan shape after the multi-shard split: + // Project [ext = STRUCT(count, min, max, avg, sum, sum_of_squares, + // variance, variance_population, variance_sampling, + // std_deviation, std_deviation_population, std_deviation_sampling, + // std_deviation_bounds)] + // └── Aggregate(FINAL) [SUM, MIN, MAX, SUM, COUNT] + // └── ExchangeReducer + // └── Aggregate(PARTIAL) [SUM, MIN, MAX, SUM, COUNT] + // └── Project [orig cols + x*x] ← derives sum_of_squares input + // └── Scan + RelNode top = unwrapRootReducer(result); + assertTrue("Top must be OpenSearchProject", top instanceof OpenSearchProject); + OpenSearchProject project = (OpenSearchProject) top; + + RelNode finalAgg = unwrapExchange(project.getInput()); + assertTrue("Below project must be Aggregate(FINAL)", finalAgg instanceof OpenSearchAggregate); + assertEquals(5, ((OpenSearchAggregate) finalAgg).getAggCallList().size()); + + RelNode reducer = finalAgg.getInputs().get(0); + assertTrue("Below FINAL must be ExchangeReducer", reducer instanceof OpenSearchExchangeReducer); + } + + public void testExtendedStatsWithGroupBy_2shard() { + RelNode scan = stubScan(mockTable("test_index", "status", "size")); + AggregateCall ext = makeExtendedStatsCall(scan, 1, "ext_size"); + RelNode plan = LogicalAggregate.create(scan, List.of(), ImmutableBitSet.of(0), null, List.of(ext)); + + RelNode result = runPlanner(plan, multiShardContext()); + + RelNode top = unwrapRootReducer(result); + assertTrue("Top must be OpenSearchProject", top instanceof OpenSearchProject); + OpenSearchProject project = (OpenSearchProject) top; + assertEquals(2, project.getProjects().size()); + assertEquals("status", project.getRowType().getFieldList().get(0).getName()); + assertEquals("ext_size", project.getRowType().getFieldList().get(1).getName()); + } + + public void testExtendedStatsMixedWithCount_2shard_bothFlowThroughSamePartialFinal() { + RelNode scan = stubScan(mockTable("test_index", "status", "size")); + AggregateCall ext = makeExtendedStatsCall(scan, 1, "ext_size"); + AggregateCall countStar = AggregateCall.create( + org.apache.calcite.sql.fun.SqlStdOperatorTable.COUNT, + false, + false, + false, + List.of(), + List.of(), + -1, + null, + RelCollations.EMPTY, + 0, + scan, + null, + "cnt_all" + ); + RelNode plan = LogicalAggregate.create(scan, List.of(), ImmutableBitSet.of(0), null, List.of(ext, countStar)); + + RelNode result = runPlanner(plan, multiShardContext()); + + RelNode top = unwrapRootReducer(result); + assertTrue("Top must be OpenSearchProject", top instanceof OpenSearchProject); + OpenSearchProject project = (OpenSearchProject) top; + assertEquals(3, project.getProjects().size()); + + RelNode finalAgg = unwrapExchange(project.getInput()); + assertTrue("Below project must be Aggregate(FINAL)", finalAgg instanceof OpenSearchAggregate); + // 5 primitives for EXTENDED_STATS + 1 for cnt_all = 6. + assertEquals(6, ((OpenSearchAggregate) finalAgg).getAggCallList().size()); + } + + // ---- Helpers ---- + + private AggregateCall makeExtendedStatsCall(RelNode input, int colIdx, String name) { + return AggregateCall.create( + OpenSearchAggregateOperators.EXTENDED_STATS, + false, + false, + false, + List.of(), + List.of(colIdx), + -1, + null, + RelCollations.EMPTY, + 0, + input, + null, + name + ); + } +} diff --git a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/rules/OpenSearchExtendedStatsReduceRuleTests.java b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/rules/OpenSearchExtendedStatsReduceRuleTests.java new file mode 100644 index 0000000000000..cbc309ef2db20 --- /dev/null +++ b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/rules/OpenSearchExtendedStatsReduceRuleTests.java @@ -0,0 +1,379 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.analytics.planner.rules; + +import org.apache.calcite.plan.hep.HepMatchOrder; +import org.apache.calcite.plan.hep.HepPlanner; +import org.apache.calcite.plan.hep.HepProgramBuilder; +import org.apache.calcite.rel.RelCollations; +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rel.core.Aggregate; +import org.apache.calcite.rel.core.AggregateCall; +import org.apache.calcite.rel.core.Project; +import org.apache.calcite.rel.logical.LogicalAggregate; +import org.apache.calcite.rel.logical.LogicalProject; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.fun.SqlStdOperatorTable; +import org.apache.calcite.sql.type.SqlTypeName; +import org.apache.calcite.util.ImmutableBitSet; +import org.opensearch.analytics.planner.BasePlannerRulesTests; +import org.opensearch.analytics.spi.OpenSearchAggregateOperators; + +import java.util.List; + +/** + * Unit tests for {@link OpenSearchExtendedStatsReduceRule}. Mirrors + * {@code OpenSearchStatsReduceRuleTests} — applies only this rule via a minimal + * {@link HepPlanner} and asserts the resulting tree's structure. + * + *

    Tests are intentionally rule-scoped — they do not exercise {@code PlannerImpl} + * marking, CBO, or fragment conversion. End-to-end planner integration is covered by + * {@code ExtendedStatsAggregatePlanShapeTests}. + */ +public class OpenSearchExtendedStatsReduceRuleTests extends BasePlannerRulesTests { + + public void testSingleExtendedStatsCall_noGroupBy_decomposesIntoFivePrimitivesPlusProject() { + StubTableScan scan = (StubTableScan) stubScan(mockTable("orders", "price")); + AggregateCall call = makeExtendedStatsCall(scan, 0, "ext_stats_price"); + LogicalAggregate aggregate = LogicalAggregate.create(scan, List.of(), ImmutableBitSet.of(), null, List.of(call)); + + RelNode rewritten = applyRule(aggregate); + + assertProjectOverAggregateWithFivePrimitives(rewritten, 0); + } + + public void testSingleExtendedStatsCall_withGroupBy_preservesGroupSetAndPassesThroughGroupColumn() { + StubTableScan scan = (StubTableScan) stubScan(mockTable("orders", "city", "price")); + AggregateCall call = makeExtendedStatsCall(scan, 1, "ext_stats_price"); + LogicalAggregate aggregate = LogicalAggregate.create(scan, List.of(), ImmutableBitSet.of(0), null, List.of(call)); + + RelNode rewritten = applyRule(aggregate); + + assertTrue("Top must be a Project", rewritten instanceof Project); + Project project = (Project) rewritten; + assertEquals(2, project.getProjects().size()); + assertEquals("city", project.getRowType().getFieldList().get(0).getName()); + assertEquals("ext_stats_price", project.getRowType().getFieldList().get(1).getName()); + + Aggregate inner = (Aggregate) project.getInput(); + assertEquals(ImmutableBitSet.of(0), inner.getGroupSet()); + // Order matters — SUM-first emission preserves typeMatchesInferred after Volcano split. + assertEquals(5, inner.getAggCallList().size()); + assertAggCallKinds(inner, SqlKind.SUM, SqlKind.MIN, SqlKind.MAX, SqlKind.SUM, SqlKind.COUNT); + } + + public void testExtendedStatsMixedWithOtherAggregate_decomposesOnlyExtendedStats() { + StubTableScan scan = (StubTableScan) stubScan(mockTable("orders", "price", "quantity")); + AggregateCall extStatsCall = makeExtendedStatsCall(scan, 0, "ext_stats_price"); + AggregateCall avgCall = AggregateCall.create( + SqlStdOperatorTable.AVG, + false, + false, + false, + List.of(), + List.of(1), + -1, + null, + RelCollations.EMPTY, + 0, + scan, + null, + "avg_qty" + ); + LogicalAggregate aggregate = LogicalAggregate.create( + scan, + List.of(), + ImmutableBitSet.of(), + null, + List.of(extStatsCall, avgCall) + ); + + RelNode rewritten = applyRule(aggregate); + + Project project = (Project) rewritten; + assertEquals(2, project.getProjects().size()); + + Aggregate inner = (Aggregate) project.getInput(); + // 5 primitives for EXTENDED_STATS + 1 surviving AVG = 6. + assertEquals(6, inner.getAggCallList().size()); + assertAggCallKinds(inner, SqlKind.SUM, SqlKind.MIN, SqlKind.MAX, SqlKind.SUM, SqlKind.COUNT, SqlKind.AVG); + assertSame(SqlStdOperatorTable.AVG, inner.getAggCallList().get(5).getAggregation()); + } + + public void testMultipleExtendedStatsCalls_eachExpandsIntoFivePrimitives() { + StubTableScan scan = (StubTableScan) stubScan(mockTable("orders", "price", "quantity")); + AggregateCall ext1 = makeExtendedStatsCall(scan, 0, "ext_price"); + AggregateCall ext2 = makeExtendedStatsCall(scan, 1, "ext_qty"); + LogicalAggregate aggregate = LogicalAggregate.create(scan, List.of(), ImmutableBitSet.of(), null, List.of(ext1, ext2)); + + RelNode rewritten = applyRule(aggregate); + + Project project = (Project) rewritten; + assertEquals(2, project.getProjects().size()); + + Aggregate inner = (Aggregate) project.getInput(); + // 5 primitives per EXTENDED_STATS × 2 = 10. + assertEquals(10, inner.getAggCallList().size()); + assertAggCallKinds( + inner, + SqlKind.SUM, + SqlKind.MIN, + SqlKind.MAX, + SqlKind.SUM, + SqlKind.COUNT, + SqlKind.SUM, + SqlKind.MIN, + SqlKind.MAX, + SqlKind.SUM, + SqlKind.COUNT + ); + assertTrue(project.getProjects().get(0) instanceof RexCall); + assertEquals(SqlKind.ROW, ((RexCall) project.getProjects().get(0)).getOperator().getKind()); + assertTrue(project.getProjects().get(1) instanceof RexCall); + assertEquals(SqlKind.ROW, ((RexCall) project.getProjects().get(1)).getOperator().getKind()); + } + + public void testSumOfSquaresAggregatesOverDerivedSquaredColumn() { + StubTableScan scan = (StubTableScan) stubScan(mockTable("orders", "price")); + AggregateCall call = makeExtendedStatsCall(scan, 0, "ext_stats_price"); + LogicalAggregate aggregate = LogicalAggregate.create(scan, List.of(), ImmutableBitSet.of(), null, List.of(call)); + + RelNode rewritten = applyRule(aggregate); + + // The rebuilt aggregate's input must be a LogicalProject that exposes the original + // columns plus one derived squared column. Putting x*x in a Project beneath (not in + // the aggCall's rexList) is essential: DistributedAggregateRewriter preserves rexList + // verbatim into FINAL after Volcano split, which would silently change the meaning of + // SUM(x*x). Routing through a derived column lets FINAL's argList rebase normally. + Aggregate inner = (Aggregate) ((Project) rewritten).getInput(); + assertTrue( + "Aggregate's input must be a LogicalProject that adds the squared column", + inner.getInput() instanceof LogicalProject + ); + LogicalProject squaredProject = (LogicalProject) inner.getInput(); + assertEquals("Project must add 1 squared column to the original 1-column input", 2, squaredProject.getProjects().size()); + + RexNode squaredExpr = squaredProject.getProjects().get(1); + assertTrue(squaredExpr instanceof RexCall); + assertSame(SqlStdOperatorTable.MULTIPLY, ((RexCall) squaredExpr).getOperator()); + + AggregateCall sumOfSquares = inner.getAggCallList().get(3); + assertSame(SqlStdOperatorTable.SUM, sumOfSquares.getAggregation()); + assertTrue("SUM_OF_SQUARES must have empty rexList", sumOfSquares.rexList.isEmpty()); + assertEquals("SUM_OF_SQUARES argList must reference the squared column", List.of(1), sumOfSquares.getArgList()); + } + + public void testMultipleExtendedStatsOnSameSourceShareOneSquaredColumn() { + StubTableScan scan = (StubTableScan) stubScan(mockTable("orders", "price")); + AggregateCall ext1 = makeExtendedStatsCall(scan, 0, "ext_a"); + AggregateCall ext2 = makeExtendedStatsCall(scan, 0, "ext_b"); + LogicalAggregate aggregate = LogicalAggregate.create(scan, List.of(), ImmutableBitSet.of(), null, List.of(ext1, ext2)); + + RelNode rewritten = applyRule(aggregate); + + Aggregate inner = (Aggregate) ((Project) rewritten).getInput(); + LogicalProject squaredProject = (LogicalProject) inner.getInput(); + assertEquals("Same-source EXTENDED_STATS calls must share one squared column", 2, squaredProject.getProjects().size()); + assertEquals(List.of(1), inner.getAggCallList().get(3).getArgList()); + assertEquals(List.of(1), inner.getAggCallList().get(8).getArgList()); + } + + public void testExtendedStatsCall_filterArgIsPropagatedToAllPrimitives() { + // mockTable's default schema is single-column INTEGER, but FILTER needs a BOOLEAN + // NOT NULL column — build a 2-col table where col 1 is BOOLEAN. + StubTableScan scan = (StubTableScan) stubScan( + mockTable("orders2", new String[] { "price", "in_scope" }, new SqlTypeName[] { SqlTypeName.INTEGER, SqlTypeName.BOOLEAN }) + ); + AggregateCall call = AggregateCall.create( + OpenSearchAggregateOperators.EXTENDED_STATS, + false, + false, + false, + List.of(), + List.of(0), + 1, + null, + RelCollations.EMPTY, + 0, + scan, + null, + "ext_stats_price" + ); + LogicalAggregate aggregate = LogicalAggregate.create(scan, List.of(), ImmutableBitSet.of(), null, List.of(call)); + + RelNode rewritten = applyRule(aggregate); + + Aggregate inner = (Aggregate) ((Project) rewritten).getInput(); + for (AggregateCall primitive : inner.getAggCallList()) { + assertEquals("filterArg must propagate to " + primitive.getAggregation().getName(), 1, primitive.filterArg); + } + } + + public void testRuleNoOpWhenNoExtendedStatsCallPresent() { + StubTableScan scan = (StubTableScan) stubScan(mockTable("orders", "price")); + AggregateCall sumCall = AggregateCall.create( + SqlStdOperatorTable.SUM, + false, + false, + false, + List.of(), + List.of(0), + -1, + null, + RelCollations.EMPTY, + 0, + scan, + null, + "sum_price" + ); + LogicalAggregate aggregate = LogicalAggregate.create(scan, List.of(), ImmutableBitSet.of(), null, List.of(sumCall)); + + RelNode rewritten = applyRule(aggregate); + + // No EXTENDED_STATS → rule does not fire → HEP returns the input unchanged. + assertTrue("Plan must remain a LogicalAggregate", rewritten instanceof LogicalAggregate); + assertEquals(1, ((LogicalAggregate) rewritten).getAggCallList().size()); + assertSame(SqlStdOperatorTable.SUM, ((LogicalAggregate) rewritten).getAggCallList().get(0).getAggregation()); + } + + public void testExtendedStatsStructFieldNamesMatchSpec() { + StubTableScan scan = (StubTableScan) stubScan(mockTable("orders", "price")); + AggregateCall call = makeExtendedStatsCall(scan, 0, "ext_stats_price"); + LogicalAggregate aggregate = LogicalAggregate.create(scan, List.of(), ImmutableBitSet.of(), null, List.of(call)); + + RelNode rewritten = applyRule(aggregate); + + // Order matches legacy OpenSearch InternalExtendedStats.toXContent. + Project project = (Project) rewritten; + RelDataType type = project.getRowType().getFieldList().get(0).getType(); + assertTrue(type.isStruct()); + assertEquals(13, type.getFieldList().size()); + assertEquals(OpenSearchAggregateOperators.STATS_FIELD_COUNT, type.getFieldList().get(0).getName()); + assertEquals(OpenSearchAggregateOperators.STATS_FIELD_MIN, type.getFieldList().get(1).getName()); + assertEquals(OpenSearchAggregateOperators.STATS_FIELD_MAX, type.getFieldList().get(2).getName()); + assertEquals(OpenSearchAggregateOperators.STATS_FIELD_AVG, type.getFieldList().get(3).getName()); + assertEquals(OpenSearchAggregateOperators.STATS_FIELD_SUM, type.getFieldList().get(4).getName()); + assertEquals(OpenSearchAggregateOperators.EXTENDED_STATS_FIELD_SUM_OF_SQUARES, type.getFieldList().get(5).getName()); + assertEquals(OpenSearchAggregateOperators.EXTENDED_STATS_FIELD_VARIANCE, type.getFieldList().get(6).getName()); + assertEquals(OpenSearchAggregateOperators.EXTENDED_STATS_FIELD_VARIANCE_POPULATION, type.getFieldList().get(7).getName()); + assertEquals(OpenSearchAggregateOperators.EXTENDED_STATS_FIELD_VARIANCE_SAMPLING, type.getFieldList().get(8).getName()); + assertEquals(OpenSearchAggregateOperators.EXTENDED_STATS_FIELD_STD_DEVIATION, type.getFieldList().get(9).getName()); + assertEquals(OpenSearchAggregateOperators.EXTENDED_STATS_FIELD_STD_DEVIATION_POPULATION, type.getFieldList().get(10).getName()); + assertEquals(OpenSearchAggregateOperators.EXTENDED_STATS_FIELD_STD_DEVIATION_SAMPLING, type.getFieldList().get(11).getName()); + assertEquals(OpenSearchAggregateOperators.EXTENDED_STATS_FIELD_STD_DEVIATION_BOUNDS, type.getFieldList().get(12).getName()); + } + + public void testExtendedStatsBoundsStructFieldNamesMatchSpec() { + StubTableScan scan = (StubTableScan) stubScan(mockTable("orders", "price")); + AggregateCall call = makeExtendedStatsCall(scan, 0, "ext_stats_price"); + LogicalAggregate aggregate = LogicalAggregate.create(scan, List.of(), ImmutableBitSet.of(), null, List.of(call)); + + RelNode rewritten = applyRule(aggregate); + + // Order matches the std_deviation_bounds sub-struct of legacy InternalExtendedStats. + Project project = (Project) rewritten; + RelDataType extType = project.getRowType().getFieldList().get(0).getType(); + RelDataType boundsType = extType.getFieldList().get(12).getType(); + assertTrue("std_deviation_bounds must be a struct", boundsType.isStruct()); + assertEquals(6, boundsType.getFieldList().size()); + assertEquals(OpenSearchAggregateOperators.EXTENDED_STATS_BOUND_UPPER, boundsType.getFieldList().get(0).getName()); + assertEquals(OpenSearchAggregateOperators.EXTENDED_STATS_BOUND_LOWER, boundsType.getFieldList().get(1).getName()); + assertEquals(OpenSearchAggregateOperators.EXTENDED_STATS_BOUND_UPPER_POPULATION, boundsType.getFieldList().get(2).getName()); + assertEquals(OpenSearchAggregateOperators.EXTENDED_STATS_BOUND_LOWER_POPULATION, boundsType.getFieldList().get(3).getName()); + assertEquals(OpenSearchAggregateOperators.EXTENDED_STATS_BOUND_UPPER_SAMPLING, boundsType.getFieldList().get(4).getName()); + assertEquals(OpenSearchAggregateOperators.EXTENDED_STATS_BOUND_LOWER_SAMPLING, boundsType.getFieldList().get(5).getName()); + } + + public void testExtendedStatsCall_distinctIsPropagatedWhereMeaningful() { + StubTableScan scan = (StubTableScan) stubScan(mockTable("orders", "price")); + AggregateCall call = AggregateCall.create( + OpenSearchAggregateOperators.EXTENDED_STATS, + true, + false, + false, + List.of(), + List.of(0), + -1, + null, + RelCollations.EMPTY, + 0, + scan, + null, + "ext_stats_price" + ); + LogicalAggregate aggregate = LogicalAggregate.create(scan, List.of(), ImmutableBitSet.of(), null, List.of(call)); + + RelNode rewritten = applyRule(aggregate); + + Aggregate inner = (Aggregate) ((Project) rewritten).getInput(); + // Calcite normalizes DISTINCT off MIN/MAX (idempotent). SUM and COUNT preserve it. + for (AggregateCall primitive : inner.getAggCallList()) { + SqlKind kind = primitive.getAggregation().getKind(); + if (kind == SqlKind.SUM || kind == SqlKind.COUNT) { + assertTrue("DISTINCT must propagate to " + kind, primitive.isDistinct()); + } else { + assertFalse("Calcite normalizes DISTINCT off " + kind, primitive.isDistinct()); + } + } + } + + // ---- Helpers ---- + + private AggregateCall makeExtendedStatsCall(RelNode input, int colIdx, String name) { + return AggregateCall.create( + OpenSearchAggregateOperators.EXTENDED_STATS, + false, + false, + false, + List.of(), + List.of(colIdx), + -1, + null, + RelCollations.EMPTY, + 0, + input, + null, + name + ); + } + + private RelNode applyRule(RelNode root) { + HepProgramBuilder builder = new HepProgramBuilder(); + builder.addMatchOrder(HepMatchOrder.BOTTOM_UP); + builder.addRuleInstance(OpenSearchExtendedStatsReduceRule.INSTANCE); + HepPlanner planner = new HepPlanner(builder.build()); + planner.setRoot(root); + return planner.findBestExp(); + } + + private static void assertProjectOverAggregateWithFivePrimitives(RelNode rewritten, int colIdx) { + assertTrue("Top must be a Project", rewritten instanceof Project); + Project project = (Project) rewritten; + assertTrue("Project's input must be a LogicalAggregate", project.getInput() instanceof LogicalAggregate); + LogicalAggregate inner = (LogicalAggregate) project.getInput(); + assertEquals(5, inner.getAggCallList().size()); + assertAggCallKinds(inner, SqlKind.SUM, SqlKind.MIN, SqlKind.MAX, SqlKind.SUM, SqlKind.COUNT); + RelDataType extType = project.getRowType().getFieldList().get(colIdx).getType(); + assertTrue("EXTENDED_STATS column must be a struct type", extType.isStruct()); + } + + private static void assertAggCallKinds(Aggregate aggregate, SqlKind... expectedKinds) { + assertEquals("aggCall count mismatch", expectedKinds.length, aggregate.getAggCallList().size()); + for (int i = 0; i < expectedKinds.length; i++) { + assertEquals( + "aggCall " + i + " kind mismatch", + expectedKinds[i], + aggregate.getAggCallList().get(i).getAggregation().getKind() + ); + } + } +}