From ab4d58ca6ffd65a8e9e71fe0470916343ea41133 Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Wed, 14 Oct 2026 18:26:06 -0500 Subject: [PATCH] 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 --- ROADMAP.md | 4 +- crates/calc/src/tests.rs | 143 +++++++++++++++++++++++++++++++ crates/functions/src/criteria.rs | 9 +- crates/functions/src/lookup.rs | 63 ++++++++++++-- docs/function-parity.md | 3 +- 5 files changed, 211 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..fba2c6a35 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) | diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 196c57de7..4a2eb701a 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -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(); diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index e326267ce..58f0e6594 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -314,7 +314,9 @@ pub(crate) fn wildcard_find(pattern: &str, text: &str, start: usize) -> Option 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. diff --git a/crates/functions/src/lookup.rs b/crates/functions/src/lookup.rs index 56739c933..89ea86ad7 100644 --- a/crates/functions/src/lookup.rs +++ b/crates/functions/src/lookup.rs @@ -23,6 +23,19 @@ fn needle(args: &[Arg], i: usize) -> R { } } +/// 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 { 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 { 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 R 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(); diff --git a/docs/function-parity.md b/docs/function-parity.md index 760ffd6ac..6dad80804 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -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 |