@qe-libs/rena-wasm 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -91,6 +91,26 @@ const model = ena.fit(rows, { codes, units, conversations, window: 4, weightMode
91
91
 
92
92
  ---
93
93
 
94
+ ## Reduced-Code Search (PRIA)
95
+
96
+ `pria()` finds the largest set of codes (up to `removeNum`, never leaving
97
+ fewer than 3) whose removal keeps the model within `threshold` of the full
98
+ model — the same search, gates and tie-break as R's `PRIA::pria()`. It takes
99
+ the same options as `fit()` (rotation, `weightModel`, `tensor`, `codeMask`,
100
+ …), so it scores the model you display.
101
+
102
+ ```js
103
+ const { removed, k, variance } = ena.pria(rows, {
104
+ codes, units, conversations, window: 4,
105
+ rotation: 'mean', groupA, groupB,
106
+ removeNum: 3, threshold: 0.95,
107
+ });
108
+ // removed → code names to drop (most codes first, then highest dim-1 variance)
109
+ // variance → dim-1 share of variance in the chosen reduced model
110
+ ```
111
+
112
+ ---
113
+
94
114
  ## Accumulation Only
95
115
 
96
116
  Returns raw (un-normalised) network vectors without running the full pipeline.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qe-libs/rena-wasm",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "JavaScript/WebAssembly ENA pipeline — thin orchestration layer over @qe-libs/libqe-wasm",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/index.js CHANGED
@@ -210,11 +210,47 @@ function weightModelName(name) {
210
210
  }
211
211
  }
212
212
 
213
+ // ── code masking ─────────────────────────────────────────────────────────────
214
+
215
+ /**
216
+ * Zero, in place, every connection column the code mask excludes.
217
+ * codeMask is an nCodes × nCodes 0/1 matrix; a 0 at [a][b] drops the
218
+ * connection between codes a and b (unordered: either orientation drops the
219
+ * pair; ordered: codeMask[j][i] === 0 drops the directed column i*n + j).
220
+ *
221
+ * @param {Float64Array} networks nUnits × nConnections, row-major
222
+ * @param {number[][]} codeMask
223
+ * @param {number} nCodes
224
+ * @param {number} nUnits
225
+ * @param {number} nConnections
226
+ * @param {boolean} ordered
227
+ */
228
+ function applyCodeMask(networks, codeMask, nCodes, nUnits, nConnections, ordered) {
229
+ if (!codeMask || codeMask.length !== nCodes) return;
230
+ const zeroColumn = (k) => {
231
+ for (let u = 0; u < nUnits; u++) networks[u * nConnections + k] = 0;
232
+ };
233
+ if (ordered) {
234
+ for (let j = 0; j < nCodes; j++)
235
+ for (let i = 0; i < nCodes; i++)
236
+ if (codeMask[j] && codeMask[j][i] === 0) zeroColumn(i * nCodes + j);
237
+ return;
238
+ }
239
+ let k = 0;
240
+ for (let col = 1; col < nCodes; col++) {
241
+ for (let row = 0; row < col; row++) {
242
+ if ((codeMask[row] && codeMask[row][col] === 0) ||
243
+ (codeMask[col] && codeMask[col][row] === 0)) zeroColumn(k);
244
+ k++;
245
+ }
246
+ }
247
+ }
248
+
213
249
  // ── shared pipeline (post-accumulation) ──────────────────────────────────────
214
250
 
215
251
  function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
216
252
  metaData, rotMethod, groupA, groupB, dims, gParams,
217
- rowConnectionCounts = null) {
253
+ rowConnectionCounts = null, directedNodes = false) {
218
254
  const connectionNames = qe.connection_names(codes);
219
255
 
220
256
  // Sphere norm → lineWeights (= R's set$line.weights)
@@ -241,6 +277,9 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
241
277
  rot = rotateSVD(qe, pointsForProjection, nUnits, nConnections);
242
278
  }
243
279
 
280
+ // Never ask for more dimensions than the rotation has columns.
281
+ dims = Math.min(dims, rot.rotCols);
282
+
244
283
  // Truncate rotation matrix to dims columns (= R's set$rotation.matrix)
245
284
  const rotationMatrix = new Float64Array(rot.rotRows * dims);
246
285
  for (let r = 0; r < rot.rotRows; r++)
@@ -259,8 +298,10 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
259
298
  // Matches rENA's lws.positions.sq, which regresses the projected points onto
260
299
  // the SPHERE-normed line weights (enaset$line.weights) — not the centered
261
300
  // networks. Verified node-for-node against R rENA on rs.data.new.csv.
301
+ // `directedNodes` (ordered networks, used by pria()) solves with libqe's
302
+ // directed_node_positions, as rENA's optimize() does for ordered sets.
262
303
  const { nodes, centroids } = nodePositions(
263
- qe, lineWeights, nUnits, nConnections, points, dims
304
+ qe, lineWeights, nUnits, nConnections, points, dims, directedNodes
264
305
  );
265
306
 
266
307
  // Variance explained (= R's model$variance)
@@ -371,34 +412,7 @@ export default async function loadENA() {
371
412
  );
372
413
  const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
373
414
 
374
- // Apply code masking by zeroing out the masked connection columns across all units
375
- if (codeMask && codeMask.length === codes.length) {
376
- console.log('[rena-wasm] Applying code mask to raw networks...');
377
- if (ordered) {
378
- for (let j = 0; j < codes.length; j++) {
379
- for (let i = 0; i < codes.length; i++) {
380
- if (codeMask[j] && codeMask[j][i] === 0) {
381
- const k = i * codes.length + j;
382
- for (let u = 0; u < nUnits; u++) {
383
- rawNetworks[u * nConnections + k] = 0;
384
- }
385
- }
386
- }
387
- }
388
- } else {
389
- let k = 0;
390
- for (let col = 1; col < codes.length; col++) {
391
- for (let row = 0; row < col; row++) {
392
- if ((codeMask[row] && codeMask[row][col] === 0) || (codeMask[col] && codeMask[col][row] === 0)) {
393
- for (let u = 0; u < nUnits; u++) {
394
- rawNetworks[u * nConnections + k] = 0;
395
- }
396
- }
397
- k++;
398
- }
399
- }
400
- }
401
- }
415
+ applyCodeMask(rawNetworks, codeMask, codes.length, nUnits, nConnections, ordered);
402
416
 
403
417
  return runPipeline(qe, rawNetworks, nUnits, nConnections, codes,
404
418
  unitLabels, metaData, rotMethod, groupA, groupB,
@@ -444,34 +458,7 @@ export default async function loadENA() {
444
458
  );
445
459
  const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
446
460
 
447
- // Apply code masking by zeroing out the masked connection columns across all units
448
- if (codeMask && codeMask.length === codes.length) {
449
- console.log('[rena-wasm] Applying code mask to accumulated networks...');
450
- if (ordered) {
451
- for (let j = 0; j < codes.length; j++) {
452
- for (let i = 0; i < codes.length; i++) {
453
- if (codeMask[j] && codeMask[j][i] === 0) {
454
- const k = i * codes.length + j;
455
- for (let u = 0; u < nUnits; u++) {
456
- networks[u * nConnections + k] = 0;
457
- }
458
- }
459
- }
460
- }
461
- } else {
462
- let k = 0;
463
- for (let col = 1; col < codes.length; col++) {
464
- for (let row = 0; row < col; row++) {
465
- if ((codeMask[row] && codeMask[row][col] === 0) || (codeMask[col] && codeMask[col][row] === 0)) {
466
- for (let u = 0; u < nUnits; u++) {
467
- networks[u * nConnections + k] = 0;
468
- }
469
- }
470
- k++;
471
- }
472
- }
473
- }
474
- }
461
+ applyCodeMask(networks, codeMask, codes.length, nUnits, nConnections, ordered);
475
462
 
476
463
  const connectionNames = qe.connection_names(codes);
477
464
  return {
@@ -676,12 +663,27 @@ export default async function loadENA() {
676
663
  * removed while keeping the model's goodness-of-fit within `threshold`
677
664
  * of the full model. Brute-force subset search matching R
678
665
  * PRIA::pria(): for k = 1..removeNum, over every k-subset of codes,
679
- * build the reduced model (fit with a codeMask that drops every
680
- * connection touching a removed code), gate on
681
- * min_d(gof_reduced[d] / gof_full[d]) >= threshold where gof is the
682
- * per-dimension ena_correlation(points, centroids), and keep the subset
683
- * with the MOST codes removed, then the highest reduced dim-1 variance.
666
+ * build the reduced model (drop every connection touching a removed
667
+ * code, as R's remove.codes.from.accum does), gate on min_d(gof_reduced[d] / gof_full[d]) >= threshold where
668
+ * gof is the per-dimension ena_correlation(points, centroids) on dims
669
+ * 1:2, then on point and retained-node correlations, and keep the
670
+ * subset with the MOST codes removed, then the highest reduced dim-1
671
+ * variance (= R's reduced.set$model$variance[1]).
672
+ *
673
+ * The full and reduced models are built from the same accumulation as
674
+ * fit() with the same options — weightModel, tensor and codeMask
675
+ * included — so PRIA scores the model the caller actually displays.
676
+ * The data are accumulated once; each candidate only drops the removed
677
+ * codes' connection columns and re-runs normalize → center → rotate →
678
+ * project.
684
679
  *
680
+ * @param {Object[]} rows
681
+ * @param {object} opts - fit() options (codes, units, conversations,
682
+ * window, binary, ordered, tensor, weightModel,
683
+ * codeMask, rotation, groupA, groupB, gParams)
684
+ * plus removeNum (default 3) and threshold
685
+ * (default 0.95). `dims` is ignored: scoring
686
+ * uses dims 1:2 and the variance uses all dims.
685
687
  * @returns {{ removed: string[], removedIndices: number[], k: number,
686
688
  * variance: (number|null) }}
687
689
  */
@@ -689,7 +691,8 @@ export default async function loadENA() {
689
691
  const {
690
692
  codes, units, conversations,
691
693
  window: windowSize = 4, binary = true, ordered = false,
692
- rotation = 'svd', dims = 2, gParams, groupA, groupB,
694
+ tensor: tensorDef, weightModel, codeMask,
695
+ rotation = 'svd', gParams, groupA, groupB,
693
696
  removeNum = 3, threshold = 0.95,
694
697
  } = opts;
695
698
  if (!codes?.length) throw new Error('opts.codes is required');
@@ -701,32 +704,79 @@ export default async function loadENA() {
701
704
  const empty = { removed: [], removedIndices: [], k: 0, variance: null };
702
705
  if (rn < 1) return empty;
703
706
 
704
- const gofDims = Math.min(dims, 2); // R scores on dims 1:2
705
- const baseOpts = { codes, units, conversations, window: windowSize,
706
- binary, ordered, rotation, dims, gParams, groupA, groupB };
707
+ // Accumulate once, exactly as fit() would for these options.
708
+ const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
709
+ unitOf, convoGroups, metaData } =
710
+ parseData(rows, codes, units, conversations);
711
+ const { networks: raw } = accumulateTensor(
712
+ qe, rows, codeMatrix, nRows, nCodes, nUnits,
713
+ unitOf, convoGroups, tensorDef ?? defaultTensor(windowSize),
714
+ ordered, binary, weightModelName(weightModel)
715
+ );
716
+ const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
717
+ applyCodeMask(raw, codeMask, nCodes, nUnits, nConnections, ordered);
718
+
719
+ // Build a model from the accumulation with `removedSet`'s codes
720
+ // DROPPED -- their connection columns removed and the code list
721
+ // shortened -- as R PRIA's remove.codes.from.accum() does. Zeroing
722
+ // the columns instead is not equivalent: libqe's GMR rotation is
723
+ // sensitive to all-zero columns. All dimensions are kept so
724
+ // model.variance is the full-spectrum ratio R uses; the gates read
725
+ // only dims 1:2.
726
+ const fitWithout = (removedSet) => {
727
+ let networks = raw, keptCodes = codes, nConn = nConnections;
728
+ if (removedSet) {
729
+ const keep = (c) => !removedSet.has(c);
730
+ const cols = [];
731
+ if (ordered) {
732
+ for (let i = 0; i < nCodes; i++)
733
+ for (let j = 0; j < nCodes; j++)
734
+ if (keep(i) && keep(j)) cols.push(i * nCodes + j);
735
+ } else {
736
+ let k = 0;
737
+ for (let col = 1; col < nCodes; col++)
738
+ for (let row = 0; row < col; row++, k++)
739
+ if (keep(row) && keep(col)) cols.push(k);
740
+ }
741
+ nConn = cols.length;
742
+ networks = new Float64Array(nUnits * nConn);
743
+ for (let u = 0; u < nUnits; u++)
744
+ for (let c = 0; c < nConn; c++)
745
+ networks[u * nConn + c] = raw[u * nConnections + cols[c]];
746
+ keptCodes = codes.filter((_, i) => keep(i));
747
+ }
748
+ // Ordered models are scored with directed node positions (R's
749
+ // ordered pipeline), independent of how fit() places nodes.
750
+ return runPipeline(qe, networks, nUnits, nConn, keptCodes,
751
+ unitLabels, metaData, rotation, groupA, groupB,
752
+ nConn, gParams, null, ordered);
753
+ };
707
754
 
708
- const D = gofDims; // score on the first 2 dims (R's get_pria_scores_2Ds)
709
- // Extract a D-column submatrix (given row indices) from a flat
710
- // row-major (nRows × dims) array.
711
- const sub = (flat, rowIdxs) => {
755
+ const D = 2; // score on the first 2 dims (R's get_pria_scores_2Ds)
756
+ // D-column submatrix (given row indices) of a model's flat
757
+ // row-major (nRows × model.dims) array.
758
+ const sub = (mdl, flat, rowIdxs) => {
712
759
  const out = new Float64Array(rowIdxs.length * D);
713
- rowIdxs.forEach((r, t) => { for (let d = 0; d < D; d++) out[t * D + d] = flat[r * dims + d]; });
760
+ rowIdxs.forEach((r, t) => {
761
+ for (let d = 0; d < D; d++) out[t * D + d] = flat[r * mdl.dims + d];
762
+ });
714
763
  return out;
715
764
  };
716
765
  // ena_correlation(A, B) → [r_dim0, r_dim1] (col 0 of the dims×3 result).
717
- const corr2 = (Aflat, Bflat, nRows) => {
718
- const r = qe.ena_correlation(Array.from(Aflat), nRows, D,
719
- Array.from(Bflat), nRows, D, 0.95);
766
+ const corr2 = (Aflat, Bflat, n) => {
767
+ const r = qe.ena_correlation(Array.from(Aflat), n, D,
768
+ Array.from(Bflat), n, D, 0.95);
720
769
  const out = [];
721
770
  for (let d = 0; d < D; d++) out.push(r.data[d * 3]);
722
771
  return out;
723
772
  };
724
- const full = api.fit(rows, baseOpts);
725
- const nUnits = full.nUnits;
773
+
774
+ const full = fitWithout(null);
726
775
  const unitRows = Array.from({ length: nUnits }, (_, i) => i);
727
- const gofOf = (mdl) => corr2(sub(mdl.points, unitRows), sub(mdl.model.centroids, unitRows), nUnits);
776
+ const gofOf = (mdl) => corr2(sub(mdl, mdl.points, unitRows),
777
+ sub(mdl, mdl.model.centroids, unitRows), nUnits);
728
778
  const gFull = gofOf(full);
729
- const fullPts2 = sub(full.points, unitRows);
779
+ const fullPts2 = sub(full, full.points, unitRows);
730
780
 
731
781
  // All k-subsets of [0..m) as index arrays.
732
782
  const combos = (n, k) => {
@@ -743,15 +793,7 @@ export default async function loadENA() {
743
793
  for (let k = 1; k <= rn; k++) {
744
794
  for (const idxs of combos(m, k)) {
745
795
  const removedSet = new Set(idxs);
746
- const mask = [];
747
- for (let i = 0; i < m; i++) {
748
- const row = [];
749
- for (let j = 0; j < m; j++) {
750
- row.push((removedSet.has(i) || removedSet.has(j)) ? 0 : 1);
751
- }
752
- mask.push(row);
753
- }
754
- const red = api.fit(rows, { ...baseOpts, codeMask: mask });
796
+ const red = fitWithout(removedSet);
755
797
 
756
798
  // Gate 1 — goodness-of-fit ratio vs full, per dim.
757
799
  const gRed = gofOf(red);
@@ -764,21 +806,22 @@ export default async function loadENA() {
764
806
  // Gate 2 — reduced points AND retained-code nodes must each
765
807
  // correlate >= threshold with the full model (per dim, with a
766
808
  // per-dim sign flip that negates the node corr alongside it).
809
+ // The reduced model's nodes are the retained codes only, in order.
767
810
  const retained = [];
768
811
  for (let i = 0; i < m; i++) if (!removedSet.has(i)) retained.push(i);
769
- let [pc1, pc2] = corr2(fullPts2, sub(red.points, unitRows), nUnits);
770
- let [nc1, nc2] = corr2(sub(full.rotation.nodes, retained),
771
- sub(red.rotation.nodes, retained), retained.length);
812
+ const reducedRows = retained.map((_, t) => t);
813
+ let [pc1, pc2] = corr2(fullPts2, sub(red, red.points, unitRows), nUnits);
814
+ let [nc1, nc2] = corr2(sub(full, full.rotation.nodes, retained),
815
+ sub(red, red.rotation.nodes, reducedRows), retained.length);
772
816
  if (pc1 < 0) { pc1 = -pc1; nc1 = -nc1; }
773
817
  if (pc2 < 0) { pc2 = -pc2; nc2 = -nc2; }
774
818
  if (Math.min(pc1, pc2, nc1, nc2) < threshold) continue;
775
819
 
776
- // Dim-1 variance proportion over the FULL rotation spectrum
777
- // (= R's set$model$variance[1] = diag(var(points.rotated))/sum);
778
- // NOT model.variance, which is the 2-dim projected ratio.
779
- const ev = red.rotation.eigenvalues;
780
- let evSum = 0; for (let i = 0; i < ev.length; i++) evSum += ev[i];
781
- const vr1 = evSum > 0 ? ev[0] / evSum : 0;
820
+ // Dim-1 share of the variance of the projected points across
821
+ // ALL dimensions (= R's reduced.set$model$variance[1]). Not the
822
+ // eigenvalue ratio: means and GMR rotations report a zero
823
+ // eigenvalue for their first axis.
824
+ const vr1 = red.model.variance[0];
782
825
  // Prefer more codes removed; tie-break on higher dim-1 variance.
783
826
  if (k > bestK || (k === bestK && vr1 > bestVar)) {
784
827
  bestK = k; bestVar = vr1; bestRemoved = idxs.slice();
package/src/pipeline.js CHANGED
@@ -301,14 +301,17 @@ export function project(centered, nUnits, nConnections, rotation, rotRows, rotCo
301
301
  /**
302
302
  * Compute code node positions via least-squares (LWS).
303
303
  *
304
+ * @param {boolean} [directed=false] Use libqe's directed_node_positions (for
305
+ * ordered n² networks), as rENA's optimize() does for ordered sets.
304
306
  * @returns {{ nodes: Float64Array, nodeRows: number, nodeCols: number,
305
307
  * centroids: Float64Array|null }}
306
308
  * nodes — code positions in ENA space (= R's rotation$nodes)
307
309
  * centroids — LWS unit centroid positions (= R's model$centroids), or null
308
310
  * if libqe does not expose them
309
311
  */
310
- export function nodePositions(qe, networks, nUnits, nConnections, points, nDims) {
311
- const r = qe.node_positions(networks, nUnits, nConnections, points, nUnits, nDims, nDims);
312
+ export function nodePositions(qe, networks, nUnits, nConnections, points, nDims, directed = false) {
313
+ const solve = directed ? qe.directed_node_positions : qe.node_positions;
314
+ const r = solve(networks, nUnits, nConnections, points, nUnits, nDims, nDims);
312
315
  return {
313
316
  nodes: new Float64Array(r.nodes.data),
314
317
  nodeRows: r.nodes.rows,