const FLOAT64_BYTES = Float64Array.BYTES_PER_ELEMENT; export const DEFAULT_MAX_COVARIANCE_WORK_BYTES = 32 * 1024 * 1024; export const DEFAULT_MAX_COVARIANCE_COMPLEX_ACCUMULATIONS = 250_000_000; export const DEFAULT_COVARIANCE_EIGENVALUE_ROUNDOFF_FACTOR = 64; function assertPositiveInteger(value, label) { if (!Number.isSafeInteger(value) || value < 1) { throw new TypeError(`${label} must be a positive safe integer.`); } } function checkedProduct(factors, label) { let product = 1; for (const factor of factors) { if (factor !== 0 && product > Number.MAX_SAFE_INTEGER / factor) { throw new RangeError(`${label} exceeds the safe integer range.`); } product *= factor; } return product; } function roundoffGamma(operationCount) { const product = operationCount * (Number.EPSILON / 2); if (!(product < 1)) { throw new RangeError("roundoff bound is undefined for this workload."); } return product / (1 - product); } /** Estimate covariance output allocation and upper-triangle accumulation work. */ export function estimateComplexCovarianceWorkload({ sensorCount, snapshotCount }) { assertPositiveInteger(sensorCount, "sensorCount"); assertPositiveInteger(snapshotCount, "snapshotCount"); const inputElementCount = checkedProduct( [sensorCount, snapshotCount], "covariance input element count", ); const matrixElementCount = checkedProduct( [sensorCount, sensorCount], "covariance matrix element count", ); const upperTriangleElementCount = checkedProduct([sensorCount, sensorCount + 1], "covariance upper triangle") / 2; const complexAccumulations = checkedProduct( [upperTriangleElementCount, snapshotCount], "covariance accumulation count", ); const allocationBytes = checkedProduct( [2, matrixElementCount, FLOAT64_BYTES], "covariance allocation", ); return Object.freeze({ sensorCount, snapshotCount, inputElementCount, matrixElementCount, upperTriangleElementCount, complexAccumulations, allocationBytes, }); } function compensatedAdd(sum, compensation, value) { const next = sum + value; const correction = Math.abs(sum) >= Math.abs(value) ? (sum - next) + value : (value - next) + sum; return [next, compensation + correction]; } /** * Compute R = (1/T) sum_t x[t] x[t]^H in Float64. * * `snapshot-major` stores x(t,m) at `t * sensorCount + m`; `sensor-major` * stores it at `m * snapshotCount + t`. Only the upper triangle is * accumulated, with Neumaier compensation, and the lower triangle is filled * as its exact complex conjugate. No centering or diagonal loading is applied. */ export function computeComplexSampleCovariance({ real, imaginary, sensorCount, snapshotCount, layout = "snapshot-major", maximumWorkBytes = DEFAULT_MAX_COVARIANCE_WORK_BYTES, maximumComplexAccumulations = DEFAULT_MAX_COVARIANCE_COMPLEX_ACCUMULATIONS, }) { const workload = estimateComplexCovarianceWorkload({ sensorCount, snapshotCount }); if (layout !== "snapshot-major" && layout !== "sensor-major") { throw new RangeError("layout must be 'snapshot-major' or 'sensor-major'."); } if (real == null || imaginary == null) { throw new TypeError("real and imaginary snapshots are required."); } if ( real.length !== workload.inputElementCount || imaginary.length !== workload.inputElementCount ) { throw new RangeError( `snapshot components must each contain ${workload.inputElementCount} values.`, ); } if (!(maximumWorkBytes >= 0) || !Number.isFinite(maximumWorkBytes)) { throw new TypeError("maximumWorkBytes must be finite and nonnegative."); } if ( !(maximumComplexAccumulations >= 0) || !Number.isFinite(maximumComplexAccumulations) ) { throw new TypeError("maximumComplexAccumulations must be finite and nonnegative."); } if (workload.allocationBytes > maximumWorkBytes) { throw new RangeError( `covariance requires ${workload.allocationBytes} typed-array bytes; limit is ${maximumWorkBytes}.`, ); } if (workload.complexAccumulations > maximumComplexAccumulations) { throw new RangeError( `covariance requires ${workload.complexAccumulations} complex accumulations; limit is ${maximumComplexAccumulations}.`, ); } for (let index = 0; index < workload.inputElementCount; index += 1) { if (!Number.isFinite(real[index]) || !Number.isFinite(imaginary[index])) { throw new TypeError("snapshot components must be finite."); } } const matrixReal = new Float64Array(workload.matrixElementCount); const matrixImaginary = new Float64Array(workload.matrixElementCount); const sampleIndex = layout === "snapshot-major" ? (sensor, snapshot) => snapshot * sensorCount + sensor : (sensor, snapshot) => sensor * snapshotCount + snapshot; let matrixScale = 0; let traceSum = 0; let traceCompensation = 0; for (let row = 0; row < sensorCount; row += 1) { for (let column = row; column < sensorCount; column += 1) { let sumReal = 0; let compensationReal = 0; let sumImaginary = 0; let compensationImaginary = 0; for (let snapshot = 0; snapshot < snapshotCount; snapshot += 1) { const rowIndex = sampleIndex(row, snapshot); const columnIndex = sampleIndex(column, snapshot); const rowReal = real[rowIndex]; const rowImaginary = imaginary[rowIndex]; const columnReal = real[columnIndex]; const columnImaginary = imaginary[columnIndex]; const productReal = rowReal * columnReal + rowImaginary * columnImaginary; const productImaginary = rowImaginary * columnReal - rowReal * columnImaginary; if (!Number.isFinite(productReal) || !Number.isFinite(productImaginary)) { throw new RangeError("covariance product exceeds the finite Float64 range."); } [sumReal, compensationReal] = compensatedAdd( sumReal, compensationReal, productReal, ); [sumImaginary, compensationImaginary] = compensatedAdd( sumImaginary, compensationImaginary, productImaginary, ); } const valueReal = (sumReal + compensationReal) / snapshotCount; const valueImaginary = row === column ? 0 : (sumImaginary + compensationImaginary) / snapshotCount; if (!Number.isFinite(valueReal) || !Number.isFinite(valueImaginary)) { throw new RangeError("covariance value exceeds the finite Float64 range."); } const upper = row * sensorCount + column; const lower = column * sensorCount + row; matrixReal[upper] = valueReal; matrixImaginary[upper] = valueImaginary; matrixReal[lower] = valueReal; matrixImaginary[lower] = row === column ? 0 : -valueImaginary; matrixScale = Math.max( matrixScale, Math.abs(valueReal), Math.abs(valueImaginary), ); if (row === column) { [traceSum, traceCompensation] = compensatedAdd( traceSum, traceCompensation, valueReal, ); } } } const trace = traceSum + traceCompensation; const covarianceRoundoffBound = roundoffGamma(4 * snapshotCount + 32) * trace; return { sensorCount, snapshotCount, inputLayout: layout, normalization: "1/T", centered: false, matrix: { real: matrixReal, imaginary: matrixImaginary, layout: "row-major", }, matrixScale, trace, covarianceRoundoffBound, workload, }; } /** * Audit covariance eigenvalues against a scale-aware backward-roundoff bound. * Strictly positive values are never floored. Only negative values within the * bound are represented as zero in `roundoffClampedEigenvalues`; raw values * are always retained separately. */ export function auditCovarianceEigenvalues({ eigenvalues, sensorCount = eigenvalues?.length, matrixScale, roundoffTolerance, covarianceRoundoffBound, eigensolverResidualFrobeniusNorm = 0, roundoffFactor = DEFAULT_COVARIANCE_EIGENVALUE_ROUNDOFF_FACTOR, rejectIndefinite = true, }) { if (eigenvalues == null) throw new TypeError("eigenvalues are required."); assertPositiveInteger(sensorCount, "sensorCount"); if (eigenvalues.length !== sensorCount) { throw new RangeError("eigenvalue count must equal sensorCount."); } if (!(roundoffFactor > 0) || !Number.isFinite(roundoffFactor)) { throw new TypeError("roundoffFactor must be finite and positive."); } if (matrixScale !== undefined && (!(matrixScale >= 0) || !Number.isFinite(matrixScale))) { throw new TypeError("matrixScale must be finite and nonnegative."); } if ( covarianceRoundoffBound !== undefined && (!(covarianceRoundoffBound >= 0) || !Number.isFinite(covarianceRoundoffBound)) ) { throw new TypeError("covarianceRoundoffBound must be finite and nonnegative."); } if ( !(eigensolverResidualFrobeniusNorm >= 0) || !Number.isFinite(eigensolverResidualFrobeniusNorm) ) { throw new TypeError( "eigensolverResidualFrobeniusNorm must be finite and nonnegative.", ); } let eigenvalueScale = matrixScale ?? 0; let minimumEigenvalue = Infinity; for (const value of eigenvalues) { if (!Number.isFinite(value)) throw new TypeError("eigenvalues must be finite."); eigenvalueScale = Math.max(eigenvalueScale, Math.abs(value)); minimumEigenvalue = Math.min(minimumEigenvalue, value); } const scaleDerivedRoundoffTolerance = eigenvalueScale === 0 ? 0 : roundoffFactor * Number.EPSILON * Math.max(1, sensorCount) * eigenvalueScale; const effectiveRoundoffTolerance = roundoffTolerance ?? ( covarianceRoundoffBound === undefined ? scaleDerivedRoundoffTolerance : covarianceRoundoffBound + eigensolverResidualFrobeniusNorm ); if ( !(effectiveRoundoffTolerance >= 0) || !Number.isFinite(effectiveRoundoffTolerance) ) { throw new TypeError("roundoffTolerance must be finite and nonnegative."); } const rawEigenvalues = Float64Array.from(eigenvalues); const roundoffClampedEigenvalues = Float64Array.from(eigenvalues); let clampedNegativeCount = 0; let firstIndefiniteIndex = -1; for (let index = 0; index < sensorCount; index += 1) { const value = rawEigenvalues[index]; if (value < -effectiveRoundoffTolerance) { if (firstIndefiniteIndex < 0) firstIndefiniteIndex = index; } else if (value < 0 || Object.is(value, -0)) { roundoffClampedEigenvalues[index] = 0; clampedNegativeCount += 1; } } const isPositiveSemidefinite = firstIndefiniteIndex < 0; if (!isPositiveSemidefinite && rejectIndefinite) { const error = new RangeError( `covariance is genuinely indefinite: eigenvalue ${rawEigenvalues[firstIndefiniteIndex]} is below -${effectiveRoundoffTolerance}.`, ); error.eigenvalueIndex = firstIndefiniteIndex; error.eigenvalue = rawEigenvalues[firstIndefiniteIndex]; error.roundoffTolerance = effectiveRoundoffTolerance; throw error; } return { rawEigenvalues, roundoffClampedEigenvalues, roundoffTolerance: effectiveRoundoffTolerance, roundoffFactor, roundoffDerivation: roundoffTolerance !== undefined ? "explicit" : covarianceRoundoffBound === undefined ? "eigenvalue-scale" : "covariance-accumulation-plus-eigensolver-residual", covarianceRoundoffBound: covarianceRoundoffBound ?? null, eigensolverResidualFrobeniusNorm, eigenvalueScale, minimumEigenvalue, clampedNegativeCount, firstIndefiniteIndex, isPositiveSemidefinite, }; } function unavailableSourceCount(criterion, reason, details = {}) { return { available: false, criterion, sourceCount: null, score: null, scores: null, reason, ...details, }; } /** * Select source count with the Wax-Kailath AIC or MDL criterion. * * This pure function deliberately declines an answer unless T > M, snapshots * are declared independent, and every eigenvalue used by the candidate set is * strictly above the supplied roundoff bound. It performs no loading, rank * repair, or cross-frequency source-count fusion. */ export function selectWaxKailathSourceCount({ eigenvalues, snapshotCount, independentSnapshots, criterion = "mdl", roundoffTolerance, }) { if (criterion !== "aic" && criterion !== "mdl") { throw new RangeError("criterion must be 'aic' or 'mdl'."); } if (eigenvalues == null || eigenvalues.length < 1) { throw new TypeError("at least one eigenvalue is required."); } assertPositiveInteger(snapshotCount, "snapshotCount"); const sensorCount = eigenvalues.length; assertPositiveInteger(sensorCount, "sensorCount"); let scale = 0; for (const value of eigenvalues) { if (!Number.isFinite(value)) throw new TypeError("eigenvalues must be finite."); scale = Math.max(scale, Math.abs(value)); } const effectiveRoundoffTolerance = roundoffTolerance ?? ( scale === 0 ? 0 : DEFAULT_COVARIANCE_EIGENVALUE_ROUNDOFF_FACTOR * Number.EPSILON * Math.max(1, sensorCount) * scale ); if ( !(effectiveRoundoffTolerance >= 0) || !Number.isFinite(effectiveRoundoffTolerance) ) { throw new TypeError("roundoffTolerance must be finite and nonnegative."); } if (independentSnapshots !== true) { return unavailableSourceCount(criterion, "snapshots-not-independent", { sensorCount, snapshotCount, roundoffTolerance: effectiveRoundoffTolerance, }); } if (snapshotCount <= sensorCount) { return unavailableSourceCount(criterion, "snapshot-count-not-greater-than-sensors", { sensorCount, snapshotCount, roundoffTolerance: effectiveRoundoffTolerance, }); } for (let index = 0; index < sensorCount; index += 1) { const value = eigenvalues[index]; if (index > 0 && value > eigenvalues[index - 1] + effectiveRoundoffTolerance) { throw new RangeError("eigenvalues must be sorted in descending order."); } if (!(value > effectiveRoundoffTolerance)) { return unavailableSourceCount( criterion, "noise-eigenvalue-not-strictly-above-roundoff", { sensorCount, snapshotCount, roundoffTolerance: effectiveRoundoffTolerance, failingEigenvalueIndex: index, failingEigenvalue: value, }, ); } } const scores = new Float64Array(sensorCount); let selectedSourceCount = 0; let selectedScore = Infinity; let logRatioRoundoffClampCount = 0; for (let sourceCount = 0; sourceCount < sensorCount; sourceCount += 1) { const noiseCount = sensorCount - sourceCount; let arithmeticSum = 0; let arithmeticCompensation = 0; let logarithmSum = 0; let logarithmCompensation = 0; for (let index = sourceCount; index < sensorCount; index += 1) { [arithmeticSum, arithmeticCompensation] = compensatedAdd( arithmeticSum, arithmeticCompensation, eigenvalues[index], ); [logarithmSum, logarithmCompensation] = compensatedAdd( logarithmSum, logarithmCompensation, Math.log(eigenvalues[index]), ); } const arithmeticMean = (arithmeticSum + arithmeticCompensation) / noiseCount; const logarithmicGeometricMean = (logarithmSum + logarithmCompensation) / noiseCount; let logarithmicRatio = Math.log(arithmeticMean) - logarithmicGeometricMean; const ratioRoundoffTolerance = 64 * Number.EPSILON * Math.max(1, noiseCount) * Math.max(1, Math.abs(Math.log(arithmeticMean)), Math.abs(logarithmicGeometricMean)); if (logarithmicRatio < 0 && logarithmicRatio >= -ratioRoundoffTolerance) { logarithmicRatio = 0; logRatioRoundoffClampCount += 1; } if (!(logarithmicRatio >= 0) || !Number.isFinite(logarithmicRatio)) { throw new RangeError("Wax-Kailath arithmetic/geometric mean ratio is invalid."); } const likelihoodTerm = snapshotCount * noiseCount * logarithmicRatio; const parameterCount = sourceCount * (2 * sensorCount - sourceCount); const score = criterion === "aic" ? 2 * likelihoodTerm + 2 * parameterCount : likelihoodTerm + 0.5 * parameterCount * Math.log(snapshotCount); scores[sourceCount] = score; if (score < selectedScore) { selectedScore = score; selectedSourceCount = sourceCount; } } return { available: true, criterion, sourceCount: selectedSourceCount, score: selectedScore, scores, reason: null, sensorCount, snapshotCount, roundoffTolerance: effectiveRoundoffTolerance, logRatioRoundoffClampCount, }; }