A formula such as =A1+A2+…+A500 nests one level per operator, and the
evaluator recursed once per level, so past its depth limit (about 400
operators) the result was #CALC!. Excel allows such formulas up to its
8,192-character limit. Chains of arithmetic, comparison and & operators
are now folded in a loop.
A formula under a criteria label that names no field is evaluated for each record: its relative references move from the first record to that record, and the database labels name the record's fields, as in Excel. The calc evaluator passes the criteria reference to the database functions through Ctx::computed_criterion.
Excel lets a worksheet function be passed by name where a function is
expected. In GridCraft GROUPBY(A2:A9,B2:B9,SUM) and PIVOTBY gave #NAME?
(their documented form), and BYROW, BYCOL, MAP, REDUCE, SCAN and
MAKEARRAY gave #VALUE!. A name that is a worksheet function, and not a
LET/LAMBDA variable or a defined name, now works as a function there.
Reading a file, the _xleta. prefix Excel writes for these names is
removed.
XLOOKUP returned only values, so it couldn't end a range or feed
functions that need a reference: SUM(XLOOKUP("b",A1:A4,B1:B4):
XLOOKUP("d",A1:A4,B1:B4)) and SUM(B1:XLOOKUP(…)) gave #VALUE!, and so
did CELL("address",XLOOKUP(…)). A match on an empty cell gave 0, so
ISBLANK(XLOOKUP(…)) was FALSE and "x"&XLOOKUP(…) gave "x0" (#374). In
Excel XLOOKUP returns a reference to the matching row (or column) of
its result range. The evaluator now does so, finding the position with
XMATCH; arrays of lookup values, array arguments and errors still go
through the library function.
A name whose references have no $ was evaluated as if they were
absolute. In Excel such references are relative: defined from B2,
=Sheet1!A2 means "the cell to the left", and the file stores it
relative to A1 (Sheet1!XFD1, wrapping around the sheet's edge).
Name formulas with relative references are now moved by the using
cell's offset from A1, wrapping at the edges, and formulas.defineName
takes relative references as relative to the active cell, as Excel's
Name Manager does, and stores them relative to A1.
INDEX took one row and one column number, so INDEX(A1:C3,{1;3},{1,3})
gave a single value and SUM(INDEX(A1:C3,{1,2,3},2)) summed one cell.
In Excel, arrays of positions return an array of the values at each
(broadcast) position. A position that picks a whole row or column gives
#CALC! there, as arrays can't nest.
Evaluator special form returning the range (or array) without its blank
outer rows and columns; trim codes 0-3 as documented; written as
_xlfn.TRIMRANGE. The trim-reference operators are not parsed yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The evaluator took a single index, so CHOOSE({1,2},"a","b") gave "a"
and VLOOKUP(x,CHOOSE({1,2},B1:B3,A1:A3),2,FALSE) gave #REF!. An array
index now goes through the library CHOOSE, which lifts over its
arguments as Excel does.
=SUM(C3:INDEX(B9:C11,2,2)) adds C3:C10, but only C3 and B9:C11 were
recorded as its precedents, so typing into C5 left the total unchanged
until a full recalculation. A range whose ends are function results now
depends on the box spanned by the references inside it, which contains
any cell those functions can return.
A multi-cell reference was read only up to the sheet's used range, so
the array it gave depended on what else was on the sheet:
ROWS(A1:A10*1), COUNTBLANK(A1:A10) and SUMPRODUCT(--(A1:A10="")) came
out smaller than in Excel, and changed as cells were entered elsewhere
without the formula recalculating. References of up to 16,384 cells now
keep their size; larger ones (A:A, ranges reserved for data to come
such as $A$2:$A$50000) still stop at the used range, so that formulas
over them stay fast.
formula::parse("Sheet1!$D$1#") failed with "unknown error literal".
A # right after a cell reference is now the spill range operator: it
evaluates to the anchor's current spill range, or #REF! when the cell
doesn't spill (or its spill is blocked). Its users depend on the
anchor, so they recalculate when the spill grows or shrinks. Names can
use it.
Files have no # operator: like Excel, the writer stores A1# as
_xlfn.ANCHORARRAY(A1), and the parser reads that back as A1#.
Opening a workbook with a SUM(E:J) on each of 50,000 rows took 9 s
(1.7 s at 25,000, 0.4 s at 12,500), and editing a cell that 50,000
VLOOKUPs read froze the app for 40 s or more. Two things grew with the
number of ranges:
- Evaluating a range trims it to the sheet's used range, and
Sheet::used_range walks every stored row. It ran once per range read,
so a full recalc was rows x range formulas. The pass's host now works
it out once per sheet (the workbook doesn't change during a pass);
Host::used_range keeps the old behaviour for other hosts.
- The reverse index kept each column's range precedents in a Vec:
finding a cell's dependents scanned every range in the column, and
re-entering a formula ran a retain over each of its columns. Pasting
a column, filling formulas down or sorting was quadratic. Each column
(and each sheet's wide ranges) now has an interval index: sorted by
start, with a segment tree of the largest end, so a lookup costs
O(log n + matches). Removals mark entries dead and insertions wait in
a small sorted set; Graph::settle merges them after each batch, so
edits cost O(log n) amortised. Single-cell dependents are a set, so
removing one is O(1). dependents_of_range asks each column once
instead of once per cell.
Semantics are unchanged: whole rows and columns, other sheets, names,
tables, spills and volatile functions go through the same paths.
Release CLI, open + full recalc: 12.5k rows 0.39 s -> 0.06 s, 25k 1.83 s
-> 0.14 s, 50k 9.3 s -> 0.28 s. The 50k x 20 bug-hunt workbook: recalc
on open 87 s -> 1.7 s, editing C2 (100k dependents) 51 s -> 1.3 s;
filling 50k SUMs down 17 s -> 0.18 s, sorting 50k rows 9 s -> 0.14 s.
Tests: the interval index against a linear scan (inserts, removals,
revivals, merges) and its query cost in tree nodes; range, wide,
whole-column, whole-row and cross-sheet dependents after edits; and a
scaling test (open, change a column, re-enter every formula) that
allows 8x the time for 4x the rows. Before the fix it took 16x.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Excel adds up the range that starts at sum_range's top-left cell and has
the criteria range's height and width, whatever size sum_range has. With
A1:B3 = a,1 / b,2 / a,3, =SUMIF(A1:A3,"a",B1) is 4; GridCraft gave 1,
because if_numbers only saw sum_range's values (one cell) and read them
at the criteria positions. Its comment promised the resizing, but by then
the argument was a plain value.
The evaluator has the references, so it now does the resizing, as it does
for OFFSET and INDEX: for SUMIF and AVERAGEIF with three arguments, a
single-area sum range reference becomes resized_sum_range(sum, criteria
height, criteria width), cut off at the sheet's edge. It works across
sheets and through names. The dependency graph records the resized
range, so editing a cell inside it recalculates; when the criteria range
isn't a plain reference the formula is recalculated every time instead.
SUMIFS and AVERAGEIFS keep requiring same-size ranges (#VALUE!
otherwise).
Tests: a single-cell, shorter, wider, longer and horizontal sum range,
whole-column criteria, editing a cell of the resized range, the sheet
edge, another sheet and a defined name, and SUMIFS/AVERAGEIFS size
mismatches. They fail before the fix (1 instead of 4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>