CraftScript sparse matrices and direct factorization
Builds sparse matrices in a script, adds and multiplies them, assembles element matrices, and factors a symmetric positive definite one.
How the precision is kept
Sparse matrices are compressed sparse row with Int32Array indexes and Float64Array values. Sums, products and assembly are plain 64-bit accumulations in a fixed order. The direct solver is an envelope (skyline) Cholesky factorization after a reverse Cuthill-McKee ordering, without pivoting; it stops with an error when a pivot is not positive.
Calculations under this policy
8 calculations in 1 source file. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.
Builds a sparse matrix from row, column and value lists, adding values that share a place.
Two stable counting sorts order the entries by row then column. Values at the same place are added in 64-bit in the order they were given, so the sum is repeatable for the same input order.
cs-lib-linalg.js
lines 114 to 142
PartCraft 0.8.1
/** Triplets (0-based rows/cols) → CSR, duplicates summed, columns sorted; two counting sorts (column, then stable row). */
export function fromTriplets(I, J, V, n, m) {
const nz = I.length;
// pass 1: by column
const cc = new Int32Array(m + 1);
for (let k = 0; k < nz; k++) cc[J[k] + 1]++;
for (let c = 0; c < m; c++) cc[c + 1] += cc[c];
const o1 = new Int32Array(nz), fc = cc.slice(0, m);
for (let k = 0; k < nz; k++) o1[fc[J[k]]++] = k;
// pass 2: stable by row
const rc = new Int32Array(n + 1);
for (let k = 0; k < nz; k++) rc[I[k] + 1]++;
for (let r = 0; r < n; r++) rc[r + 1] += rc[r];
const o2 = new Int32Array(nz), fr = rc.slice(0, n);
for (let q = 0; q < nz; q++) { const k = o1[q]; o2[fr[I[k]]++] = k; }
// merge duplicates
const rowPtr = new Int32Array(n + 1), col = new Int32Array(nz), val = new Float64Array(nz);
let w = 0;
for (let r = 0; r < n; r++) {
const start = w;
for (let q = rc[r]; q < rc[r + 1]; q++) {
const k = o2[q], c = J[k];
if (w > start && col[w - 1] === c) val[w - 1] += V[k];
else { col[w] = c; val[w] = V[k]; w++; }
}
rowPtr[r + 1] = w;
}
return sp(n, m, rowPtr, col.slice(0, w), val.slice(0, w));
}
Weighted sum of two sparse matrices
64-bit
a·A + b·B for matrices of the same size.
C_ij = a × A_ij + b × B_ij
One 64-bit expression per entry. Entries that cancel to zero stay stored as zeros; nothing is dropped by a tolerance.
cs-lib-linalg.js
lines 161 to 177
PartCraft 0.8.1
/** a·A + b·B (same size). */
export function spAdd(A, B, a = 1, b = 1) {
if (A.n !== B.n || A.m !== B.m) throw err(t('script.linalg.sparse_sizes_differ', { a: `${A.n}×${A.m}`, b: `${B.n}×${B.m}` }));
const rowPtr = new Int32Array(A.n + 1), cap = A.col.length + B.col.length, col = new Int32Array(cap), val = new Float64Array(cap);
let w = 0;
for (let r = 0; r < A.n; r++) {
let i = A.rowPtr[r], j = B.rowPtr[r];
const ie = A.rowPtr[r + 1], je = B.rowPtr[r + 1];
while (i < ie || j < je) {
if (j >= je || (i < ie && A.col[i] < B.col[j])) { col[w] = A.col[i]; val[w++] = a * A.val[i++]; }
else if (i >= ie || B.col[j] < A.col[i]) { col[w] = B.col[j]; val[w++] = b * B.val[j++]; }
else { col[w] = A.col[i]; val[w++] = a * A.val[i++] + b * B.val[j++]; }
}
rowPtr[r + 1] = w;
}
return sp(A.n, A.m, rowPtr, col.slice(0, w), val.slice(0, w));
}
Sparse matrix times vector
64-bit
The product of a sparse matrix and a vector.
y_r = Σ_k val_k × x_col(k)
Each row is a plain 64-bit sum of products in column order.
cs-lib-linalg.js
lines 179 to 184
PartCraft 0.8.1
/** y = S x (x Float64Array) */
export function spMulVec(S, x, y = new Float64Array(S.n)) {
const { rowPtr, col, val } = S;
for (let r = 0; r < S.n; r++) { let s = 0; for (let k = rowPtr[r]; k < rowPtr[r + 1]; k++) s += val[k] * x[col[k]]; y[r] = s; }
return y;
}
Sparse matrix times sparse matrix
64-bit
The product of two sparse matrices.
Each row is accumulated in a 64-bit work array, one contribution at a time in the order of the left matrix's columns. Exact zeros produced by cancellation are kept as stored entries.
cs-lib-linalg.js
lines 193 to 210
PartCraft 0.8.1
/** Sparse × sparse (Gustavson). */
export function spMulSp(A, B) {
if (A.m !== B.n) throw err(t('script.linalg.inner_sizes', { a: `${A.n}×${A.m}`, b: `${B.n}×${B.m}` }));
const mark = new Int32Array(B.m).fill(-1), acc = new Float64Array(B.m), list = new Int32Array(B.m);
const rowPtr = new Int32Array(A.n + 1); let col = new Int32Array(Math.max(16, A.col.length + B.col.length)), val = new Float64Array(col.length), w = 0;
for (let i = 0; i < A.n; i++) {
let m = 0;
for (let k = A.rowPtr[i]; k < A.rowPtr[i + 1]; k++) {
const a = A.val[k], r = A.col[k];
for (let q = B.rowPtr[r]; q < B.rowPtr[r + 1]; q++) { const j = B.col[q]; if (mark[j] !== i) { mark[j] = i; acc[j] = 0; list[m++] = j; } acc[j] += a * B.val[q]; }
}
const l = list.subarray(0, m).sort();
if (w + m > col.length) { const nc = new Int32Array(2 * (w + m)), nv = new Float64Array(2 * (w + m)); nc.set(col); nv.set(val); col = nc; val = nv; }
for (let t = 0; t < m; t++) { col[w] = l[t]; val[w++] = acc[l[t]]; }
rowPtr[i + 1] = w;
}
return sp(A.n, B.m, rowPtr, col.slice(0, w), val.slice(0, w));
}
Symmetry test
64-bit
Decides whether a sparse matrix is symmetric, which selects the solver.
max |S − Sᵀ| ≤ 1e-10 × max |S|
The tolerance is relative to the largest absolute entry of the matrix (1 if the matrix is all zeros). A matrix with entries of very different sizes can pass while its small entries are not symmetric to that tolerance.
cs-lib-linalg.js
lines 238 to 247
PartCraft 0.8.1
/** Is S symmetric (pattern and values within rel·max|v|)? */
export function spIsSymmetric(S, rel = 1e-10) {
if (S.n !== S.m) return false;
const T = spTranspose(S);
let mx = 0; for (const v of S.val) mx = Math.max(mx, Math.abs(v));
const tol = rel * (mx || 1);
const D = spAdd(S, T, 1, -1);
for (const v of D.val) if (Math.abs(v) > tol) return false;
return true;
}
Envelope size after reordering
64-bit
Counts the stored entries and the work of the skyline factorization for an ordering.
entries = Σ wᵢ; work = Σ wᵢ² ÷ 2, wᵢ = i − firstᵢ + 1
Both totals are 64-bit sums of whole numbers and of halves of squares. They are compared with the limits 4e7 and 3e9 to decide between the direct and the iterative solver.
cs-lib-linalg.js
lines 282 to 292
PartCraft 0.8.1
/** Envelope size after a permutation (new index of each old node). */
function envelopeOf(S, perm) {
const n = S.n, first = new Int32Array(n);
for (let i = 0; i < n; i++) first[i] = i;
for (let r = 0; r < n; r++) for (let k = S.rowPtr[r]; k < S.rowPtr[r + 1]; k++) {
const a = perm[r], b = perm[S.col[k]], hi = a > b ? a : b, lo = a > b ? b : a;
if (lo < first[hi]) first[hi] = lo;
}
let tot = 0, work = 0; for (let i = 0; i < n; i++) { const w = i - first[i] + 1; tot += w; work += w * w / 2; }
return { first, tot, work };
}
Envelope Cholesky factorization and solve
64-bit
Factors a symmetric positive definite sparse matrix and solves with the factor.
The factor is one Float64Array. There is no pivoting: a diagonal term that is not greater than zero after the subtraction raises a not positive definite error, so no square root of a negative number is taken. The solve is one forward and one backward substitution without refinement.
cs-lib-linalg.js
lines 293 to 335
PartCraft 0.8.1
/** Factor an SPD sparse matrix (envelope Cholesky, RCM order) → { solve(b, x), n, envelope, ms } or null when it does not fit (cap entries, maxWork multiply-adds). Throws 'not positive definite'. */
export function envelopeCholesky(S, { cap = ENVELOPE_CAP, maxWork = ENVELOPE_WORK, check = () => {} } = {}) {
const t0 = Date.now(), n = S.n;
const g = symGraph(S), perm = n > 1 ? kRcm(n, g.ptr, g.adj) : new Int32Array(n);
const { first, tot, work } = envelopeOf(S, perm);
if (tot > cap || work > maxWork) return null;
const start = new Float64Array(n + 1); // offset of row i (Float64 index arithmetic safe beyond 2^31 is not needed below the cap)
for (let i = 0; i < n; i++) start[i + 1] = start[i] + (i - first[i] + 1);
const L = new Float64Array(tot);
// scatter A (lower part, permuted) into the envelope; L[start[i] + (j − first[i])] = A_ij
for (let r = 0; r < n; r++) for (let k = S.rowPtr[r]; k < S.rowPtr[r + 1]; k++) {
const a = perm[r], b = perm[S.col[k]];
if (b > a) continue;
L[start[a] + (b - first[a])] += S.val[k];
}
// in-place row-oriented Cholesky
for (let i = 0; i < n; i++) {
if ((i & 1023) === 0) check();
const fi = first[i], si = start[i];
for (let j = fi; j < i; j++) {
const fj = first[j], sj = start[j], k0 = fi > fj ? fi : fj;
let s = L[si + (j - fi)];
let p = si + (k0 - fi), q = sj + (k0 - fj);
for (let k = k0; k < j; k++) s -= L[p++] * L[q++];
L[si + (j - fi)] = s / L[sj + (j - fj)];
}
let d = L[si + (i - fi)];
for (let p = si, e = si + (i - fi); p < e; p++) d -= L[p] * L[p];
if (!(d > 0)) { const e = err(t('script.linalg.not_pd')); e.notPD = true; throw e; }
L[si + (i - fi)] = Math.sqrt(d);
}
const y = new Float64Array(n);
return {
n, envelope: tot, ms: Date.now() - t0, kind: 'envelope Cholesky (RCM)', // i18n-ignore: a method name scripts read from info.method
solve(b, x = new Float64Array(n)) {
for (let i = 0; i < n; i++) y[perm[i]] = b[i];
for (let i = 0; i < n; i++) { const fi = first[i], si = start[i]; let s = y[i]; for (let k = fi, p = si; k < i; k++) s -= L[p++] * y[k]; y[i] = s / L[si + (i - fi)]; }
for (let i = n - 1; i >= 0; i--) { const fi = first[i], si = start[i]; const v = (y[i] /= L[si + (i - fi)]); for (let k = fi, p = si; k < i; k++) y[k] -= L[p++] * v; }
for (let i = 0; i < n; i++) x[i] = y[perm[i]];
return x;
},
};
}
Assembly of element matrices
64-bit
Adds weighted element matrices into one sparse matrix.
K = Σ_e w_e × P_eᵀ Ke P_e
Each entry is a 64-bit running sum of w_e × Ke terms, added in element order. Degree-of-freedom numbers must be integers of at least 1; elements with weight exactly 0 are skipped.
cs-lib-linalg.js
lines 723 to 766
PartCraft 0.8.1
/**
* K = Σ_e w_e · P_eᵀ Ke(e) P_e — edof (ne × nd, 1-based dof numbers), Ke (nd × nd, or nd × nd × ne), w (ne, optional),
* n (size, default the largest dof). → sparse n × n. Pattern built once from the connectivity (no triplet arrays).
*/
export function assembleElements(edof, Ke, w = null, n = 0, check = () => {}) {
const [ne, nd] = size2(edof, 'edof');
const per = Ke.shape.length === 3 && Ke.shape[2] > 1;
if (Ke.shape[0] !== nd || Ke.shape[1] !== nd || (per && Ke.shape[2] !== ne)) throw err(t('script.linalg.assemble_ke', { size: `${nd}×${nd}${per ? `×${ne}` : ''}`, nd }));
if (w && w.length !== ne) throw err(t('script.linalg.assemble_w', { w: w.length, ne }));
const E = new Int32Array(ne * nd); // row-major element dofs, 0-based
let mx = 0;
for (let e = 0; e < ne; e++) for (let a = 0; a < nd; a++) {
const v = edof.data[e + a * ne];
if (!Number.isInteger(v) || v < 1) throw err(t('script.linalg.assemble_dof', { dof: v, e: e + 1 }));
E[e * nd + a] = v - 1; if (v > mx) mx = v;
}
n = n || mx;
if (mx > n) throw err(t('script.linalg.assemble_past', { dof: mx, n }));
// node-free pattern: for each dof, the set of dofs sharing an element
const cnt = new Int32Array(n + 1);
for (let i = 0; i < E.length; i++) cnt[E[i] + 1]++;
for (let i = 0; i < n; i++) cnt[i + 1] += cnt[i];
const elOf = new Int32Array(E.length), fill = cnt.slice(0, n);
for (let e = 0; e < ne; e++) for (let a = 0; a < nd; a++) elOf[fill[E[e * nd + a]]++] = e;
const mark = new Int32Array(n).fill(-1), rowPtr = new Int32Array(n + 1), rows = new Array(n);
let nnz = 0;
for (let r = 0; r < n; r++) {
const l = [];
for (let q = cnt[r]; q < cnt[r + 1]; q++) { const e = elOf[q]; for (let b = 0; b < nd; b++) { const c = E[e * nd + b]; if (mark[c] !== r) { mark[c] = r; l.push(c); } } }
rows[r] = Int32Array.from(l).sort(); nnz += l.length; rowPtr[r + 1] = nnz;
if ((r & 4095) === 0) check();
}
const col = new Int32Array(nnz); for (let r = 0; r < n; r++) col.set(rows[r], rowPtr[r]);
const val = new Float64Array(nnz), loc = new Int32Array(nd * nd), K = Ke.data;
const find = (r, c) => { let lo = rowPtr[r], hi = rowPtr[r + 1] - 1; while (lo <= hi) { const m = (lo + hi) >> 1, v = col[m]; if (v === c) return m; if (v < c) lo = m + 1; else hi = m - 1; } return -1; };
for (let e = 0; e < ne; e++) {
if ((e & 4095) === 0) check();
const s = w ? w[e] : 1; if (s === 0) continue;
const off = per ? e * nd * nd : 0;
for (let a = 0; a < nd; a++) for (let b = 0; b < nd; b++) loc[a * nd + b] = find(E[e * nd + a], E[e * nd + b]);
for (let b = 0; b < nd; b++) for (let a = 0; a < nd; a++) val[loc[a * nd + b]] += s * K[off + a + b * nd];
}
return sp(n, n, rowPtr, col, val);
}
In detail
Method
Counting sorts for triplets; Gustavson's algorithm for sparse times sparse (named in the code); row-oriented envelope Cholesky after reverse Cuthill-McKee from tetfea.js.
Number types
Float64Array values; Int32Array row pointers and column indexes; envelope row offsets held in a Float64Array.
Tolerances
Symmetry test: |S − Sᵀ| at most 1e-10 times the largest absolute entry. Cholesky: a pivot must be greater than 0.
Limits
The envelope factorization is used only up to 4e7 stored entries and 3e9 multiply-adds; beyond that the caller uses an iterative solver.
Approach: 64-bit, direct
Every value is a 64-bit number from input to result. Formulas are closed form or a direct solve, and every comparison uses a stated tolerance.