Skip to content

Add MUNIT, MDETERM and MINVERSE functions (HF-401, HF-399, HF-400) - #1817

Open
marcin-kordas-hoc wants to merge 15 commits into
developfrom
feat/hf-matrix-munit-mdeterm-minverse
Open

marcin-kordas-hoc wants to merge 15 commits into
developfrom
feat/hf-matrix-munit-mdeterm-minverse

Conversation

@marcin-kordas-hoc

@marcin-kordas-hoc marcin-kordas-hoc commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Context

Adds the matrix functions MUNIT, MDETERM and MINVERSE to MatrixPlugin, next to MMULT.

  • MUNIT(dimension) returns the identity matrix of that size. Numeric text such as "3" is read as a number, with the configured decimal separator, and a fractional dimension is truncated. A dimension below 1, text, an empty text, an empty cell or a dimension above the sheet's row or column limit is #VALUE!, and TRUE() is 1.
  • MDETERM(array) returns the determinant of a square matrix of numbers. A matrix that is not square, or that holds text, an empty cell or a boolean, is #VALUE!, even when it also holds an error. A non-square matrix of numbers gets the message "Matrix must be square." Otherwise an error inside the matrix propagates. A single number is its own determinant.
  • MINVERSE(array) returns the inverse of a square matrix, with the same #VALUE! cases. A singular matrix is #NUM!.
  • Approach: both functions use one LU decomposition with partial pivoting. MDETERM multiplies the pivots, and MINVERSE solves one pair of triangular systems per column. A matrix is singular when the elimination meets a pivot that is exactly 0.
  • Rejected: fraction-free (Bareiss) elimination. It gives an exact 0 for small singular integer matrices, but it multiplies minors. A 3x3 matrix with elements near 1E100 returned #NUM! instead of its determinant (about 6E300), and MINVERSE returned #NUM! for matrices that have an inverse.
  • Rounding: a singular matrix whose elimination does not meet an exact zero pivot gives rounding noise, as in Excel. MDETERM({1,2,3;4,5,6;7,8,9}) returns about 6.66E-16 in both, and MINVERSE of that matrix returns about -4.5E+15 at position (1,1) in both, instead of #NUM!.
  • Translations: every localized name was checked on Microsoft's function page for its locale: the name must appear in both the page title and the syntax line. For MUNIT, esES is M.UNIDAD and ruRU is МЕДИН, taken from those pages. idID keeps the English names.
  • Result size at parse time: MUNIT and SEQUENCE read a dimension written as a number, with an optional sign, through one FunctionPlugin method, signedNumberLiteral. Each keeps its own handling of a text dimension, and SEQUENCE keeps TRUE() and FALSE(); no SEQUENCE result changes.
  • Also in this PR: catalogue entries, the license capability table, a CHANGELOG entry, and MUNIT added to the known limitation on result sizes that must be known at parse time.

How did you test your changes?

  • Paired branch feat/hf-matrix-munit-mdeterm-minverse in hyperformula-tests. It adds unit/interpreter/function-munit.spec.ts, function-mdeterm.spec.ts and function-minverse.spec.ts, with 51, 48 and 58 tests of one assertion each, and matrix-functions-layout.ts, the sheet the expected values were measured on.
  • With the non-square message switched back to MMULT's, the 2 message tests fail. With the parse-time result size of MUNIT or MINVERSE forced to one cell, 6 and 3 tests fail, the #SPILL! tests among them. With signedNumberLiteral ignoring a unary plus, the =MUNIT(+2) spill test and 2 SEQUENCE tests fail. All pass now.
  • 202 test matrices, sizes 2 to 60, compared with exact rational results. 108 of them are non-singular and their determinant is a normal number. The previous elimination returned #NUM! or 0 for 11 of the 108. LU returns #NUM! for 1: its pivots overflow when multiplied, although the determinant is about 3E137. Excel also returns #NUM! for a matrix whose pivot product overflows (table below). The previous elimination also returned #NUM! from MINVERSE for 3 invertible matrices; LU for none.
  • Differential check of the previous head against this one: 2990 formulas over matrices scaled from 1E-300 to 1E300, with 0 blocking lines after 15 accepted ones. All 15 are INDEX(MINVERSE(...)) of {1,2,3;4,5,6;7,8,9} scaled by a power of ten. That matrix is singular, and it now gives #NUM! instead of rounding noise.
  • Full jest run on this head with the paired tests: 523 suites, 6899 passed, 3 skipped.

Compatibility with Excel and Google Sheets

60 formulas were evaluated in Excel Online on one data layout (A1:B2 = {1,2;3,4}, A3 = text); 58 match. Selected rows:

Formula Excel Google Sheets HyperFormula
=INDEX(MUNIT(3),2,2) 1 not checked 1
=ROWS(MUNIT(2.9)) 2 not checked 2
=MUNIT(0) #VALUE! not checked #VALUE!
=MUNIT(TRUE()) 1 not checked 1
=MDETERM(A1:B2) -2 not checked -2
=MDETERM(A1:C2) #VALUE! not checked #VALUE!
=MDETERM(TRUE()) #VALUE! not checked #VALUE!
=INDEX(MINVERSE(A1:B2),2,1) 1.5 not checked 1.5
=MINVERSE(0) #NUM! not checked #NUM!
=MDETERM(A1:B2*2) -8 not checked #VALUE!; -8 with useArrayArithmetic
=MINVERSE(A3:B4) (a range with text) #VALUE! not checked #VALUE! in each cell of the predicted 2x2 result, #SPILL! when that area is occupied

The last two rows are differences. MDETERM(A1:B2*2) needs array arithmetic, which HyperFormula enables only with useArrayArithmetic. MINVERSE sizes its result before it evaluates the argument, so an error result still occupies a 2x2 area.

14 more formulas were measured in Excel Online after the first round; 13 match:

Formula Excel Google Sheets HyperFormula
=MDETERM({1,2,3;4,5,6;7,8,9}) 6.66E-16 not checked 6.66E-16
=INDEX(MINVERSE({1,2,3;4,5,6;7,8,9}),1,1) about -4.5036E+15 not checked about -4.5036E+15
=ROWS(MUNIT("3")) 3 not checked 3
=ROWS(MUNIT(+3)) 3 not checked 3
=MDETERM(A1:C3) with {1,2,0;0,3,1;2,1,1} times 1E100 6E+300 not checked 6E+300
=MDETERM(A1:C3) with the same matrix times 1E-90 6E-270 not checked 6E-270
=MDETERM(A1:B2) with {1E200,0;0,1E200} #NUM! not checked #NUM!
=MDETERM(A1:C3) with {1E200,0,0;0,1E200,0;0,0,1E-200} #NUM! not checked #NUM!
=INDEX(MINVERSE(A1:C3),1,1) with {1E-170,0,0;0,1,0;0,0,1E-170} 1E+170 not checked 1E+170
=INDEX(MINVERSE(A1),1,1) with A1 = 1E-307 1E+307 not checked 1E+307
=INDEX(MINVERSE(1e-307/1000),1,1) #NUM! not checked #NUM!
=MDETERM(A1:B2) with {1,"x";=1/0,4} #VALUE! not checked #VALUE!
=MDETERM(A1:B2) with {TRUE,2;3,4} #VALUE! not checked #VALUE!
=ROWS(MUNIT(A1:A2)) with A1:A2 = 3, 2 2 not checked #VALUE!

The last row is a difference: Excel returns one result per cell of a range of dimensions, and HyperFormula does not. It is listed in docs/guide/list-of-differences.md.

6 more formulas were measured in Excel Online on 2026-11-02; all match. Each #SPILL! row has a value inside the area the result needs, and the same formula on an empty area spills in both.

Formula Excel Google Sheets HyperFormula
=MUNIT(2) in A1, B2 occupied #SPILL! not checked #SPILL!
=MINVERSE(A1:B2) in C1, D2 occupied #SPILL! not checked #SPILL!
=MINVERSE({1,2;3,4}) in A1, B2 occupied #SPILL! not checked #SPILL!
=MDETERM(A1:C2) with A1:C2 = {1,2,3;4,5,6} #VALUE! not checked #VALUE!
=MINVERSE(A1:C2) with the same range #VALUE! not checked #VALUE!
=SUMPRODUCT(ABS(MMULT(D1:F3,MINVERSE(D1:F3))-{1,0,0;0,1,0;0,0,1})), D1:F3 = {2,1,1;1,3,2;1,0,0} 3.1E-15 not checked 2.7E-15 with useArrayArithmetic

Public API changes

None.

Out of scope and follow-ups

  • The formula parser does not read Cyrillic function names, so no ruRU function name can be typed in a formula; the new tests check the Russian names through getRegisteredFunctionNames. This already affects every ruRU function and is not changed here.
  • MUNIT with a cell reference or a formula as the dimension (=MUNIT(A1)) is #VALUE! unless the result is 1x1, like SEQUENCE; see the known limitations.
  • SEQUENCE still reads a text dimension at parse time with JavaScript Number(), while MUNIT coerces it the way evaluation does. Aligning SEQUENCE is a follow-up.

Types of changes

  • Breaking change (existing functionality no longer works as before) – agreed with the maintainers
  • New feature or improvement (a non-breaking change that adds functionality)
  • Bug fix (a non-breaking change that fixes an issue)
  • Additional language file, or a change to an existing language file (translations)
  • Change to the documentation

Related issues

  1. HF-399, HF-400, HF-401

Checklist

🤖 Generated with Claude Code


Note

Low Risk
Additive formula features in MatrixPlugin with no public API changes; numerical edge cases follow Excel-style LU behavior already described in tests.

Overview
Adds Excel-style matrix functions MUNIT, MDETERM, and MINVERSE in MatrixPlugin, alongside existing MMULT.

MUNIT(dimension) builds an identity matrix (dimension truncated, capped by sheet limits). MDETERM and MINVERSE require square numeric arrays; non-square inputs use new SquareMatrix messages, singular inverses use SingularMatrix / #NUM!. Both share LU decomposition with partial pivoting—determinant from pivot products, inverse via triangular solves.

Parse-time spill sizing: MUNIT and MINVERSE declare result dimensions before evaluation. signedNumberLiteral on FunctionPlugin centralizes reading literal dimensions (including unary +/-); SequencePlugin now uses it for SEQUENCE without intended behavior change.

Also updates localized names across language packs, license capabilities, function catalog metadata, CHANGELOG, docs (MUNIT in parse-time array-size limits; Excel difference for MUNIT with a range argument).

Reviewed by Cursor Bugbot for commit e6578aa. Bugbot is set up for automated code reviews on this repo. Configure here.

marcin-kordas-hoc and others added 2 commits October 15, 2026 04:53
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
hyperformula-docs e6578aa Commit Preview URL

Branch Preview URL
Nov 04 2026, 06:27 AM

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Performance comparison of head (a4f00d6) vs base (16c1caf)

                                     testName |    base |    head | change
--------------------------------------------------------------------------
                                      Sheet A |  501.11 |   497.6 | -0.70%
                                      Sheet B |  165.42 |  162.24 | -1.92%
                                      Sheet T |  147.37 |  141.71 | -3.84%
                                Column ranges |  491.45 |  476.79 | -2.98%
                                Sorted lookup | 15096.8 | 14266.3 | -5.50%
Sheet A:  change value, add/remove row/column |   16.64 |   16.74 | +0.60%
 Sheet B: change value, add/remove row/column |  129.55 |  136.88 | +5.66%
                   Column ranges - add column |  164.31 |  149.88 | -8.78%
                Column ranges - without batch |  493.03 |  483.71 | -1.89%
                        Column ranges - batch |  117.86 |  121.43 | +3.03%

marcin-kordas-hoc and others added 4 commits October 23, 2026 00:22
…rtial pivoting

The fraction-free elimination multiplied minors together, so a matrix with large or small elements
overflowed or underflowed although its determinant was a number, and MINVERSE returned #NUM! for
matrices that have an inverse. A singular matrix is now one whose elimination meets a pivot that is
exactly 0, and the list of differences no longer claims an exact elimination.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and ruRU

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st MUNIT among such functions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

marcin-kordas-hoc and others added 3 commits October 23, 2026 08:27
…sion at parse time

A dimension above the row or column limit of a sheet is now #VALUE!, like SEQUENCE, instead of
allocating the matrix (a dimension of 20000 crashed the process). A dimension written as +3 or -3
is read when the result size is predicted, so =MUNIT(+3) spills a 3x3 matrix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n MDETERM and MINVERSE

Excel returns a 3x3 matrix for MUNIT("3") and #VALUE! for a matrix that holds both text and an
error, whichever comes first. MUNIT now takes its dimension as a number, and the result size of a
numeric text literal is known at parse time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Excel lifts MUNIT over a range and returns one result per cell; HyperFormula returns #VALUE!.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

…n does

The predicted result size read a text literal with JavaScript number parsing, which ignores the
configured decimal separator and accepts forms the engine rejects, so the prediction and the
evaluated size could disagree. It now uses the same coercion as a NUMBER argument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment on lines +391 to +420
public mdeterm(ast: ProcedureAst, state: InterpreterState): InterpreterValue {
return this.runFunction(ast.args, state, this.metadata('MDETERM'), (matrix: SimpleRangeValue) => {
const numbers = numbersOrError(matrix)
if (numbers instanceof CellError) {
return numbers
}
if (matrix.width() !== matrix.height()) {
return new CellError(ErrorType.VALUE, ErrorMessage.ArrayDimensions)
}
const result = determinant(numbers)
return Number.isFinite(result) ? result : new CellError(ErrorType.NUM, ErrorMessage.NaN)
})
}

/**
* Corresponds to MINVERSE(Array).
*
* Returns the inverse of a square matrix of numbers. A non-square matrix or a non-numeric value returns
* the #VALUE! error. A matrix whose elimination meets a pivot that is exactly 0, or whose inverse has an element
* too large for a number, returns the #NUM! error.
*/
public minverse(ast: ProcedureAst, state: InterpreterState): InterpreterValue {
return this.runFunction(ast.args, state, this.metadata('MINVERSE'), (matrix: SimpleRangeValue) => {
const numbers = numbersOrError(matrix)
if (numbers instanceof CellError) {
return numbers
}
if (matrix.width() !== matrix.height()) {
return new CellError(ErrorType.VALUE, ErrorMessage.ArrayDimensions)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the matrix is not square, MDETERM and MINVERSE return #VALUE! with "Array dimensions are not compatible.", which is MMULT's message for two arrays that can't be multiplied. These functions take a single argument, so that message doesn't tell the user what to fix.

const hf = HyperFormula.buildFromArray([
  [1, 2, 3],
  [4, 5, 6],
  ['=MDETERM(A1:C2)', '=MINVERSE(A1:C2)'],
], { licenseKey: 'gpl-v3' })
hf.getCellValue({ sheet: 0, row: 2, col: 0 }).message
// 'Array dimensions are not compatible.' (same for MINVERSE)

Could we add a message in error-message.ts, next to SingularMatrix, and use it at lines 398 and 419?

public static SquareMatrix = 'Matrix must be square.'

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 443deef: MDETERM and MINVERSE return the new ErrorMessage.SquareMatrix ("Matrix must be square.") for a non-square matrix, so your 2x3 example shows it in both cells. The tests are in handsontable/hyperformula-tests@2e14cb3f5daf51467e030cbcf3ff626111cb5047.

Comment on lines +181 to +195
/**
* Reads a number written directly in a formula, with an optional sign, such as `3`, `+3` or `-3`.
*
* @param node - an argument of a function call
* @returns the number, or undefined when the argument is anything else and its value is known only after evaluation
*/
function numberLiteral(node: Ast): Maybe<number> {
if (node.type === AstNodeType.NUMBER) {
return node.value
}
if ((node.type === AstNodeType.PLUS_UNARY_OP || node.type === AstNodeType.MINUS_UNARY_OP) && node.value.type === AstNodeType.NUMBER) {
return node.type === AstNodeType.MINUS_UNARY_OP ? -node.value.value : node.value.value
}
return undefined
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: numberLiteral duplicates the signed-literal handling in SequencePlugin.parseLiteralDimension. Both read a parse-time dimension from a NUMBER node, or a PLUS_UNARY_OP/MINUS_UNARY_OP wrapped around one, using separate code. The two copies have already drifted: SEQUENCE also accepts TRUE()/FALSE() and reads text with Number(), while MUNIT coerces text the way evaluation does. A future fix to one copy could easily miss the other.

Could we move a single literal-dimension reader into FunctionPlugin, next to coerceScalarToNumberOrError, and call it from both size methods? Bringing SEQUENCE's text handling in line can be a separate follow-up.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f638d2c: FunctionPlugin.signedNumberLiteral reads a NUMBER node, bare or under a unary + or -, and both munitArraySize and SequencePlugin.parseLiteralDimension call it. Text handling stays per caller and SEQUENCE keeps TRUE()/FALSE(), for the follow-up you suggested.

# Conflicts:
#	CHANGELOG.md
#	hyperformula/src/license/functionCapabilities.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a4f00d6. Configure here.

}
if (matrix.width() !== matrix.height()) {
return new CellError(ErrorType.VALUE, ErrorMessage.SquareMatrix)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-square error matrix skips #VALUE!

Medium Severity

When a non-square matrix also holds a cell error, MDETERM and MINVERSE propagate that error instead of #VALUE!. numbersOrError returns the first inner error before the square-shape check, so ErrorMessage.SquareMatrix never applies in that case.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a4f00d6. Configure here.

@codecov

codecov Bot commented Nov 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.38%. Comparing base (e6150c3) to head (e6578aa).

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop    #1817      +/-   ##
===========================================
+ Coverage    97.23%   97.38%   +0.15%     
===========================================
  Files          209      209              
  Lines        16524    16642     +118     
  Branches      3636     3666      +30     
===========================================
+ Hits         16067    16207     +140     
+ Misses         445      427      -18     
+ Partials        12        8       -4     
Files with missing lines Coverage Δ
hyperformula/src/error-message.ts 100.00% <100.00%> (ø)
hyperformula/src/i18n/languages/csCZ.ts 100.00% <ø> (ø)
hyperformula/src/i18n/languages/daDK.ts 100.00% <ø> (ø)
hyperformula/src/i18n/languages/deDE.ts 100.00% <ø> (ø)
hyperformula/src/i18n/languages/enGB.ts 100.00% <ø> (ø)
hyperformula/src/i18n/languages/esES.ts 100.00% <ø> (ø)
hyperformula/src/i18n/languages/fiFI.ts 100.00% <ø> (ø)
hyperformula/src/i18n/languages/frFR.ts 100.00% <ø> (ø)
hyperformula/src/i18n/languages/huHU.ts 100.00% <ø> (ø)
hyperformula/src/i18n/languages/idID.ts 100.00% <ø> (ø)
... and 13 more

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants