Artículo técnico

Matriz de opciones de AGGREGATE y fuga de gates en HotXLS

HotXLS, el componente nativo de hojas de cálculo Excel para Delphi y C++Builder, publicó en septiembre de 2026 dos fixes relacionados de AGGREGATE. La versión 2.382.0 corrigió el argumento de opciones para que los códigos 1/3/5/7 ignoren las filas ocultas, 2/3/6/7 ignoren los errores y 0 a 3 ignoren las celdas SUBTOTAL y AGGREGATE anidadas, exactamente como lo documenta Microsoft. La versión 2.382.3 después evitó que esos flags de selección se filtraran a la evaluación de las mismas celdas que la función referencia. El primer defecto es vergonzoso de la forma en que siempre lo son los bugs de transcripción de tablas: las posiciones de los bits estaban intercambiadas, así que cada fórmula que usaba un código de opciones distinto de cero recibía una política que su autor no había pedido. El segundo es más interesante, porque es una forma que usted va a encontrar en cualquier evaluador que use un campo transitorio para pasar contexto a un recorrido recursivo. Una agregación externa arma un flag, recorre un rango y trae una celda cuya fórmula todavía no se calculó. Esa fórmula corre en la misma calculadora, ve el mismo flag armado y agrega en silencio las filas equivocadas, produciendo un número que está mal por una cantidad que nadie puede explicar solo con el texto de la fórmula

¿Qué seleccionan realmente las opciones 0 a 7 de AGGREGATE?

El argumento de opciones de AGGREGATE es una matriz de tres bits, y los tres bits son independientes. El bit 0 (valor 1) significa ignorar las filas ocultas, el bit 1 (valor 2) significa ignorar los valores de error, y el bit 2 (valor 4) significa dejar de ignorar las celdas SUBTOTAL y AGGREGATE anidadas, porque saltearlas es lo que viene por defecto con los códigos bajos. Hay dos cosas de esto que son fáciles de entender al revés. El bit de filas ocultas es el bit bajo, no el del medio, así que AGGREGATE(9,1,...) es la forma de total filtrado y AGGREGATE(9,2,...) es la tolerante a errores. Y la política de agregados anidados está invertida respecto de las otras dos: solo los códigos 4 a 7 tratan como valor común a una celda cuya propia fórmula es un SUBTOTAL o un AGGREGATE. ECMA-376 Parte 1 §18.17.7 define SUBTOTAL con la misma división de incluir o excluir filas ocultas entre los códigos 1-11 y 101-111, y AGGREGATE, que en los archivos OOXML se guarda bajo el prefijo _xlfn., generaliza esa división dentro del argumento de opciones, así que la tabla que Microsoft publica para la función AGGREGATE es el contrato que un motor tiene que cumplir y no una comodidad

OpciónFilas ocultasValores de errorSUBTOTAL / AGGREGATE anidados
0incluidaspropagadosignorados
1ignoradaspropagadosignorados
2incluidasignoradosignorados
3ignoradasignoradosignorados
4incluidaspropagadosincluidos
5ignoradaspropagadosincluidos
6incluidasignoradosincluidos
7ignoradasignoradosincluidos

¿Por qué HotXLS tenía las opciones de AGGREGATE al revés?

Porque la TXLSCalculator.CalcAggregateFunc original se escribió a partir de una paráfrasis de la tabla y no de la tabla. Calculaba ignoreErrors := (optCode >= 4) and (optCode <= 7) y armaba el gate de filas ocultas para los códigos 2, 3, 6 y 7, mientras que la política de agregados anidados no estaba implementada en absoluto. El artículo anterior sobre filas ocultas en SUBTOTAL y AGGREGATE listaba esa brecha como límite abierto y describía el mapeo viejo tal como salía entonces; la descripción era fiel al código y estaba equivocada respecto de Excel, y nadie lo notó por mucho tiempo porque las dos políticas que más se combinan, ocultas más errores, caen en los códigos 3 y 7 con las dos tablas. Solo un código de un bit dejaba al descubierto el intercambio: AGGREGATE(9,1,A1:A4) devolvía la suma sin filtrar, y AGGREGATE(9,2,...) salteaba las filas ocultas mientras seguía propagando #DIV/0!. El defecto apareció en una revisión estática de lxCalc.pas, registrado como HXLS-008 en el registro de problemas conocidos del proyecto, no a partir del archivo de un cliente, lo que dice algo sobre lo poco que aparecen los códigos de un bit en los libros de producción. La versión 2.382.0 reescribió la decodificación como tres pruebas de pertenencia a conjuntos y agregó un segundo gate para la política de anidados, cableado a través de un callback TXLSIsSubtotalCell nuevo que el workbook provee junto con TXLSIsRowHidden

La decodificación de opciones de AGGREGATE en HotXLS antes y después de la v2.382.0: el CalcAggregateFunc original armaba el gate de filas ocultas para los códigos 2, 3, 6, 7 e ignoraba errores desde 4 en adelante sin política de anidados, mientras que la decodificación corregida prueba filas ocultas en 1, 3, 5, 7, errores en 2, 3, 6, 7 y saltos de anidados en 0 a 3
Solo los códigos de un bit dejaban al descubierto el intercambio, porque la combinación popular de ocultas más errores cae en los códigos 3 y 7 con las dos tablas, y los códigos fuera de 0 a 7 ahora devuelven lxErrorValue tal como Excel los rechaza
// TXLSCalculator.CalcAggregateFunc, forma de la v2.382.3
if (optCode < 0) or (optCode > 7) then
begin
  Result := lxErrorValue;            // Excel rechaza los códigos fuera de 0..7
  Exit;
end;
ignoreErrors := optCode in [2, 3, 6, 7];
prevIgnoreHidden := FIgnoreHiddenRows;
prevIgnoreSubtotal := FIgnoreSubtotalCells;
FIgnoreHiddenRows := (optCode in [1, 3, 5, 7]) and Assigned(FIsRowHidden);
FIgnoreSubtotalCells := (optCode in [0, 1, 2, 3]) and Assigned(FIsSubtotalCell);
try
  // ... mapea function_num al iftab interno, recorre ref1..refN ...
finally
  FIgnoreHiddenRows := prevIgnoreHidden;
  FIgnoreSubtotalCells := prevIgnoreSubtotal;
end;

Fíjese que los dos flags se asignan de forma incondicional, en lugar de activarse solo cuando la opción los pide. La versión 2.382.0 todavía usaba if ... then FIgnoreHiddenRows := True, lo que significaba que un AGGREGATE con código 4 anidado dentro de un SUBTOTAL(109, ...) heredaba el gate de filas ocultas externo en lugar de limpiarlo. Asignar el valor decodificado al entrar y restaurar el valor previo en el bloque finally hace que cada llamada a AGGREGATE sea dueña de su política durante su recorrido y nada más. La versión 2.382.0 también hizo honesta la forma de array: cuando un argumento evalúa a un array Variant de una o dos dimensiones, CalcAggregateFunc ahora recorre cada elemento y aplica la política de errores por elemento, donde el código viejo solo probaba si había un double NaN y en cualquier otro caso le entregaba el array completo a ExcelSum

¿Por qué un AGGREGATE externo se filtra a las fórmulas que referencia?

Porque FIgnoreHiddenRows y FIgnoreSubtotalCells son campos de la calculadora, y la calculadora la comparten todas las fórmulas que se evalúan durante un recálculo. Los gates se diseñaron como campos de trabajo justamente para que seis bucles de recorrido de celdas pudieran consultarlos sin pasar un parámetro por cada firma, y ese diseño es sólido mientras todo lo que corre con un gate armado pertenezca a la agregación que lo armó. La suposición se rompe en un punto concreto: FGetValue. Cuando un walker le pide al workbook el valor de una celda y esa celda contiene una fórmula sin resultado cacheado, el workbook compila la fórmula y la evalúa en el momento, sobre la misma TXLSCalculator, con los gates externos todavía puestos. El fixture de regresión en HotXLS.WorkbookApiTests.pas muestra la falla con cuatro celdas. A1 contiene 10, A2 contiene 20 en una fila oculta, A3 contiene =1/0, y A4 contiene =SUBTOTAL(9,A1:A2), cuyo valor correcto es 30. Ahora evalúe =AGGREGATE(9,7,A1:A4): ignorar filas ocultas, ignorar errores, contar el subtotal anidado como valor. Excel devuelve 10 + 30 = 40. Con A4 sin cachear, el motor anterior a la 2.382.3 armaba el gate de filas ocultas, llegaba hasta A4, disparaba su evaluación, y CalcSubtotalFunc para el código 9 heredaba el gate armado, porque solo activa el flag para los códigos 101 a 111 y nunca lo limpia. A4 evaluaba a 10 en lugar de 30, y el total externo volvía como 20. Ninguna de las dos fórmulas menciona filas ocultas en el camino que produjo el número equivocado

Cómo un AGGREGATE externo de HotXLS se filtraba a sus precedentes: con FIgnoreHiddenRows armado para el código 7, el recorrido llega a A4 sin cachear que contiene SUBTOTAL 9 sobre A1:A2, FGetValue la evalúa en la misma calculadora, CalcSubtotalFunc hereda el gate y devuelve 10 en lugar de 30, así que el total reporta 20 donde Excel devuelve 40
El gate de anidados se filtraba también en la otra dirección, y CalcSubtotalFunc reseteaba FIgnoreSubtotalCells al salir en lugar de restaurarlo, desarmando la política externa para cada celda posterior a un subtotal sin cachear alcanzado a mitad del recorrido

El gate de agregados anidados se filtraba igual en la otra dirección. Con los códigos 0 a 3, FIgnoreSubtotalCells está armado, y el walker genérico de rangos en GetValueItemRange lo respeta, así que un precedente cuya fórmula es =SUM(B1:B3) descartaría B2 en silencio si B2 contuviera un SUBTOTAL. Peor todavía, CalcSubtotalFunc resetea FIgnoreSubtotalCells a False al salir en lugar de restaurar el valor previo, así que un precedente SUBTOTAL sin cachear alcanzado a mitad del recorrido desarmaba el gate externo para cada celda posterior. El registro de problemas conocidos del proyecto archiva esto bajo HXLS-008 como fuga de estado de selección anidado, y ese es el nombre correcto para la clase de bug: un flag transitorio global que es correcto para el marco que lo puso y equivocado para cada marco que lo hereda

Cómo aíslan el recorrido AggregateGetCellValue y AggregateGetItemValue

El fix de la v2.382.3 pone una frontera alrededor de cada punto donde AGGREGATE lee un valor que no calculó él mismo. TXLSCalculator.AggregateGetCellValue envuelve la llamada cruda a FGetValue: guarda los dos flags, los limpia, hace el fetch y los restaura en un bloque finally. La agregación externa igual aplica su propia política a la celda que acaba de traer, porque las pruebas de fila oculta y de celda anidada ocurren en el walker alrededor del fetch, pero la fórmula precedente en sí corre sin política alguna, que es lo que hace Excel

El aislamiento de HotXLS en la v2.382.3: AggregateGetCellValue guarda los dos flags de gate, los limpia, hace el fetch a través de FGetValue y los restaura en un bloque finally, así que una fórmula precedente se evalúa sin política mientras el walker externo igual aplica las pruebas de fila oculta y celda anidada alrededor del fetch
AggregateGetItemValue hace lo mismo con los argumentos de array calculados y mapea los errores de fetch a VarAsError, mientras que un código de límite de recursos deliberadamente nunca se trata como error ignorable bajo las opciones que ignoran errores
function TXLSCalculator.AggregateGetCellValue(SheetIndex, Row, Col: Integer;
  var Value: Variant; var OutOfRange: Boolean): Integer;
var
  Hidden, Nested: Boolean;
begin
  Hidden := FIgnoreHiddenRows;
  Nested := FIgnoreSubtotalCells;
  FIgnoreHiddenRows := False;        // una fórmula precedente es dueña de su propia política
  FIgnoreSubtotalCells := False;
  try
    Result := FGetValue(SheetIndex, Row, Col, Value, OutOfRange);
  finally
    FIgnoreHiddenRows := Hidden;
    FIgnoreSubtotalCells := Nested;
  end;
end;

AggregateGetItemValue hace lo mismo con los argumentos que no son rangos, y tiene que hacer más que limpiar flags, porque un argumento como A1:A4/(B1:B4-20) es un array calculado cuya forma de elementos tiene que sobrevivir. El wrapper materializa un rango común en un array Variant bidimensional a través de AggregateGetCellValue, mapeando a VarAsError una celda que devolvió un código de error para que la política de errores igual se pueda aplicar por elemento, y recursa por los nodos de operadores binarios y unarios (SA_ADD, SA_DIV, SA_UNARMINUS y el resto) con ApplyArrayBinaryOp y ApplyArrayUnaryOp; cualquier otra cosa cae al GetValueItem normal. Dos guardas se sientan delante de la materialización: un rango más grande que EffectiveFormulaArrayMemoryLimit devuelve lxErrorResourceLimit, y un rango multihoja o invertido devuelve #VALUE!. Un código de límite de recursos a propósito no se trata como error de celda ignorable ni siquiera bajo las opciones 2/3/6/7, ya que un motor que se tragara su propia señal de memoria agotada porque el usuario pidió saltear #N/A estaría mintiendo. Los tres walkers de AGGREGATE, AggregateCollectRange para la familia de SUM, AggregateReduceVariance para STDEV, VAR y PRODUCT, y AggregateReduceWithK para MEDIAN y las formas de cuantiles, se pasaron de FGetValue y GetValueItem a los dos wrappers, y cada uno ganó la prueba de celda anidada a través de FIsSubtotalCell

¿Qué error devuelve AGGREGATE cuando no ignora los errores?

El original, desde la v2.382.3. La versión 2.382.0 detectaba bien las celdas de error, pero colapsaba todas en lxErrorValue, así que AGGREGATE(9,4,A1:A3) sobre una celda #DIV/0! devolvía #VALUE!, cuando Excel propaga sin cambios el primer error que encuentra. El helper de reemplazo AggregateErrorCode mapea un Variant al código lxError* que corresponda, ya sea que el Variant sea un varError genuino o uno de los siete strings de error, y AggregateValueIsError ahora no es más que una prueba de que el resultado sea distinto de cero. Cada walker registra el primer código de error que ve y devuelve ese código, lo que además significa que una celda cuya fórmula nunca se calculó, y cuyo error por lo tanto llega como código de retorno de FGetValue en lugar de como Variant cacheado, propaga igual que una cacheada. Dos funciones de conteo reciben trato especial dentro de AggregateCollectRange, y el trato coincide con SUBTOTAL y no con SUM. Para la función interna 0, COUNT, una celda de error nunca se cuenta ni se propaga, sin importar el código de opciones, porque COUNT solo cuenta números. Para la función interna 169, COUNTA, una celda de error es un valor no vacío y cuenta como 1, salvo que el código de opciones ignore errores, en cuyo caso se saltea. Esa asimetría es cómo trata Excel a COUNT y COUNTA también fuera de AGGREGATE, y es el tipo de detalle que una regla genérica de "si hay error, propagar" resuelve mal sin hacer ruido

Qué verifica la matriz de regresión de ocho opciones

El fixture descrito arriba se ejercita como matriz completa en AggregateFunc_OptionMatrixCoversHiddenErrorsAndNestedAggregates: para cada código de opciones de 0 a 7 evalúa tanto la forma SUM como la forma MEDIAN sobre A1:A4 y chequea el resultado contra una expectativa derivada a mano. Los códigos 0, 1, 4 y 5 tienen que propagar el #DIV/0! de A3, ya que ninguno de ellos ignora errores. El código 2 da SUM 30 y MEDIAN 15, a partir de 10 y 20 con la celda anidada A4 salteada. El código 3 da 10 y 10. El código 6 da 60 y 20, porque el 30 de A4 ahora cuenta. El código 7 da 40 y 20, que es el caso que devolvía 20 antes del fix de la fuga. La corrida de aceptación más amplia registrada en el registro de problemas conocidos cubre los diecinueve números de función contra los ocho códigos, con cada precedente cacheado y sin cachear, para 304 escenarios en Win32 y Win64

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Data');
    Sheet.Cells[1, 1].Value := 10;
    Sheet.Cells[2, 1].Value := 20;
    Sheet.Cells[3, 1].Formula := '=1/0';
    Sheet.Cells[4, 1].Formula := '=SUBTOTAL(9,A1:A2)';   // subtotal del grupo = 30
    Sheet.RowHidden[2] := True;

    Sheet.Cells[6, 1].Formula := '=AGGREGATE(9,1,A1:A4)'; // #DIV/0!  se saltean ocultas, el error se propaga
    Sheet.Cells[7, 1].Formula := '=AGGREGATE(9,3,A1:A4)'; // 10       se saltean ocultas + error + anidadas
    Sheet.Cells[8, 1].Formula := '=AGGREGATE(9,6,A1:A4)'; // 60       solo se saltean los errores
    Sheet.Cells[9, 1].Formula := '=AGGREGATE(9,7,A1:A4)'; // 40       era 20 antes de la v2.382.3
    Book.Recalculate;
    Book.SaveAs('aggregate-options.xlsx');
  finally
    Book.Free;
  end;
end;

Dónde sigue estando la frontera

Hay tres límites que conviene conocer antes de apoyarse en esto. Primero, el predicado de agregado anidado es textual. TXLSXWorkbook.GetCalcIsSubtotalCell y su gemelo del motor clásico devuelven True cuando la fórmula de una celda empieza con SUBTOTAL(, AGGREGATE( o _xlfn.AGGREGATE(, con o sin el signo igual adelante, así que una fórmula como =IF(C1,SUBTOTAL(9,B1:B9),0) o =SUBTOTAL(9,B1:B9)*2 no se reconoce como anidada y los códigos 0 a 3 la van a contar doble donde Excel la saltearía; un generador que emita subtotales calculados debería mantener la llamada de agregación al frente de la fórmula. Segundo, el aislamiento vive en los tres walkers de AGGREGATE. CalcSubtotalFunc todavía recorre a través de GetValueItemRange, CollectRangeValues y SubtotalReduceVariance, que llaman a FGetValue directamente, así que un SUBTOTAL(109, ...) cuyo rango contenga una fórmula precedente sin cachear todavía puede pasarle su gate de filas ocultas a ese precedente. Un Recalculate completo evalúa los precedentes antes que los dependientes, así que se toma el camino cacheado y el gate nunca se hereda; la exposición queda limitada a la evaluación ad hoc con Calculate y a los libros cargados sin valores cacheados, y si usted se apoya en el recálculo incremental sobre el grafo de dependencias para mantener ágiles los modelos grandes, la misma garantía de orden es lo que mantiene esta fuga dormida. Tercero, los dos gates están condicionados a Assigned(FIsRowHidden) y Assigned(FIsSubtotalCell). Las dos fachadas de workbook cablean los callbacks en sus constructores, pero el código que arma un TXLSCalculator a mano con solo los dos argumentos originales obtiene el comportamiento heredado de incluir todo para cada código de opciones, en silencio. Cuando un total se ve mal y el texto de la fórmula se ve bien, trazar la evaluación paso a paso es la forma más rápida de ver si un precedente se evaluó bajo un gate heredado o si simplemente nunca se enganchó un callback

El motor de cálculo descrito acá, el decodificador de opciones, los wrappers de fetch aislados y la matriz de regresión que los fija vienen como código fuente con el componente de planilla HotXLS Delphi, que lee, escribe y recalcula libros XLS, XLSX y ODS en Delphi y C++Builder sin necesidad de tener Excel instalado