-
Notifications
You must be signed in to change notification settings - Fork 56
Description
The ml-matrix SVD implementation appears to be broken for wide matrices (more columns than rows).
Evidence:
- 2×4 rank-1 matrix: Theory requires 3 null vectors, ml-matrix provides only 1
- 2×5 rank-2 matrix: Theory requires 3 null vectors, ml-matrix provides only 2
- 1×4 rank-1 matrix: Theory requires 3 null vectors, ml-matrix provides only 2
The Bug:
ml-matrix's SVD violates the fundamental rank-nullity theorem (rank + nullity = n) for wide matrices, providing incomplete null space bases.
What Works vs. What's Broken:
✅ Square/tall matrices (rows ≥ columns): Perfect SVD implementation
❌ Wide matrices (rows < columns): Incomplete/incorrect null space computation
Impact:
This bug would cause constraint solving failures in geometric construction problems that have more variables than constraints (common in underdetermined systems).
Tests Fixed:
The original failing tests were actually revealing this underlying SVD bug. I've documented the complete ml-matrix SVD convention analysis in /test/ml-matrix-svd-convention.test.ts
, which systematically demonstrates the limitation.
The autoTranspose option does NOT fix the ml-matrix SVD limitation - it actually makes it significantly worse!
autoTranspose Test Results: WORSE Performance
Key Findings:
- V matrix gets truncated: From n×n to n×min(m,n), losing critical null space information
- Null space becomes even more incomplete:
- 2×4 rank-1: Still only 1 vector found (need 3)
- 2×3 rank-1: Now only 1 vector found (need 2) - got worse!
- 1×4 rank-1: Now 0 vectors found (need 3) - completely broken!
Conclusion:
autoTranspose is not the solution to ml-matrix's incomplete null space problem. The fundamental issue remains: ml-matrix cannot provide complete null space bases for wide matrices, regardless of settings.
The comprehensive testing has confirmed that ml-matrix's SVD has inherent limitations that cannot be resolved through configuration options. The original failing tests correctly identified a real mathematical deficiency in the library.
import { Matrix } from 'ml-matrix';
// =============================================================================
// MATRIX DECOMPOSITION CONTRACTS
// =============================================================================
/**
* SVD decomposition result
*/
export interface SVDResult {
readonly U: Matrix;
readonly S: readonly number[];
readonly V: Matrix;
readonly rank: number;
readonly condition: number;
/** Access to original ml-matrix object for advanced operations */
readonly raw: any; // SingularValueDecomposition
}
/**
* LU decomposition result
*/
export interface LUResult {
readonly L: Matrix;
readonly U: Matrix;
readonly p: readonly number[]; // Permutation vector
/** Access to original ml-matrix object */
readonly raw: any; // LuDecomposition
}
/**
* QR decomposition result
*/
export interface QRResult {
readonly Q: Matrix;
readonly R: Matrix;
/** Access to original ml-matrix object */
readonly raw: any; // QrDecomposition
}
/**
* Cholesky decomposition result
*/
export interface CholeskyResult {
readonly L: Matrix;
/** Access to original ml-matrix object */
readonly raw: any; // CholeskyDecomposition
}
/**
* @fileoverview ml-matrix SVD Convention Analysis
*
* This test suite systematically determines the exact convention
* used by ml-matrix for SVD decomposition, specifically:
* 1. How V columns relate to singular values
* 2. Whether it's A = UΣV^T or A = UΣV
* 3. The ordering of singular values and corresponding vectors
*/
import { Matrix } from 'ml-matrix';
import { svd } from '../src/math/linear/core/matrixOps';
describe('ml-matrix SVD Convention Analysis', () => {
test('Convention Test 1: Simple rank-1 matrix [1,2; 2,4]', () => {
const A = new Matrix([
[1, 2],
[2, 4]
]);
console.log('\n=== CONVENTION TEST 1: Rank-1 2x2 ===');
console.log('A =', A.to2DArray());
const svdResult = svd(A);
const { U, S, V } = svdResult;
console.log(`SVD: U=${U.rows}×${U.columns}, S=[${S.map(s => s.toFixed(6)).join(', ')}], V=${V.rows}×${V.columns}`);
console.log('U =', U.to2DArray().map(row => row.map(x => x.toFixed(6))));
console.log('V =', V.to2DArray().map(row => row.map(x => x.toFixed(6))));
// Test reconstruction: A = U*S*V^T
const SMatrix = Matrix.zeros(U.columns, V.columns);
for (let i = 0; i < Math.min(S.length, Math.min(SMatrix.rows, SMatrix.columns)); i++) {
const singularValue = S[i];
if (singularValue !== undefined) {
SMatrix.set(i, i, singularValue);
}
}
const reconstructed = U.mmul(SMatrix).mmul(V.transpose());
console.log('Reconstructed A (U*S*V^T) =', reconstructed.to2DArray().map(row => row.map(x => x.toFixed(6))));
// Test each V column with Ax = 0
console.log('\nNull space analysis:');
for (let col = 0; col < V.columns; col++) {
const v = Matrix.zeros(V.rows, 1);
for (let row = 0; row < V.rows; row++) {
v.set(row, 0, V.get(row, col));
}
const Av = A.mmul(v);
const norm = Av.norm('frobenius');
console.log(`V column ${col}: [${Array.from({length: v.rows}, (_, i) => v.get(i, 0).toFixed(6)).join(', ')}] → ||Av|| = ${norm.toExponential(3)}`);
}
// Expected: null space should be span{[-2, 1]^T} or equivalent
const expectedNull = new Matrix([[-2], [1]]);
const expectedNorm = A.mmul(expectedNull).norm('frobenius');
console.log(`Expected null vector [-2, 1]: ||A*v|| = ${expectedNorm.toExponential(3)}`);
});
test('Convention Test 2: Rank-1 matrix [1,2,3; 2,4,6]', () => {
const A = new Matrix([
[1, 2, 3],
[2, 4, 6]
]);
console.log('\n=== CONVENTION TEST 2: Rank-1 2x3 ===');
console.log('A =', A.to2DArray());
const svdResult = svd(A);
const { U, S, V } = svdResult;
console.log(`SVD: U=${U.rows}×${U.columns}, S=[${S.map(s => s.toFixed(6)).join(', ')}], V=${V.rows}×${V.columns}`);
console.log('rank =', svdResult.rank, 'expected nullity =', A.columns - svdResult.rank);
// Map singular values to their indices
const singularValueMap = S.map((s, i) => ({ value: s, index: i, isZero: s <= svdResult.raw.threshold }));
console.log('Singular value mapping:');
singularValueMap.forEach(({value, index, isZero}) => {
console.log(` S[${index}] = ${value.toExponential(6)} ${isZero ? '(ZERO)' : '(NON-ZERO)'}`);
});
console.log('\nTesting V columns:');
for (let col = 0; col < V.columns; col++) {
const v = Matrix.zeros(V.rows, 1);
for (let row = 0; row < V.rows; row++) {
v.set(row, 0, V.get(row, col));
}
const Av = A.mmul(v);
const norm = Av.norm('frobenius');
const isNull = norm < 1e-10;
console.log(` V[:, ${col}] = [${Array.from({length: v.rows}, (_, i) => v.get(i, 0).toFixed(6)).join(', ')}]`);
console.log(` ||A * V[:, ${col}]|| = ${norm.toExponential(3)} ${isNull ? '✓ NULL' : '✗ not null'}`);
}
// Known null vectors for [1,2,3; 2,4,6]: span{[-2,1,0], [-3,0,1]}
const knownNull1 = new Matrix([[-2], [1], [0]]);
const knownNull2 = new Matrix([[-3], [0], [1]]);
console.log('\nKnown null space vectors:');
console.log(`[-2,1,0]: ||A*v|| = ${A.mmul(knownNull1).norm('frobenius').toExponential(3)}`);
console.log(`[-3,0,1]: ||A*v|| = ${A.mmul(knownNull2).norm('frobenius').toExponential(3)}`);
});
test('Convention Test 3: Identity matrix (full rank)', () => {
const A = Matrix.eye(3);
console.log('\n=== CONVENTION TEST 3: Identity 3x3 ===');
const svdResult = svd(A);
const { U, S, V } = svdResult;
console.log(`SVD: U=${U.rows}×${U.columns}, S=[${S.map(s => s.toFixed(6)).join(', ')}], V=${V.rows}×${V.columns}`);
console.log('rank =', svdResult.rank, 'expected nullity =', A.columns - svdResult.rank);
console.log('\nTesting V columns (should all be non-null):');
for (let col = 0; col < V.columns; col++) {
const v = Matrix.zeros(V.rows, 1);
for (let row = 0; row < V.rows; row++) {
v.set(row, 0, V.get(row, col));
}
const Av = A.mmul(v);
const norm = Av.norm('frobenius');
console.log(` ||A * V[:, ${col}]|| = ${norm.toExponential(3)}`);
}
});
test('Convention Test 4: Zero matrix (rank 0)', () => {
const A = Matrix.zeros(2, 3);
console.log('\n=== CONVENTION TEST 4: Zero 2x3 ===');
const svdResult = svd(A);
const { U, S, V } = svdResult;
console.log(`SVD: U=${U.rows}×${U.columns}, S=[${S.map(s => s.toFixed(6)).join(', ')}], V=${V.rows}×${V.columns}`);
console.log('rank =', svdResult.rank, 'expected nullity =', A.columns - svdResult.rank);
console.log('\nTesting V columns (should all be null):');
for (let col = 0; col < V.columns; col++) {
const v = Matrix.zeros(V.rows, 1);
for (let row = 0; row < V.rows; row++) {
v.set(row, 0, V.get(row, col));
}
const Av = A.mmul(v);
const norm = Av.norm('frobenius');
console.log(` ||A * V[:, ${col}]|| = ${norm.toExponential(3)}`);
}
});
test('Convention Test 5: Systematic mapping analysis', () => {
const A = new Matrix([
[1, 2, 3, 4],
[2, 4, 6, 8]
]);
console.log('\n=== CONVENTION TEST 5: Systematic Analysis 2x4 ===');
console.log('A =', A.to2DArray());
const svdResult = svd(A);
const { U, S, V } = svdResult;
console.log(`\nDetailed analysis:`);
console.log(`U = ${U.rows}×${U.columns}`);
console.log(`S = [${S.map(s => s.toExponential(6)).join(', ')}]`);
console.log(`V = ${V.rows}×${V.columns}`);
console.log(`rank = ${svdResult.rank}`);
console.log(`threshold = ${svdResult.raw.threshold.toExponential(6)}`);
console.log('\n🔍 HYPOTHESIS TESTING:');
// Hypothesis 1: V columns are ordered by corresponding singular values
console.log('\nHypothesis 1: V[:, i] corresponds to S[i]');
for (let i = 0; i < Math.min(S.length, V.columns); i++) {
const singularValue = S[i];
if (singularValue === undefined) continue;
const isZeroSingular = singularValue <= svdResult.raw.threshold;
const v = Matrix.zeros(V.rows, 1);
for (let row = 0; row < V.rows; row++) {
v.set(row, 0, V.get(row, i));
}
const Av = A.mmul(v);
const norm = Av.norm('frobenius');
const isNullEmpirical = norm < 1e-10;
console.log(` S[${i}] = ${singularValue.toExponential(3)} ${isZeroSingular ? '(zero)' : '(non-zero)'} ↔ V[:, ${i}] gives ||Av|| = ${norm.toExponential(3)} ${isNullEmpirical ? '(null)' : '(non-null)'}`);
console.log(` Match: ${isZeroSingular === isNullEmpirical ? '✓' : '✗'}`);
}
// Hypothesis 2: V columns from the end correspond to zero singular values
console.log('\nHypothesis 2: Last V columns correspond to null space');
const numZeroSingulars = S.filter(s => s <= svdResult.raw.threshold).length;
const startCol = V.columns - numZeroSingulars;
for (let col = startCol; col < V.columns; col++) {
const v = Matrix.zeros(V.rows, 1);
for (let row = 0; row < V.rows; row++) {
v.set(row, 0, V.get(row, col));
}
const Av = A.mmul(v);
const norm = Av.norm('frobenius');
console.log(` V[:, ${col}] (expecting null): ||Av|| = ${norm.toExponential(3)} ${norm < 1e-10 ? '✓' : '✗'}`);
}
// Hypothesis 3: Check if it's actually V^T we should be using
console.log('\nHypothesis 3: Check V^T columns instead');
const VT = V.transpose();
for (let col = 0; col < VT.columns; col++) {
const v = Matrix.zeros(VT.rows, 1);
for (let row = 0; row < VT.rows; row++) {
v.set(row, 0, VT.get(row, col));
}
const Av = A.mmul(v);
const norm = Av.norm('frobenius');
console.log(` V^T[:, ${col}]: ||Av|| = ${norm.toExponential(3)} ${norm < 1e-10 ? '(null)' : '(non-null)'}`);
}
});
describe('Wide Matrix V-S Correspondence Analysis', () => {
test('Wide Test 1: Single row matrices [1×n]', () => {
console.log('\n=== WIDE MATRIX ANALYSIS: Single Row ===');
const testCases = [
{ matrix: [[1, 2]], name: '1×2' },
{ matrix: [[1, 2, 3]], name: '1×3' },
{ matrix: [[1, 2, 3, 4]], name: '1×4' },
{ matrix: [[1, 2, 3, 4, 5]], name: '1×5' }
];
testCases.forEach(({matrix, name}) => {
const A = new Matrix(matrix);
const svdResult = svd(A);
const { U, S, V, rank } = svdResult;
console.log(`\n${name} matrix: A = ${JSON.stringify(matrix[0])}`);
console.log(`SVD: U=${U.rows}×${U.columns}, S=[${S.map(s => s.toFixed(3)).join(', ')}], V=${V.rows}×${V.columns}, rank=${rank}`);
// Systematic V-S correspondence check
console.log('V-S Correspondence Analysis:');
for (let i = 0; i < Math.min(S.length, V.columns); i++) {
const singularValue = S[i];
if (singularValue === undefined) continue;
// Extract V column i
const v = Matrix.zeros(V.rows, 1);
for (let row = 0; row < V.rows; row++) {
v.set(row, 0, V.get(row, i));
}
const Av = A.mmul(v);
const norm = Av.norm('frobenius');
console.log(` S[${i}] = ${singularValue.toFixed(6)} vs ||A*V[:, ${i}]|| = ${norm.toFixed(6)} ratio=${(norm/singularValue).toFixed(3)}`);
}
// Check remaining V columns (beyond S.length)
if (V.columns > S.length) {
console.log('Extra V columns (beyond S.length):');
for (let i = S.length; i < V.columns; i++) {
const v = Matrix.zeros(V.rows, 1);
for (let row = 0; row < V.rows; row++) {
v.set(row, 0, V.get(row, i));
}
const Av = A.mmul(v);
const norm = Av.norm('frobenius');
console.log(` V[:, ${i}]: ||A*v|| = ${norm.toExponential(3)} ${norm < 1e-10 ? '(NULL)' : '(non-null)'}`);
}
}
});
});
test('Wide Test 2: Two row matrices [2×n] with varying ranks', () => {
console.log('\n=== WIDE MATRIX ANALYSIS: Two Rows, Varying Ranks ===');
const testCases = [
{ matrix: [[0, 0, 0]], name: '2×3 rank-0 (zero)', rank: 0 },
{ matrix: [[1, 2, 3], [0, 0, 0]], name: '2×3 rank-1', rank: 1 },
{ matrix: [[1, 2, 3], [4, 5, 6]], name: '2×3 rank-2', rank: 2 },
{ matrix: [[1, 2, 3, 4], [0, 0, 0, 0]], name: '2×4 rank-1', rank: 1 },
{ matrix: [[1, 2, 3, 4], [5, 6, 7, 8]], name: '2×4 rank-2', rank: 2 },
{ matrix: [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]], name: '2×5 rank-2', rank: 2 }
];
testCases.forEach(({matrix, name, rank: expectedRank}) => {
// Handle zero matrix case
const firstRow = matrix[0];
if (!firstRow) throw new Error(`Invalid matrix: ${name}`);
const A = firstRow.every(x => x === 0) ? Matrix.zeros(2, firstRow.length) : new Matrix(matrix);
const svdResult = svd(A);
const { U, S, V, rank } = svdResult;
console.log(`\n${name}: rank=${rank} (expected ${expectedRank}), nullity=${A.columns - rank}`);
console.log(`SVD shapes: U=${U.rows}×${U.columns}, S.length=${S.length}, V=${V.rows}×${V.columns}`);
// Detailed V-S analysis
console.log('Detailed V-S correspondence:');
for (let i = 0; i < V.columns; i++) {
const v = Matrix.zeros(V.rows, 1);
for (let row = 0; row < V.rows; row++) {
v.set(row, 0, V.get(row, i));
}
const Av = A.mmul(v);
const norm = Av.norm('frobenius');
// Check if this column corresponds to a singular value
let correspondingSValue = 'none';
if (i < S.length) {
const sVal = S[i];
if (sVal !== undefined) {
correspondingSValue = sVal.toFixed(6);
}
}
console.log(` V[:, ${i}] → ||Av|| = ${norm.toExponential(3)}, S[${i}] = ${correspondingSValue}`);
// Check if it's actually null space
if (norm < 1e-10) {
console.log(` ↳ NULL SPACE vector found at position ${i}`);
}
}
// Summary of null space pattern
const nullColumns = [];
for (let i = 0; i < V.columns; i++) {
const v = Matrix.zeros(V.rows, 1);
for (let row = 0; row < V.rows; row++) {
v.set(row, 0, V.get(row, i));
}
const norm = A.mmul(v).norm('frobenius');
if (norm < 1e-10) {
nullColumns.push(i);
}
}
console.log(`NULL SPACE PATTERN: columns [${nullColumns.join(', ')}] out of ${V.columns} total`);
console.log(`Expected nullity: ${A.columns - rank}, Found: ${nullColumns.length}`);
});
});
test('Wide Test 3: Systematic pattern detection', () => {
console.log('\n=== PATTERN DETECTION: Wide Matrix Null Space ===');
// Test hypothesis: "Last k columns are null space where k = nullity"
const testMatrices = [
{ A: new Matrix([[1, 2, 3]]), name: '1×3, rank=1' },
{ A: new Matrix([[1, 2, 3, 4]]), name: '1×4, rank=1' },
{ A: new Matrix([[1, 2, 3], [2, 4, 6]]), name: '2×3, rank=1' },
{ A: new Matrix([[1, 2, 3, 4], [2, 4, 6, 8]]), name: '2×4, rank=1' },
{ A: new Matrix([[1, 2, 3], [4, 5, 6]]), name: '2×3, rank=2' },
{ A: new Matrix([[1, 2, 3, 4], [5, 6, 7, 8]]), name: '2×4, rank=2' },
];
testMatrices.forEach(({A, name}) => {
const svdResult = svd(A);
const { V, rank } = svdResult;
const nullity = A.columns - rank;
console.log(`\n${name}: nullity=${nullity}`);
// Test "last k columns" hypothesis
const expectedNullColumns = [];
for (let i = A.columns - nullity; i < A.columns; i++) {
expectedNullColumns.push(i);
}
const actualNullColumns = [];
for (let col = 0; col < V.columns; col++) {
const v = Matrix.zeros(V.rows, 1);
for (let row = 0; row < V.rows; row++) {
v.set(row, 0, V.get(row, col));
}
const norm = A.mmul(v).norm('frobenius');
if (norm < 1e-10) {
actualNullColumns.push(col);
}
}
console.log(`Expected null columns (last ${nullity}): [${expectedNullColumns.join(', ')}]`);
console.log(`Actual null columns: [${actualNullColumns.join(', ')}]`);
console.log(`Hypothesis "last k columns": ${JSON.stringify(expectedNullColumns) === JSON.stringify(actualNullColumns) ? '✅ CONFIRMED' : '❌ REJECTED'}`);
// Test other hypotheses
const firstKColumns = Array.from({length: nullity}, (_, i) => i);
console.log(`Hypothesis "first ${nullity} columns": ${JSON.stringify(firstKColumns) === JSON.stringify(actualNullColumns) ? '✅ CONFIRMED' : '❌ REJECTED'}`);
// Check if it's random/unpredictable
console.log(`Actual pattern: columns [${actualNullColumns.join(', ')}] are null space`);
});
});
test('Wide Test 4: V^T vs V analysis', () => {
console.log('\n=== V^T vs V ANALYSIS ===');
const A = new Matrix([[1, 2, 3, 4], [2, 4, 6, 8]]); // 2×4, rank=1
const svdResult = svd(A);
const { V } = svdResult;
const VT = V.transpose();
console.log('Testing if we should use V^T instead of V...');
console.log(`A: 2×4, V: ${V.rows}×${V.columns}, V^T: ${VT.rows}×${VT.columns}`);
console.log('\nV columns:');
for (let col = 0; col < V.columns; col++) {
const v = Matrix.zeros(V.rows, 1);
for (let row = 0; row < V.rows; row++) {
v.set(row, 0, V.get(row, col));
}
const norm = A.mmul(v).norm('frobenius');
console.log(` V[:, ${col}]: ||Av|| = ${norm.toExponential(3)} ${norm < 1e-10 ? '(null)' : ''}`);
}
console.log('\nV^T rows (as column vectors):');
for (let row = 0; row < VT.rows; row++) {
const v = Matrix.zeros(VT.columns, 1);
for (let col = 0; col < VT.columns; col++) {
v.set(col, 0, VT.get(row, col));
}
const norm = A.mmul(v).norm('frobenius');
console.log(` V^T[${row}, :] as column: ||Av|| = ${norm.toExponential(3)} ${norm < 1e-10 ? '(null)' : ''}`);
}
});
});
});
/**
* @fileoverview Matrix Operations Implementation
* @module math/linear/core/matrixOps
*
* This module implements low-level matrix operations using ml-matrix.
* It provides a clean functional API that wraps ml-matrix classes and
* standardizes the results.
*
* ARCHITECTURAL PRINCIPLE:
* This is the only module that directly imports and uses ml-matrix classes.
* All matrix operations are wrapped in our standardized interfaces and
* provide consistent error handling.
*/
import {
Matrix,
LuDecomposition,
QrDecomposition,
SingularValueDecomposition,
CholeskyDecomposition,
} from 'ml-matrix';
import type {
SVDResult,
LUResult,
QRResult,
CholeskyResult,
} from '../core/core.interface';
// =============================================================================
// DECOMPOSITION FUNCTIONS
// =============================================================================
/**
* Performs Singular Value Decomposition on a matrix.
* This is the primary diagnostic tool for determining rank, condition number, and more.
*/
export function svd(A: Matrix): SVDResult {
const svdInstance = new SingularValueDecomposition(A);
return {
U: svdInstance.leftSingularVectors,
S: svdInstance.diagonal,
V: svdInstance.rightSingularVectors,
rank: svdInstance.rank,
condition: svdInstance.condition,
raw: svdInstance,
};
}
/**
* Performs LU Decomposition with pivoting on a square matrix.
*/
export function lu(A: Matrix): LUResult {
const luInstance = new LuDecomposition(A);
return {
L: luInstance.lowerTriangularMatrix,
U: luInstance.upperTriangularMatrix,
p: luInstance.pivotPermutationVector,
raw: luInstance,
};
}
/**
* Performs QR Decomposition on a matrix.
*/
export function qr(A: Matrix): QRResult {
const qrInstance = new QrDecomposition(A);
return {
Q: qrInstance.orthogonalMatrix,
R: qrInstance.upperTriangularMatrix,
raw: qrInstance,
};
}
/**
* Performs Cholesky Decomposition on a symmetric, positive-definite matrix.
*/
export function cholesky(A: Matrix): CholeskyResult {
const choleskyInstance = new CholeskyDecomposition(A);
if (!choleskyInstance.isPositiveDefinite()) {
throw new Error('Cholesky decomposition failed: matrix is not positive-definite.');
}
return {
L: choleskyInstance.lowerTriangularMatrix,
raw: choleskyInstance,
};
}
// =============================================================================
// SOLVER FUNCTIONS
// =============================================================================
/**
* Solves Ax = b using LU decomposition.
*/
export function solveWithLU(A: Matrix, b: Matrix): Matrix {
return new LuDecomposition(A).solve(b);
}
/**
* Solves Ax = b (in the least-squares sense) using QR decomposition.
*/
export function solveWithQR(A: Matrix, b: Matrix): Matrix {
return new QrDecomposition(A).solve(b);
}
/**
* Solves Ax = b using SVD (computes the pseudoinverse solution).
*/
export function solveWithSVD(svdResult: SVDResult, b: Matrix): Matrix {
return svdResult.raw.solve(b);
}
/**
* Solves Ax = b using Cholesky decomposition.
*/
export function solveWithCholesky(A: Matrix, b: Matrix): Matrix {
return new CholeskyDecomposition(A).solve(b);
}
// =============================================================================
// DIAGNOSTIC FUNCTIONS
// =============================================================================
/**
* Checks if a matrix is symmetric within a small tolerance.
*/
export function isSymmetric(A: Matrix): boolean {
if (A.rows !== A.columns) return false;
// Use built-in method if available
if (typeof (A as any).isSymmetric === 'function') {
return (A as any).isSymmetric();
}
// Fallback: check by comparing with transpose
const tolerance = 1e-12;
for (let i = 0; i < A.rows; i++) {
for (let j = 0; j < A.columns; j++) {
if (Math.abs(A.get(i, j) - A.get(j, i)) > tolerance) {
return false;
}
}
}
return true;
}
/**
* Checks if a matrix is symmetric, positive-definite (SPD).
*/
export function isSpd(A: Matrix): boolean {
if (A.rows !== A.columns || !isSymmetric(A)) {
return false;
}
try {
const choleskyInstance = new CholeskyDecomposition(A);
return choleskyInstance.isPositiveDefinite();
} catch {
return false;
}
}
// =============================================================================
// BASIC MATRIX OPERATIONS
// =============================================================================
/**
* Matrix-matrix multiplication.
*/
export function matrixMultiply(A: Matrix, B: Matrix): Matrix {
return A.mmul(B);
}
/**
* Matrix subtraction.
*/
export function matrixSubtract(A: Matrix, B: Matrix): Matrix {
return A.sub(B);
}
/**
* Calculate the Euclidean (L2) norm of a matrix or vector.
*/
export function getNorm(A: Matrix): number {
return A.norm('frobenius');
}
/**
* Create a matrix filled with zeros.
*/
export function createZeros(rows: number, cols: number): Matrix {
return Matrix.zeros(rows, cols);
}
/**
* Check if a matrix consists entirely of zeros within tolerance.
*/
export function isMatrixAllZeros(A: Matrix, tol: number = 1e-15): boolean {
return getNorm(A) < tol;
}
/**
* Create an identity matrix.
*/
export function createIdentity(size: number): Matrix {
return Matrix.eye(size);
}
/**
* Get matrix rank using SVD.
*/
export function getRank(A: Matrix, _tolerance: number = 1e-12): number {
const svdResult = svd(A);
return svdResult.rank;
}
/**
* Get matrix condition number using SVD.
*/
export function getCondition(A: Matrix): number {
const svdResult = svd(A);
return svdResult.condition;
}
/**
* Compute the null space of a matrix using SVD.
*
* THEORY:
* For A = UΣV^T, the null space Null(A) = {x : Ax = 0} is spanned by
* columns of V corresponding to zero singular values.
*
* EMPIRICAL APPROACH (ROBUST):
* Since ml-matrix SVD behavior can be complex (especially with autoTranspose),
* we test each column of V to see which ones actually satisfy Ax = 0.
* This is more reliable than assuming positional patterns.
*
* ALGORITHM:
* 1. Test each column of V by computing ||A * v_i||
* 2. Columns with ||A * v_i|| < tolerance are in the null space
* 3. Extract these columns as the null space basis
*
* VERIFIED: This approach works correctly with ml-matrix regardless of
* internal implementation details or autoTranspose behavior.
*
* @param A - Original matrix
* @param svdResult - SVD decomposition result of A
* @param tolerance - Threshold for considering Ax ≈ 0
* @returns Matrix whose columns form orthonormal null space basis, or null if trivial
*/
export function computeNullSpace(A: Matrix, svdResult: SVDResult, tolerance: number = 1e-12): Matrix | null {
const { U, S, V, rank } = svdResult;
const expectedNullity = A.columns - rank;
console.log(`\n=== DEBUGGING computeNullSpace ===`);
console.log(`Matrix A: ${A.rows}×${A.columns}, rank=${rank}`);
console.log(`Expected nullity: ${expectedNullity} (A.columns=${A.columns} - rank=${rank})`);
console.log(`SVD matrices: U=${U.rows}×${U.columns}, V=${V.rows}×${V.columns}`);
console.log(`Singular values: [${S.map(s => s.toExponential(3)).join(', ')}]`);
console.log(`SVD threshold: ${svdResult.raw.threshold.toExponential(3)}`);
console.log(`SVD norm2: ${svdResult.raw.norm2.toExponential(3)}`);
// Check which singular values are below threshold
const zeroIndices = S.map((s, i) => ({ value: s, index: i, isZero: s <= svdResult.raw.threshold }));
console.log(`Singular value analysis:`);
zeroIndices.forEach(({value, index, isZero}) => {
console.log(` S[${index}] = ${value.toExponential(3)} ${isZero ? '(≤ threshold, ZERO)' : '(> threshold, NON-ZERO)'}`);
});
// Show the V matrix
console.log(`\nV matrix (${V.rows}×${V.columns}):`);
for (let i = 0; i < V.rows; i++) {
const row = [];
for (let j = 0; j < V.columns; j++) {
row.push(V.get(i, j).toFixed(6));
}
console.log(` [${row.join(', ')}]`);
}
// If full rank, no null space
if (expectedNullity <= 0) {
console.log(`✓ Full rank matrix, no null space\n`);
return null;
}
// Use SVD's own threshold and map by singular values
const svdThreshold = svdResult.raw.threshold;
const adjustedTolerance = Math.max(tolerance, svdThreshold * 1000); // Be more lenient
console.log(`\nAdjusted tolerance: ${adjustedTolerance.toExponential(3)} (was ${tolerance.toExponential(3)})`);
// Method 1: Trust SVD singular values to identify null space columns
const theoreticalNullIndices: number[] = [];
for (let i = 0; i < S.length; i++) {
const singularValue = S[i];
if (singularValue !== undefined && singularValue <= svdThreshold) {
theoreticalNullIndices.push(i);
}
}
console.log(`Theoretical null space columns (by singular values): [${theoreticalNullIndices.join(', ')}]`);
// Method 2: Empirical test with relaxed tolerance
const empiricalNullIndices: number[] = [];
console.log(`\nTesting V columns for null space property:`);
for (let col = 0; col < V.columns; col++) {
// Extract column col of V as a vector
const v = Matrix.zeros(V.rows, 1);
for (let row = 0; row < V.rows; row++) {
const value = V.get(row, col);
if (value !== undefined) {
v.set(row, 0, value);
}
}
console.log(` V column ${col}: [${Array.from({length: v.rows}, (_, i) => v.get(i, 0).toFixed(6)).join(', ')}]`);
// Test if A * v ≈ 0
try {
const Av = A.mmul(v);
const norm = Av.norm('frobenius');
const isNullEmpirical = norm < adjustedTolerance;
const isNullTheoretical = theoreticalNullIndices.includes(col);
console.log(` A*v = [${Array.from({length: Av.rows}, (_, i) => Av.get(i, 0).toExponential(3)).join(', ')}]`);
console.log(` ||A*v|| = ${norm.toExponential(3)} empirical:${isNullEmpirical ? '✓' : '✗'} theoretical:${isNullTheoretical ? '✓' : '✗'}`);
if (isNullEmpirical) {
empiricalNullIndices.push(col);
}
} catch (error) {
console.log(` ERROR: Matrix multiplication failed: ${error}`);
continue;
}
}
// Combine both approaches
const allNullIndices = [...new Set([...theoreticalNullIndices, ...empiricalNullIndices])];
console.log(`Combined null space indices: [${allNullIndices.join(', ')}]`);
console.log(`\nSummary:`);
console.log(` Empirical: ${empiricalNullIndices.length} vectors [${empiricalNullIndices.join(', ')}]`);
console.log(` Theoretical: ${theoreticalNullIndices.length} vectors [${theoreticalNullIndices.join(', ')}]`);
console.log(` Combined: ${allNullIndices.length} vectors [${allNullIndices.join(', ')}]`);
console.log(` Expected: ${expectedNullity} vectors`);
// Use the combined approach
const finalNullIndices = allNullIndices.length >= expectedNullity ? allNullIndices : allNullIndices;
if (finalNullIndices.length === 0) {
console.log(`✗ No null space vectors found\n`);
return null;
}
// Extract the null space columns
const nullBasis = Matrix.zeros(V.rows, finalNullIndices.length);
for (let j = 0; j < finalNullIndices.length; j++) {
const sourceCol = finalNullIndices[j];
if (sourceCol !== undefined && sourceCol < V.columns) {
for (let i = 0; i < V.rows; i++) {
const value = V.get(i, sourceCol);
if (value !== undefined) {
nullBasis.set(i, j, value);
}
}
}
}
console.log(`✓ Returning combined null space basis: ${nullBasis.rows}×${nullBasis.columns}\n`);
return nullBasis;
}