Artículo técnico

Escaneos lookup de HotXLS y referencias circulares falsas

Poned =VLOOKUP(A1,B:B,1) en una celda de la columna B y Excel la calcula sin queja. Dadle el mismo libro a un motor de recálculo con grafo de dependencias y lo probable es que obtengáis un error de referencia circular, porque la fórmula depende de un rango que contiene la fórmula. HotXLS informaba exactamente de eso hasta la v2.361.98. El arreglo no es un caso especial para rangos de columna completa; es una distinción entre dos clases de arista de dependencia que un motor de hojas de cálculo necesita y que un grafo dirigido llano no tiene

El argumento de matriz de búsqueda de la familia de búsquedas, LOOKUP, MATCH, HLOOKUP, VLOOKUP, XLOOKUP y XMATCH, se marca ahora como referencia de escaneo. Una referencia de escaneo sigue sembrando suciedad, así que editar una celda dentro del rango recalcula la fórmula, pero nunca contribuye a la detección de ciclos ni al orden de evaluación. Los ciclos reales se siguen encontrando; los falsos han desaparecido

Por qué Excel permite que un rango de búsqueda contenga la fórmula?

Porque ese argumento no se consume como un operando aritmético. La familia de búsquedas escanea el rango en busca de valores en caché y devuelve una coincidencia; no exige que el rango se haya evaluado por completo antes. Excel trata un rango de búsqueda que se solapa consigo mismo como una lectura de lo que esas celdas contienen ahora mismo, que es la misma semántica que aplica a cualquier libro no iterativo: las celdas que no se han recalculado en esta pasada aportan su último valor calculado

Las referencias de columna completa hacen de esto el caso común y no uno exótico. B:B es la forma idiomática de escribir «toda la tabla de búsqueda» en una hoja donde se añaden filas, y cualquier fórmula que viva en la columna B queda entonces dentro de su propio rango de búsqueda. Los modelos financieros, las hojas de conciliación y los libros de auditoría hacen esto constantemente, normalmente sin que nadie note que el rango se solapa

La celda B7 contiene VLOOKUP(A1,B:B,1) dentro de su propio rango de búsqueda de columna completa B:B, un autosolape que Excel calcula desde valores en caché sin queja
Los rangos de búsqueda de columna completa hacen del autosolape el caso normal en modelos financieros y libros de auditoría, no un rincón exótico

Qué hace un grafo de dependencias con la misma fórmula

HotXLS recalcula incrementalmente, lo que exige un grafo de dependencias de verdad: nodos para celdas, aristas para referencias, un orden topológico para la evaluación y una pasada de componentes fuertemente conexos para clasificar ciclos. Esa maquinaria se describe en el artículo de recálculo incremental, y es precisamente por eso que apareció el falso positivo

Extraed las dependencias de =VLOOKUP(A1,B:B,1) en la celda B7 y el segundo argumento produce un rango que contiene a la propia B7. El grafo tiene ahora un self-loop. El grado de entrada de ese nodo nunca llega a cero, así que la pasada topológica nunca puede planificarlo, y la pasada de componentes lo clasifica como ciclo. El motor razona correctamente sobre el grafo que le dieron. El grafo es el modelo equivocado, porque codifica un tipo de arista donde la hoja de cálculo tiene dos

El rango de búsqueda B:B da al nodo del grafo B7 un self-loop, así que el grado de entrada nunca llega a cero y HotXLS antes de v2.361.98 informaba de una referencia circular falsa
El motor de recálculo razonó correctamente sobre el grafo que le dieron; el grafo era el modelo equivocado para una hoja de cálculo

Dos clases de arista, un grafo

El cambio añade una bandera al record de referencia resuelta, TXLSDepRange.LookupScan, que el extractor de dependencias activa cuando recorre el argumento de matriz de búsqueda de una de las seis funciones. Aguas abajo, las aristas procedentes de esas referencias se almacenan aparte de las ordinarias: el nodo del grafo guarda listas ScanDependents y ScanPrecedents junto a sus listas normales de dependientes y precedentes

La separación es lo que hace correcta la semántica. Las aristas de escaneo se recorren por la propagación de suciedad, así que una edición en cualquier punto de B:B sigue marcando B7 como sucia y B7 se recalcula. Las aristas de escaneo nunca se cuentan en el grado de entrada y nunca entran en el constructor de componentes, así que no pueden crear un bloqueo topológico ni clasificarse como ciclo. Ambas implementaciones de grafo de la biblioteca, el grafo clásico por libro y el grafo de espacio de trabajo entre libros que lleva el análisis de componentes, se cambiaron juntas; dejarlas divergir produciría un libro que recalcula de forma distinta según se abriera solo o como parte de un workspace

Las aristas de escaneo de TXLSDepRange.LookupScan impulsan la propagación de suciedad hacia ScanPrecedents y ScanDependents pero nunca cuentan en el grado de entrada ni en los ciclos
Las ediciones dentro de B:B siguen marcando la fórmula como sucia, pero las aristas de escaneo no pueden bloquear la pasada topológica ni fabricar un ciclo
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Ledger');
    Sheet.Cells[1, 1].Value := 'ACC-4471';
    Sheet.Cells[1, 2].Value := 1200.00;
    // El rango de búsqueda cubre la columna B, y esta fórmula vive en ella
    Sheet.Cells[7, 2].Formula := 'VLOOKUP(A1,B:B,1)';

    case Book.Recalculate of
      lxOk:
        // Antes de v2.361.98 esta rama era inalcanzable para esta hoja
        SaveReport(Book);
      lxErrorRef:
        LogWarning('Genuine circular reference - review model inputs');
    end;
  finally
    Book.Free;
  end;
end;

A qué renunciáis al excluir las aristas de escaneo del orden

Exactamente una cosa, y merece decirse llanamente en lugar de esconderla. Como las aristas de escaneo no participan en el orden topológico, una fórmula de búsqueda puede evaluarse en la misma pasada antes de que algunas celdas de su rango de búsqueda se hayan recalculado, y entonces leerá sus valores anteriores. El resultado converge en el siguiente recálculo

Eso es aceptable porque es lo que hace Excel. Para un libro sin cálculo iterativo activado, la propia respuesta de Excel para un valor aún no recalculado en la pasada actual es el último valor calculado, así que un motor que reproduce este comportamiento iguala a la implementación de referencia en lugar de aproximarla. Si necesitáis una respuesta genuinamente convergente sobre un modelo autorreferencial, el mecanismo para eso es el cálculo iterativo con un límite de iteración explícito, cubierto en el artículo de cálculo iterativo, y aplica a ciclos reales y no a solapes de escaneo

El riesgo de regresión escondido dentro del arreglo

Añadir LookupScan a TXLSDepRange introdujo un riesgo que no tiene nada que ver con búsquedas y todo que ver con Pascal. TXLSDepRange es un record no gestionado, así que una variable local de ese tipo no se inicializa a cero. Todo sitio de la base de código que construya uno a mano, incluidos los bloques de dependencias de tablas de datos y varios ayudantes de prueba, tuvo por tanto que actualizarse para asignar el campo nuevo explícitamente. Perder uno y el byte que hubiera en la pila decide si esa referencia se trata como arista de escaneo, lo que produce un error de recálculo que aparece y desaparece con cambios de código ajenos

// Un campo Boolean nuevo en un record no gestionado convierte cada
// sitio de construcción manual en un bug latente. Dos modismos seguros:
var
  R: TXLSDepRange;
begin
  FillChar(R, SizeOf(R), 0);      // poner todo a cero y luego rellenar
  R.Sheet1 := SheetIndex;
  R.Sheet2 := SheetIndex;
  R.Row1 := Row; R.Col1 := Col;
  R.Row2 := Row; R.Col2 := Col;

  // o asignar cada campo, incluido el nuevo, en cada sitio
  R.LookupScan := False;
end;

La regla general que esto se ganó: añadir un campo a un record que se construye en la pila en más de un puñado de sitios es un cambio de más riesgo del que parece, y el compilador no os ayudará a encontrar los sitios. Si el record es alcanzable desde una ruta caliente, preferid un ayudante que lo inicialice por completo a confiar en que cada punto de llamada se actualice

Distinguir un ciclo real de un solape de escaneo

Nada en este cambio debilita la detección de ciclos. =B7+1 en B7 sigue siendo un ciclo, una cadena de tres fórmulas que se cierra sobre sí misma sigue siendo un ciclo, y ambos se siguen informando a través del resultado de recálculo, con los miembros del ciclo conservando sus valores anteriores en caché mientras todo lo de fuera del ciclo se mantiene actualizado. Lo único que cambió es que el argumento de matriz de búsqueda ya no fabrica ciclos que Excel no ve

Si estáis auditando un libro y queréis saber qué referencias resolvió realmente el motor y en qué orden, el tracer de evaluación es la herramienta para ello; el artículo del tracer de evaluación de fórmulas cubre cómo leer su salida. HotXLS es un componente de hojas de cálculo nativo para Delphi y C++Builder que lee y escribe XLS, XLSX, ODS y CSV sin Excel instalado, y el motor de recálculo es el mismo en todos los formatos; la cobertura actual de funciones y motor está en la página de producto de HotXLS Delphi spreadsheet component