Skip to content

fix(statistical): an all-blank array is 0, not three different answers - #782

Merged
hhimanshu merged 2 commits into
mainfrom
fix/775-blank-only-arrays
Jul 28, 2026
Merged

fix(statistical): an all-blank array is 0, not three different answers#782
hhimanshu merged 2 commits into
mainfrom
fix/775-blank-only-arrays

Conversation

@hhimanshu

@hhimanshu hhimanshu commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

closes #775

Summary

Four functions that should agree gave three different answers for an array where every element is blank:

before Sheets
MAX #REF! 0
MIN 0 0
MAXA #N/A 0
MINA #N/A 0

MIN was the only one already right. A blank-only array is what a range over empty cells materialises as, so =MAX(A1:A3) on an untouched column is the everyday form of this.

Getting the evidence took fixing the probe harness first

An earlier attempt to capture this measured the harness instead of Sheets. With M4 holding 1, =MAX(Data!M4) returned #REF! while =COUNTA(Data!M1:M4) returned 1 — impossible as real behaviour, and only visible because a control asserted a boring known value.

Root cause: the evaluator resolves the setup sidecar from the path it actually reads rows from. These functions read from common/, so the sidecar had to be at test-inputs/common/{FN}.setup.json. It had been placed under google_sheets/statistical/, where it was never read — so no Data sheet was created and every reference correctly returned #REF!.

(SPARKLINE's probes worked only by accident: it is a Sheets-only function with no common/ file, so its rows and sidecar happen to share a directory. The two paths coincided by luck, not by rule.)

The rule generalises past one shape — and that is now captured, not assumed

The predicate is "every argument blank AND at least one arrived as an array", which is broader than the single shape first captured. At the Value layer the engine cannot see range geometry, so a narrower predicate is not expressible.

Rather than ship that as a judgement call, all seven shapes were probed — each with a populated control proving the range actually resolved:

shape value control
A1:A1 single-cell 0 7
A1:A3 single-column 0 7
A1:B1 multi-column 0 7
A1:B2 2-D 0 7
A1:A100 far past the used area 0 7
A1,A1:A3 scalar then range 0 7
A1:A3,A1 reversed 0 7

Identical for all four functions. Blankness asserted rather than assumed (=COUNTA(Data!A1:B100)0, =COUNTBLANK(Data!A1:A100)100), and the type read back through a real cell → 0 number, so it is a plain zero and not a date-formatted one.

⚠️ None of those rows are in this repo yet

They live on the conformance-fixtures pipeline branch and land in a separate fixtures-only PR. Two reasons, both forced: three of the four fail until this code exists, and CI rejects any PR touching both fixture TSVs and code.

A reviewer working from this repo alone cannot verify the Sheets answer. The nearest in-repo rows are =MAX({})#REF! and the sparkline Data!K1:K1 rows; neither covers a blank-only array. The doc on is_blank_only_array says this explicitly, and each test comment notes that read from this repo alone it pins the behaviour, not the Sheets answer.

Review

  • Independent review with a 249,910-case differential built from scratch: zero movement outside the blank-only-array class. The predicate is exhaustive over all 10 Value variants with no catch-all — a new variant is a compile error there — it fires strictly after every error path, and costs nothing on the hot path.
  • Two findings, both about claims rather than behaviour, both fixed: the generalisation was undisclosed (now captured and stated), and comments implied local backing they lacked (now explicit about what is and is not checkable here).

How to test

cargo test -p truecalc-core statistical::max
cargo test -p truecalc-core statistical::min
cargo test --workspace

Directly — =MAX(A1:A3), =MIN(A1:A3), =MAXA(A1:A3), =MINA(A1:A3) over three empty cells all return 0. Before: #REF!, 0, #N/A, #N/A.

Test plan

  • cargo test --workspace — 3766 passed, 89 suites
  • cargo clippy --workspace -- -D warnings — clean
  • cargo nextest run --workspace --profile ci — 3759/3759
  • No fixture TSV touched
  • cargo fmt --check fails only on files this branch never touched (main is not fmt-clean; fmt is not a CI gate)
  • CI green

Related

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

hhimanshu and others added 2 commits July 29, 2026 09:16
MAX, MIN, MAXA and MINA gave three different answers for an array whose
every element is blank — the shape a range over empty cells materialises
as, so `=MAX(A1:A3)` on an untouched column is the everyday form:

    MAX   #REF!
    MIN   0
    MAXA  #N/A
    MINA  #N/A

A range-based probe with controls settles it: Google Sheets answers the
number 0 for all four. The probe carried a boring-value control in both
directions — `=MAX(Data!M4)` with M4 holding 1 returns 1, and
`=MAX(Data!M1:M4)` over a range whose only non-blank cell holds 1 also
returns 1 — so a silent harness failure could not masquerade as a
result. The same controls hold for MIN, MAXA and MINA.

MIN was already there. MAX, MAXA and MINA are brought to it by one
shared predicate, `stat_helpers::is_blank_only_array`, which is true only
when every argument is blank and at least one arrived as an array.

Deliberately narrow, in two directions:

- The decision is over the arguments as a whole, not a per-element
  "had content" flag. A blank next to a date still answers #REF! from
  MAX rather than a believable 0; date-only arrays are a separate,
  still-unprobed defect and are untouched here.
- Requiring an array confines the rule to the range form. A bare blank
  argument (`=MAXA(A1)` on an empty cell) is unprobed and keeps its
  #N/A.

The predicate matches every `Value` variant explicitly instead of using
a catch-all, so a variant added later is a compile error rather than a
silent 0.

Differential against main: a probe compiled on both revisions ran 7,931
distinct input shapes — numeric, text-only, numeric text, empty string,
boolean-only, mixed, blanks, dates, zoned instants, sparklines, errors
leading and trailing, scalars, multi-argument, empty arrays, flat and
nested and 2-D arrays, and range-delivered forms through a seeded
resolver — for 31,724 evaluations per revision. 72 evaluations changed,
every one of them an all-blank input carrying an array; 31,628
evaluations outside that class were byte-identical, and no MIN
evaluation changed anywhere.

The pinning tests added with the empty-array and numberless-array work
are updated to the captured behaviour, and new tests pin both narrowing
decisions above.

Fixture rows land separately.

closes #775
The blank-only rule is broader than the one range shape originally
captured, so the comments now name every shape that was probed: a
single cell, a single column, a row across columns, a two-dimensional
range, a range far past the used area, and a blank scalar beside a
blank range in both orders. Each carried a populated control that
returned its value, blankness was asserted with COUNTA/COUNTBLANK, and
the answer's type was read back through a real cell as a plain number.

The comments also stop implying local backing. Nothing under
tests/fixtures/google_sheets/ covers a blank-only array today; the rows
sit on the conformance-fixtures pipeline branch feat/stat-range-probe
and land in a separate fixtures-only PR, since three of the four fail
until this code exists. A reviewer working from this repo alone can now
tell what is checkable here and what is not.

Comments that asserted where other numberless kinds end up are narrowed
to describe only what this change moves.

Comments only; no behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hhimanshu hhimanshu self-assigned this Jul 28, 2026
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Test Coverage by Category

Category Unit Tests Google Sheets Conformance Property Cases Total
Array 42 547/547 ✓ 1,000 (2×500) 1,589
Database 35 182/182 ✓ 3,500 (7×500) 3,717
Date 369 418/418 ✓ 2,500 (5×500) 3,287
Engineering 245 886/888 ⚠ 5,500 (11×500) 6,633
Filter 11 80/80 ✓ 4,500 (9×500) 4,591
Financial 149 1,208/1,208 ✓ 2,000 (4×500) 3,357
Info 0 256/256 ✓ 4,500 (9×500) 4,756
Logical 121 263/263 ✓ 3,500 (7×500) 3,884
Lookup 69 392/392 ✓ 1,000 (2×500) 1,461
Math 536 2,006/2,006 ✓ 8,000 (16×500) 10,542
Operator 87 250/250 ✓ 7,500 (15×500) 7,837
Parser 83 92/92 ✓ 4,000 (8×500) 4,175
Query 37 37
Statistical 501 3,156/3,156 ✓ 5,000 (10×500) 8,657
Text 298 729/733 ⚠ 4,000 (8×500) 5,031
Timezone 47 47
Volatile 0 3,500 (7×500) 3,500
Web 29 56/56 ✓ 6,000 (12×500) 6,085
Total 2,915 10,521/10,527 66,000 (132×500) ~79,442

✓ = 100% passing · ⚠ = known deviation · The ~79,442 total counts formula evaluations (each conformance row and each property case = 1). GitHub Checks reports 3,759 Rust test functions: 2,915 unit + 159 property functions (shown as cases above) + 685 conformance/integration.

@hhimanshu
hhimanshu merged commit 344a45c into main Jul 28, 2026
9 checks passed
@hhimanshu
hhimanshu deleted the fix/775-blank-only-arrays branch July 28, 2026 23:10
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 28, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(statistical): MAX/MIN/MAXA/MINA give three different answers for an all-blank array

1 participant