Lookups compare numbers exactly, like Excel

MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP and LOOKUP compared numbers at
15 significant digits (through compare / compare_numbers), so a computed
key such as =0.1+0.2 (0.30000000000000004) found 0.3. Excel 365 compares
lookup keys exactly in exact, approximate and binary search modes, while
=, SWITCH and the criteria functions (COUNTIF(S), SUMIF(S), AVERAGEIF,
MAXIFS) keep the 15-digit comparison. Observed in Excel via COM with
synthetic values.

lookup_equal now compares numbers with ==, and the lookup searches order
values with lookup_order (exact for numbers, compare otherwise).
compare_numbers and the criteria path are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Harrison Booth
2026-10-14 18:26:06 -05:00
co-authored by Claude Opus 5.5
parent d9d09f0baa
commit ab4d58ca6f
5 changed files with 211 additions and 11 deletions
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (lookups compare numbers exactly, like Excel) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
@@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Progress log: lookups compare numbers exactly |
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
+143
View File
@@ -310,6 +310,149 @@ fn date_text_coerces_in_the_workbook_date_system() {
}
}
/// Lookups compare numbers exactly while `=` and the criteria functions compare them at 15
/// significant digits. Every expectation was observed in Excel 365 with these fixtures
/// (`=0.1+0.2` is 0.30000000000000004, `=100+1E-14` is 100.00000000000001).
#[test]
fn lookups_compare_numbers_exactly() {
let mut t = T::new();
for (at, input) in [
("A1", "=0.1+0.2"),
("A2", "0.3"),
("A3", "=100+1E-14"),
("A4", "100"),
("A5", "=1E15+0.5"),
("A6", "1E15"),
// Ascending 0.1, 0.3+e, 0.5 and the same descending.
("B1", "0.1"),
("B2", "=0.1+0.2"),
("B3", "0.5"),
("C1", "0.5"),
("C2", "=0.1+0.2"),
("C3", "0.1"),
// 0.3 next to 0.3+e, ascending and descending.
("D1", "0.3"),
("D2", "=0.1+0.2"),
("D3", "0.7"),
("E1", "0.7"),
("E2", "=0.1+0.2"),
("E3", "0.3"),
("G1", "=0.1+0.2"),
("H1", "x"),
("G2", "row2"),
// The bisection probes 0.3 first and must not run on into 0.3+e.
("J1", "0.1"),
("J2", "0.2"),
("J3", "0.3"),
("J4", "=0.1+0.2"),
("J5", "0.4"),
("K1", "a"),
("K2", "b"),
("K3", "c"),
("K4", "d"),
("K5", "e"),
] {
t.set(at, input);
}
let e = 0.1 + 0.2;
let n = Value::Number;
let s = |x: &str| Value::from(x);
let na = Value::Error(CellError::NA);
let cases: Vec<(&str, Value)> = vec![
// `=` and friends stay at 15 digits.
("=A1=A2", Value::Bool(true)),
("=A3=A4", Value::Bool(true)),
("=A5=A6", Value::Bool(true)),
("=SWITCH(A1,0.3,\"hit\",\"miss\")", s("hit")),
// Exact match.
("=MATCH(A1,A2:A2,0)", na.clone()),
("=MATCH(A2,A1:A1,0)", na.clone()),
("=VLOOKUP(A1,A2:A2,1,FALSE)", na.clone()),
("=HLOOKUP(A1,A2,1,FALSE)", na.clone()),
("=HLOOKUP(0.3,G1:H2,2,FALSE)", na.clone()),
("=XLOOKUP(A1,A2:A2,A2:A2,\"nf\")", s("nf")),
("=XMATCH(A1,A2:A2)", na.clone()),
("=MATCH(0.1+0.2,{0.3},0)", na.clone()),
("=MATCH(0.3,A1:A2,0)", n(2.0)),
("=MATCH(A1,A1:A2,0)", n(1.0)),
("=MATCH(0.3,INDEX(A1:A2,0),0)", n(2.0)),
("=MATCH(100,A3:A3,0)", na.clone()),
("=MATCH(1E15,A5:A5,0)", na.clone()),
("=XLOOKUP(0.3,A1:A2,{1;2},\"nf\",0,-1)", n(2.0)),
("=XMATCH(A1,A1:A2,0,-1)", n(1.0)),
// Approximate match against a single entry.
("=MATCH(0.3,A1:A1,1)", na.clone()),
("=MATCH(0.3,A1:A1,-1)", n(1.0)),
("=LOOKUP(0.3,A1:A1)", na.clone()),
("=LOOKUP(0.3,A1:A1,G2)", na.clone()),
("=VLOOKUP(0.3,A1:A1,1,TRUE)", na.clone()),
("=HLOOKUP(0.3,G1:H2,2,TRUE)", na.clone()),
("=XLOOKUP(0.3,A1:A1,A1:A1,\"nf\",-1)", s("nf")),
("=XLOOKUP(0.3,A1:A1,A1:A1,\"nf\",1)", n(e)),
("=XMATCH(0.3,A1:A1,-1)", na.clone()),
("=XMATCH(0.3,A1:A1,1)", n(1.0)),
("=MATCH(100,A3:A3,1)", na.clone()),
("=MATCH(A3,A4:A4,1)", n(1.0)),
// Approximate match in sorted lists.
("=MATCH(0.3,B1:B3,1)", n(1.0)),
("=MATCH(A1,B1:B3,1)", n(2.0)),
("=MATCH(0.3,C1:C3,-1)", n(2.0)),
("=MATCH(0.3,D1:D3,1)", n(1.0)),
("=MATCH(A1,D1:D3,1)", n(2.0)),
("=MATCH(0.3,E1:E3,-1)", n(3.0)),
("=MATCH(A1,E1:E3,-1)", n(2.0)),
("=MATCH(0.3,J1:J5,1)", n(3.0)),
("=MATCH(A1,J1:J5,1)", n(4.0)),
("=MATCH(0.3,J1:J5,0)", n(3.0)),
("=MATCH(A1,J1:J5,0)", n(4.0)),
("=VLOOKUP(0.3,J1:K5,2,TRUE)", s("c")),
("=VLOOKUP(A1,J1:K5,2,TRUE)", s("d")),
("=LOOKUP(0.3,J1:J5,K1:K5)", s("c")),
("=LOOKUP(A1,J1:J5,K1:K5)", s("d")),
// XLOOKUP / XMATCH match and search modes.
("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",0,2)", s("nf")),
("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",-1,2)", n(0.1)),
("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",1,2)", n(e)),
("=XMATCH(0.3,B1:B3,-1,2)", n(1.0)),
("=XMATCH(0.3,B1:B3,1,2)", n(2.0)),
("=XMATCH(0.3,C1:C3,1,-2)", n(2.0)),
("=XMATCH(0.3,C1:C3,-1,-2)", n(3.0)),
("=XMATCH(0.3,C1:C3,0,-2)", na.clone()),
("=XMATCH(0.3,J1:J5,0,2)", n(3.0)),
("=XMATCH(A1,J1:J5,0,2)", n(4.0)),
("=XMATCH(0.3,J1:J5,1,2)", n(3.0)),
("=XMATCH(0.3,J1:J5,-1)", n(3.0)),
("=XMATCH(0.3,J1:J5,1)", n(3.0)),
("=XMATCH(A1,J1:J5,-1)", n(4.0)),
("=XMATCH(0.3,B1:B3,1)", n(2.0)),
("=XMATCH(0.3,B1:B3,-1)", n(1.0)),
("=XMATCH(0.3,B1:B3,0,-1)", na.clone()),
("=XMATCH(0.3,D1:D3,1,-1)", n(1.0)),
// The criteria functions keep 15 digits.
("=COUNTIF(A1:A1,0.3)", n(1.0)),
("=COUNTIF(A1:A1,\"<0.3\")", n(0.0)),
("=COUNTIF(A1:A1,\">0.3\")", n(0.0)),
("=COUNTIF(A2:A2,A1)", n(1.0)),
("=COUNTIFS(A1:A1,0.3)", n(1.0)),
("=SUMIF(A1:A1,0.3)", n(e)),
("=SUMIFS(A1:A1,A1:A1,0.3)", n(e)),
("=AVERAGEIF(A1:A1,0.3)", n(e)),
("=MAXIFS(A1:A1,A1:A1,0.3)", n(e)),
("=COUNTIF(A3:A3,\">100\")", n(0.0)),
("=COUNTIF(J1:J5,0.3)", n(2.0)),
("=ROWS(FILTER(A1:A2,A1:A2=0.3))", n(2.0)),
("=MATCH(TRUE,A1:A2=0.3,0)", n(1.0)),
];
let mut wrong = Vec::new();
for (formula, expected) in cases {
let got = crate::evaluate(&t.wb, 0, c("Z1"), formula);
if got != expected {
wrong.push(format!("{formula}: got {got:?}, Excel {expected:?}"));
}
}
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
}
#[test]
fn operators() {
let mut t = T::new();
+6 -3
View File
@@ -314,7 +314,9 @@ pub(crate) fn wildcard_find(pattern: &str, text: &str, start: usize) -> Option<u
}
/// Exact-or-wildcard equality used by MATCH/VLOOKUP/XLOOKUP exact modes: text with wildcards
/// matches by pattern (only when `wild`), otherwise values compare case-insensitively.
/// matches by pattern (only when `wild`), otherwise values compare case-insensitively. Numbers
/// compare exactly, not at the 15 digits of `=` and the criteria functions (Excel 365:
/// `MATCH(0.1+0.2,{0.3},0)` is #N/A while `0.1+0.2=0.3` is TRUE).
pub(crate) fn lookup_equal(needle: &Value, hay: &Value, wild: bool) -> bool {
match (needle, hay) {
(Value::Text(p), Value::Text(t)) => {
@@ -324,7 +326,7 @@ pub(crate) fn lookup_equal(needle: &Value, hay: &Value, wild: bool) -> bool {
compare_text(p, t) == Ordering::Equal
}
}
(Value::Number(a), Value::Number(b)) => compare_numbers(*a, *b) == Ordering::Equal,
(Value::Number(a), Value::Number(b)) => a == b,
(Value::Bool(a), Value::Bool(b)) => a == b,
(Value::Error(a), Value::Error(b)) => a == b,
(Value::Empty, Value::Empty) => true,
@@ -370,7 +372,8 @@ mod tests {
assert!(m("#N/A", Value::Error(CellError::NA)));
assert!(Criterion::parse(&Value::Number(3.0)).matches(&Value::Number(3.0)));
assert!(Criterion::parse(&Value::Number(0.3)).matches(&Value::Number(0.1 + 0.2)));
assert!(lookup_equal(&Value::Number(0.3), &Value::Number(0.1 + 0.2), false));
assert!(!lookup_equal(&Value::Number(0.3), &Value::Number(0.1 + 0.2), false));
assert!(lookup_equal(&Value::Number(0.0), &Value::Number(-0.0), false));
assert!(m("<>a*", Value::from("bcd")));
assert!(m(">1/1/2020", Value::Number(44000.0)));
// Text patterns never match blank cells, but an empty string is text.
+57 -6
View File
@@ -23,6 +23,19 @@ fn needle(args: &[Arg], i: usize) -> R<Value> {
}
}
/// Order of two values for the approximate and binary searches: [`compare`], except that numbers
/// compare exactly. Excel's `=` treats numbers equal to 15 significant digits as equal, but its
/// lookups don't. Observed in Excel 365 with A1 holding
/// `=0.1+0.2` (0.30000000000000004): `A1=0.3` is TRUE, yet `MATCH(0.3,A1,1)` is #N/A because
/// A1 is larger, and in {0.1;0.2;0.3;A1;0.4} `MATCH(0.3,…,1)` is 3 and `MATCH(A1,…,1)` is 4.
/// The same holds for `=100+1E-14` against 100, and in every XLOOKUP / XMATCH mode.
fn lookup_order(a: &Value, b: &Value) -> Ordering {
match (a, b) {
(Value::Number(x), Value::Number(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
_ => compare(a, b),
}
}
fn same_type(a: &Value, b: &Value) -> bool {
matches!((a, b), (Value::Number(_), Value::Number(_)) | (Value::Text(_), Value::Text(_)) | (Value::Bool(_), Value::Bool(_)))
}
@@ -59,10 +72,10 @@ fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option<usize> {
continue;
};
let v = hay.get(j)?;
let o = compare(v, x);
let o = lookup_order(v, x);
if o == Ordering::Equal {
let mut k = j;
while hay.get(k + 1).is_some_and(|w| same_type(x, w) && compare(w, x) == Ordering::Equal) {
while hay.get(k + 1).is_some_and(|w| same_type(x, w) && lookup_order(w, x) == Ordering::Equal) {
k += 1;
}
return Some(k);
@@ -88,8 +101,8 @@ fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option<usize> {
fn binary_order(v: &Value, x: &Value, desc: bool) -> Ordering {
match v {
Value::Empty => Ordering::Greater,
_ if desc => compare(v, x).reverse(),
_ => compare(v, x),
_ if desc => lookup_order(v, x).reverse(),
_ => lookup_order(v, x),
}
}
@@ -261,7 +274,7 @@ fn xsearch(x: &Value, hay: &[Value], match_mode: i64, search_mode: i64) -> R<Opt
if !same_type(x, v) {
continue;
}
let o = compare(v, x);
let o = lookup_order(v, x);
let candidate = if match_mode == -1 { o == Ordering::Less } else { o == Ordering::Greater };
if !candidate {
continue;
@@ -269,7 +282,7 @@ fn xsearch(x: &Value, hay: &[Value], match_mode: i64, search_mode: i64) -> R<Opt
let better = match best.and_then(|b| hay.get(b)) {
None => true,
Some(bv) => {
let c = compare(v, bv);
let c = lookup_order(v, bv);
if match_mode == -1 { c == Ordering::Greater } else { c == Ordering::Less }
}
};
@@ -747,6 +760,44 @@ mod tests {
assert_eq!(rows_of(&r), vec![vec![nv(3.0), nv(1.0)]]);
}
/// Lookups compare numbers exactly, unlike `=` (15 significant digits). Observed in Excel 365.
#[test]
fn lookups_compare_numbers_exactly() {
let e = 0.1 + 0.2;
let one = || rf(col(&[e]));
// Exact modes miss a key one bit away.
is_err(ev("MATCH", vec![n(0.3), one(), n(0.0)]), CellError::NA);
is_err(ev("VLOOKUP", vec![n(0.3), one(), n(1.0), b(false)]), CellError::NA);
is_err(ev("XMATCH", vec![n(0.3), one()]), CellError::NA);
close(ev("MATCH", vec![n(0.3), rf(col(&[e, 0.3])), n(0.0)]), 2.0);
close(ev("MATCH", vec![n(e), rf(col(&[e, 0.3])), n(0.0)]), 1.0);
is_err(ev("MATCH", vec![n(100.0), rf(col(&[100.0 + 1e-14])), n(0.0)]), CellError::NA);
// 0 and -0 are still the same key.
close(ev("MATCH", vec![n(-0.0), rf(col(&[0.0])), n(0.0)]), 1.0);
// Approximate modes: 0.3 sorts below 0.30000000000000004.
is_err(ev("MATCH", vec![n(0.3), one(), n(1.0)]), CellError::NA);
close(ev("MATCH", vec![n(0.3), one(), n(-1.0)]), 1.0);
is_err(ev("LOOKUP", vec![n(0.3), one()]), CellError::NA);
is_err(ev("XMATCH", vec![n(0.3), one(), n(-1.0)]), CellError::NA);
close(ev("XMATCH", vec![n(0.3), one(), n(1.0)]), 1.0);
// The bisection stops on 0.3 and doesn't run on into the next entry.
let list = || rf(col(&[0.1, 0.2, 0.3, e, 0.4]));
close(ev("MATCH", vec![n(0.3), list(), n(1.0)]), 3.0);
close(ev("MATCH", vec![n(e), list(), n(1.0)]), 4.0);
close(ev("XMATCH", vec![n(0.3), list(), n(0.0), n(2.0)]), 3.0);
close(ev("XMATCH", vec![n(e), list(), n(0.0), n(2.0)]), 4.0);
let asc = || rf(col(&[0.1, e, 0.5]));
let desc = || rf(col(&[0.5, e, 0.1]));
close(ev("MATCH", vec![n(0.3), asc(), n(1.0)]), 1.0);
close(ev("MATCH", vec![n(0.3), desc(), n(-1.0)]), 2.0);
is_err(ev("XMATCH", vec![n(0.3), asc(), n(0.0), n(2.0)]), CellError::NA);
close(ev("XMATCH", vec![n(0.3), asc(), n(-1.0), n(2.0)]), 1.0);
close(ev("XMATCH", vec![n(0.3), asc(), n(1.0), n(2.0)]), 2.0);
close(ev("XMATCH", vec![n(0.3), desc(), n(-1.0), n(-2.0)]), 3.0);
close(ev("XMATCH", vec![n(0.3), asc(), n(-1.0)]), 1.0);
close(ev("XMATCH", vec![n(0.3), asc(), n(1.0)]), 2.0);
}
#[test]
fn index() {
let t3 = table_rows3();
+2 -1
View File
@@ -1,6 +1,6 @@
# Function parity: worksheet functions and formulas
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (lookups compare numbers exactly, like Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Excel's worksheet functions, category by category, against GridCraft's function library. Part of
[target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md).
@@ -108,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP and LOOKUP compare numbers exactly in every match and search mode, as Excel 365 does: `MATCH(0.1+0.2,{0.3},0)` is `#N/A` and 0.3 sorts below 0.30000000000000004 in approximate and binary search. `=`, SWITCH and the criteria functions (COUNTIF(S), SUMIF(S), AVERAGEIF(S), MAXIFS…) keep comparing at 15 significant digits, also as observed |
| 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) |
| 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) |
| 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled |