From 7b38a5f347b8ab042c24a6632ac9fb2cb0621256 Mon Sep 17 00:00:00 2001 From: Sarthak Tayal Date: Wed, 5 Aug 2026 17:01:22 -0400 Subject: [PATCH 1/6] fix: reject matrices with fewer rows than columns in the LU and QR decompositions --- src/__tests__/decompositions/lu.test.js | 9 +++++++++ src/dc/lu.js | 3 +++ src/dc/qr.js | 3 +++ 3 files changed, 15 insertions(+) diff --git a/src/__tests__/decompositions/lu.test.js b/src/__tests__/decompositions/lu.test.js index 39b22986..ba525ec1 100644 --- a/src/__tests__/decompositions/lu.test.js +++ b/src/__tests__/decompositions/lu.test.js @@ -45,6 +45,15 @@ describe('LU decomposition', () => { new LU([ [0, 1, 2], [0, 1, 2], + ]), + ).toThrow('Matrix must have at least as many rows as columns'); + + expect( + () => + new LU([ + [0, 1], + [0, 1], + [0, 1], ]).determinant, ).toThrow('Matrix must be square'); }); diff --git a/src/dc/lu.js b/src/dc/lu.js index 68b12b05..227cadae 100644 --- a/src/dc/lu.js +++ b/src/dc/lu.js @@ -4,6 +4,9 @@ import WrapperMatrix2D from '../wrap/WrapperMatrix2D'; export default class LuDecomposition { constructor(matrix) { matrix = WrapperMatrix2D.checkMatrix(matrix); + if (matrix.rows < matrix.columns) { + throw new RangeError('Matrix must have at least as many rows as columns'); + } let lu = matrix.clone(); let rows = lu.rows; diff --git a/src/dc/qr.js b/src/dc/qr.js index ffce7825..f3d74254 100644 --- a/src/dc/qr.js +++ b/src/dc/qr.js @@ -6,6 +6,9 @@ import { hypotenuse } from './util'; export default class QrDecomposition { constructor(value) { value = WrapperMatrix2D.checkMatrix(value); + if (value.rows < value.columns) { + throw new RangeError('Matrix must have at least as many rows as columns'); + } let qr = value.clone(); let m = value.rows; From 377a69df9dc9e9384bb3729a9968d949beaad104 Mon Sep 17 00:00:00 2001 From: Sarthak Tayal Date: Wed, 5 Aug 2026 17:01:53 -0400 Subject: [PATCH 2/6] --- src/__tests__/decompositions/shape.test.js | 105 +++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/__tests__/decompositions/shape.test.js diff --git a/src/__tests__/decompositions/shape.test.js b/src/__tests__/decompositions/shape.test.js new file mode 100644 index 00000000..9f5895a7 --- /dev/null +++ b/src/__tests__/decompositions/shape.test.js @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; + +import { + Matrix, + LuDecomposition, + QrDecomposition, + SingularValueDecomposition, + solve, +} from '../..'; + +const message = /^Matrix must have at least as many rows as columns$/; + +describe('LU and QR need at least as many rows as columns', () => { + const wide = new Matrix([ + [1, 2, 3], + [4, 5, 6], + ]); + + it('LU rejects a wide matrix', () => { + expect(() => new LuDecomposition(wide)).toThrow(message); + }); + + it('QR rejects a wide matrix', () => { + expect(() => new QrDecomposition(wide)).toThrow(message); + }); + + it('LU rejects a wide 2D array', () => { + expect(() => new LuDecomposition([[1, 2, 3, 4]])).toThrow(message); + }); + + it('QR rejects a wide 2D array', () => { + expect(() => new QrDecomposition([[1, 2, 3, 4]])).toThrow(message); + }); + + it('solve reports the shape rather than a rank problem', () => { + expect(() => solve(wide, Matrix.columnVector([1, 2]))).toThrow(message); + }); +}); + +describe('LU and QR keep working on the supported shapes', () => { + const tall = new Matrix([ + [1, 2], + [3, 4], + [5, 6], + ]); + const square = new Matrix([ + [4, 3], + [6, 3], + ]); + + it('QR on a tall matrix rebuilds the input', () => { + const qr = new QrDecomposition(tall); + const product = qr.orthogonalMatrix.mmul(qr.upperTriangularMatrix); + for (let i = 0; i < tall.rows; i++) { + for (let j = 0; j < tall.columns; j++) { + expect(product.get(i, j)).toBeCloseTo(tall.get(i, j), 10); + } + } + }); + + it('LU on a tall matrix reports a usable triangular pair', () => { + const lu = new LuDecomposition(tall); + expect(lu.lowerTriangularMatrix.rows).toBe(3); + expect(lu.upperTriangularMatrix.columns).toBe(2); + }); + + it('LU on a square matrix stays solvable', () => { + const lu = new LuDecomposition(square); + expect(lu.isSingular()).toBe(false); + const x = lu.solve(Matrix.columnVector([10, 12])); + const back = square.mmul(x); + expect(back.get(0, 0)).toBeCloseTo(10, 10); + expect(back.get(1, 0)).toBeCloseTo(12, 10); + }); + + it('an empty matrix is still accepted', () => { + expect(() => new LuDecomposition(new Matrix(0, 0))).not.toThrow(); + expect(() => new QrDecomposition(new Matrix(0, 0))).not.toThrow(); + }); +}); + +describe('SVD covers the wide case through autoTranspose', () => { + const wide = new Matrix([ + [1, 2, 3], + [4, 5, 6], + ]); + + it('rebuilds a wide matrix with autoTranspose', () => { + const svd = new SingularValueDecomposition(wide, { autoTranspose: true }); + const product = svd.leftSingularVectors + .mmul(Matrix.diag(svd.diagonal)) + .mmul(svd.rightSingularVectors.transpose()); + for (let i = 0; i < wide.rows; i++) { + for (let j = 0; j < wide.columns; j++) { + expect(product.get(i, j)).toBeCloseTo(wide.get(i, j), 10); + } + } + }); + + it('reports one singular value per row of a wide matrix', () => { + const svd = new SingularValueDecomposition(wide, { autoTranspose: true }); + expect(svd.diagonal).toHaveLength(2); + expect(svd.rank).toBe(2); + }); +}); From f150452ee165d89ef9f60f708e3994b8cb811419 Mon Sep 17 00:00:00 2001 From: Sarthak Tayal Date: Wed, 5 Aug 2026 17:02:10 -0400 Subject: [PATCH 3/6] --- matrix.d.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/matrix.d.ts b/matrix.d.ts index c6f61535..ddccbb0a 100644 --- a/matrix.d.ts +++ b/matrix.d.ts @@ -1570,6 +1570,9 @@ export class CholeskyDecomposition { export { CholeskyDecomposition as CHO }; /** + * The LU decomposition of a matrix with at least as many rows as columns. + * A matrix carrying more columns than rows is rejected with a `RangeError`. + * * @link https://github.com/lutzroeder/Mapack/blob/master/Source/LuDecomposition.cs */ export class LuDecomposition { @@ -1585,6 +1588,9 @@ export class LuDecomposition { export { LuDecomposition as LU }; /** + * The QR decomposition of a matrix with at least as many rows as columns. + * A matrix carrying more columns than rows is rejected with a `RangeError`. + * * @link https://github.com/lutzroeder/Mapack/blob/master/Source/QrDecomposition.cs */ export class QrDecomposition { From ec3ab67f06b2b5841791b7d0907f9c73d3c26a12 Mon Sep 17 00:00:00 2001 From: Sarthak Tayal Date: Wed, 5 Aug 2026 17:02:31 -0400 Subject: [PATCH 4/6] --- matrix.d.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/matrix.d.ts b/matrix.d.ts index ddccbb0a..d9cb7e6e 100644 --- a/matrix.d.ts +++ b/matrix.d.ts @@ -1475,12 +1475,21 @@ export interface ISVDOptions { computeRightSingularVectors?: boolean; /** + * Decompose the transpose when the matrix carries more columns than rows, then swap the + * singular vectors back. Leaving this off on such a matrix logs a warning and yields a + * decomposition padded to the number of columns. * @default `false` */ autoTranspose?: boolean; } /** + * The economy singular value decomposition of a matrix. + * For a matrix of shape m by n with m at least n, the left singular vectors are m by n, + * the right singular vectors are n by n, and n singular values are reported. The null + * space of a wide matrix is therefore not spanned by the returned vectors. + * Set `autoTranspose` to decompose a matrix carrying more columns than rows. + * * @see https://github.com/accord-net/framework/blob/development/Sources/Accord.Math/Decompositions/SingularValueDecomposition.cs */ export class SingularValueDecomposition { From d25e5901953e10a0a968a023e33b85999a0d77b3 Mon Sep 17 00:00:00 2001 From: Sarthak Tayal Date: Wed, 5 Aug 2026 17:02:52 -0400 Subject: [PATCH 5/6] docs: document the shape a decomposition accepts --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 0af64f0e..d4f404e3 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,10 @@ var error = Matrix.sub(B, A.mmul(x)); // The error enables to evaluate the solut ``` #### Decompositions +`QrDecomposition` along with `LuDecomposition` need a matrix with at least as many rows as columns. A matrix carrying more columns than rows is rejected with a `RangeError`. + +`SingularValueDecomposition` is an economy decomposition, meaning a matrix of shape m by n with m at least n gives left singular vectors of shape m by n, right singular vectors of shape n by n, and n singular values. The vectors it returns therefore do not span the null space of a wide matrix. Pass `autoTranspose` to decompose a matrix carrying more columns than rows. + ##### QR Decomposition ```js var A = new Matrix([ From c43f2dfb0e855c23d4117b537580bd79b93fef45 Mon Sep 17 00:00:00 2001 From: Sarthak Tayal Date: Thu, 6 Aug 2026 16:40:13 -0400 Subject: [PATCH 6/6] docs: narrow wide decomposition shape guarantees --- README.md | 2 -- matrix.d.ts | 9 ------ src/__tests__/decompositions/shape.test.js | 33 +--------------------- 3 files changed, 1 insertion(+), 43 deletions(-) diff --git a/README.md b/README.md index d4f404e3..662a5eb5 100644 --- a/README.md +++ b/README.md @@ -236,8 +236,6 @@ var error = Matrix.sub(B, A.mmul(x)); // The error enables to evaluate the solut `QrDecomposition` along with `LuDecomposition` need a matrix with at least as many rows as columns. A matrix carrying more columns than rows is rejected with a `RangeError`. -`SingularValueDecomposition` is an economy decomposition, meaning a matrix of shape m by n with m at least n gives left singular vectors of shape m by n, right singular vectors of shape n by n, and n singular values. The vectors it returns therefore do not span the null space of a wide matrix. Pass `autoTranspose` to decompose a matrix carrying more columns than rows. - ##### QR Decomposition ```js var A = new Matrix([ diff --git a/matrix.d.ts b/matrix.d.ts index d9cb7e6e..ddccbb0a 100644 --- a/matrix.d.ts +++ b/matrix.d.ts @@ -1475,21 +1475,12 @@ export interface ISVDOptions { computeRightSingularVectors?: boolean; /** - * Decompose the transpose when the matrix carries more columns than rows, then swap the - * singular vectors back. Leaving this off on such a matrix logs a warning and yields a - * decomposition padded to the number of columns. * @default `false` */ autoTranspose?: boolean; } /** - * The economy singular value decomposition of a matrix. - * For a matrix of shape m by n with m at least n, the left singular vectors are m by n, - * the right singular vectors are n by n, and n singular values are reported. The null - * space of a wide matrix is therefore not spanned by the returned vectors. - * Set `autoTranspose` to decompose a matrix carrying more columns than rows. - * * @see https://github.com/accord-net/framework/blob/development/Sources/Accord.Math/Decompositions/SingularValueDecomposition.cs */ export class SingularValueDecomposition { diff --git a/src/__tests__/decompositions/shape.test.js b/src/__tests__/decompositions/shape.test.js index 9f5895a7..1b98ee26 100644 --- a/src/__tests__/decompositions/shape.test.js +++ b/src/__tests__/decompositions/shape.test.js @@ -1,12 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { - Matrix, - LuDecomposition, - QrDecomposition, - SingularValueDecomposition, - solve, -} from '../..'; +import { Matrix, LuDecomposition, QrDecomposition, solve } from '../..'; const message = /^Matrix must have at least as many rows as columns$/; @@ -78,28 +72,3 @@ describe('LU and QR keep working on the supported shapes', () => { expect(() => new QrDecomposition(new Matrix(0, 0))).not.toThrow(); }); }); - -describe('SVD covers the wide case through autoTranspose', () => { - const wide = new Matrix([ - [1, 2, 3], - [4, 5, 6], - ]); - - it('rebuilds a wide matrix with autoTranspose', () => { - const svd = new SingularValueDecomposition(wide, { autoTranspose: true }); - const product = svd.leftSingularVectors - .mmul(Matrix.diag(svd.diagonal)) - .mmul(svd.rightSingularVectors.transpose()); - for (let i = 0; i < wide.rows; i++) { - for (let j = 0; j < wide.columns; j++) { - expect(product.get(i, j)).toBeCloseTo(wide.get(i, j), 10); - } - } - }); - - it('reports one singular value per row of a wide matrix', () => { - const svd = new SingularValueDecomposition(wide, { autoTranspose: true }); - expect(svd.diagonal).toHaveLength(2); - expect(svd.rank).toBe(2); - }); -});