Skip to content

is SVD broken for wide matrices? (undocumented economy implementation) #203

@da2ce7

Description

@da2ce7

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;
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions