@qe-libs/rena-wasm 0.1.5 → 0.1.7

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.
@@ -116,7 +136,13 @@ columns in the data.
116
136
  // dims = [nRoleValues, 2] → Teacher uses window=4, Student uses window=2.
117
137
  const model = ena.fit(rows, {
118
138
  codes, units, conversations,
119
- ordered: true, // directed (n² connections); also works without a tensor
139
+ ordered: true, // directed (n² connections); also works without a tensor.
140
+ // Ordered models follow ona::model(): zero-network units are
141
+ // left out of the centring mean but shifted by it, nodes use
142
+ // directed positions, and points/nodes are centred on the origin.
143
+ // connectionNames / rotation.adjacencyKey list all n² directed
144
+ // connections as R names them: column j*n + i is
145
+ // "codes[i] & codes[j]", ground codes[i] → response codes[j].
120
146
  tensor: {
121
147
  dims: [2, 2], // [nRoleValues=2, weight/window=2]
122
148
  dimsSender: [0], // axis 0 is a sender factor
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.7",
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
@@ -90,19 +90,45 @@ function computeVariance(points, nUnits, dims) {
90
90
 
91
91
  /**
92
92
  * Build the adjacency key — list of [codeA, codeB] pairs for each connection.
93
- * Matches R's enadata$adjacency.matrix (column-major upper triangle order).
93
+ * Matches R's rotation$adjacency.key.
94
+ *
95
+ * Unordered: the upper triangle in column-major order (choose_two(n) pairs).
96
+ * Ordered: all n² directed connections in column-major order — column
97
+ * j*n + i is [codes[i], codes[j]], ground codes[i] → response codes[j]
98
+ * (as R names it "codes[i] & codes[j]").
94
99
  *
95
100
  * @param {string[]} codes
101
+ * @param {boolean} [ordered=false]
96
102
  * @returns {string[][]} length nConnections, each entry is [codeI, codeJ]
97
103
  */
98
- function buildAdjacencyKey(codes) {
104
+ function buildAdjacencyKey(codes, ordered = false) {
99
105
  const key = [];
106
+ if (ordered) {
107
+ for (let j = 0; j < codes.length; j++)
108
+ for (let i = 0; i < codes.length; i++)
109
+ key.push([codes[i], codes[j]]);
110
+ return key;
111
+ }
100
112
  for (let j = 1; j < codes.length; j++)
101
113
  for (let i = 0; i < j; i++)
102
114
  key.push([codes[i], codes[j]]);
103
115
  return key;
104
116
  }
105
117
 
118
+ /**
119
+ * Connection names, "codeA & codeB" per connection, in network column order
120
+ * (= R's colnames(set$connection.counts)).
121
+ *
122
+ * @param {object} qe
123
+ * @param {string[]} codes
124
+ * @param {boolean} [ordered=false]
125
+ * @returns {string[]}
126
+ */
127
+ function connectionNamesFor(qe, codes, ordered = false) {
128
+ if (!ordered) return qe.connection_names(codes);
129
+ return buildAdjacencyKey(codes, true).map(([a, b]) => `${a} & ${b}`);
130
+ }
131
+
106
132
  // ── ENA result object ─────────────────────────────────────────────────────────
107
133
 
108
134
  class ENAModel {
@@ -210,20 +236,58 @@ function weightModelName(name) {
210
236
  }
211
237
  }
212
238
 
239
+ // ── code masking ─────────────────────────────────────────────────────────────
240
+
241
+ /**
242
+ * Zero, in place, every connection column the code mask excludes.
243
+ * codeMask is an nCodes × nCodes 0/1 matrix; a 0 at [a][b] drops the
244
+ * connection between codes a and b (unordered: either orientation drops the
245
+ * pair; ordered: codeMask[j][i] === 0 drops the directed column i*n + j).
246
+ *
247
+ * @param {Float64Array} networks nUnits × nConnections, row-major
248
+ * @param {number[][]} codeMask
249
+ * @param {number} nCodes
250
+ * @param {number} nUnits
251
+ * @param {number} nConnections
252
+ * @param {boolean} ordered
253
+ */
254
+ function applyCodeMask(networks, codeMask, nCodes, nUnits, nConnections, ordered) {
255
+ if (!codeMask || codeMask.length !== nCodes) return;
256
+ const zeroColumn = (k) => {
257
+ for (let u = 0; u < nUnits; u++) networks[u * nConnections + k] = 0;
258
+ };
259
+ if (ordered) {
260
+ for (let j = 0; j < nCodes; j++)
261
+ for (let i = 0; i < nCodes; i++)
262
+ if (codeMask[j] && codeMask[j][i] === 0) zeroColumn(i * nCodes + j);
263
+ return;
264
+ }
265
+ let k = 0;
266
+ for (let col = 1; col < nCodes; col++) {
267
+ for (let row = 0; row < col; row++) {
268
+ if ((codeMask[row] && codeMask[row][col] === 0) ||
269
+ (codeMask[col] && codeMask[col][row] === 0)) zeroColumn(k);
270
+ k++;
271
+ }
272
+ }
273
+ }
274
+
213
275
  // ── shared pipeline (post-accumulation) ──────────────────────────────────────
214
276
 
215
277
  function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
216
278
  metaData, rotMethod, groupA, groupB, dims, gParams,
217
- rowConnectionCounts = null) {
218
- const connectionNames = qe.connection_names(codes);
279
+ rowConnectionCounts = null, ordered = false) {
280
+ const connectionNames = connectionNamesFor(qe, codes, ordered);
219
281
 
220
282
  // Sphere norm → lineWeights (= R's set$line.weights)
221
283
  const lineWeights = sphereNorm(qe, rawNetworks, nUnits, nConnections);
222
284
 
223
285
  // Center → pointsForProjection (= R's model$points.for.projection)
224
286
  // centerVec (= R's rotation$center.vec)
287
+ // Ordered (ONA) models follow ona::model(): zero-network units are left
288
+ // out of the mean but still shifted by it.
225
289
  const { centered: pointsForProjection, centerVec } =
226
- center(qe, lineWeights, nUnits, nConnections);
290
+ center(qe, lineWeights, nUnits, nConnections, ordered);
227
291
 
228
292
  // Rotate
229
293
  let rot;
@@ -241,6 +305,9 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
241
305
  rot = rotateSVD(qe, pointsForProjection, nUnits, nConnections);
242
306
  }
243
307
 
308
+ // Never ask for more dimensions than the rotation has columns.
309
+ dims = Math.min(dims, rot.rotCols);
310
+
244
311
  // Truncate rotation matrix to dims columns (= R's set$rotation.matrix)
245
312
  const rotationMatrix = new Float64Array(rot.rotRows * dims);
246
313
  for (let r = 0; r < rot.rotRows; r++)
@@ -259,15 +326,30 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
259
326
  // Matches rENA's lws.positions.sq, which regresses the projected points onto
260
327
  // the SPHERE-normed line weights (enaset$line.weights) — not the centered
261
328
  // networks. Verified node-for-node against R rENA on rs.data.new.csv.
329
+ // Ordered (ONA) models use libqe's directed_node_positions, as
330
+ // ona::model() does (via rENA's optimize()).
262
331
  const { nodes, centroids } = nodePositions(
263
- qe, lineWeights, nUnits, nConnections, points, dims
332
+ qe, lineWeights, nUnits, nConnections, points, dims, ordered
264
333
  );
265
334
 
335
+ // ONA: translate points and nodes so the points' mean is at the origin
336
+ // (ona::model's center_to_origin; centroids are left as computed, as in R).
337
+ if (ordered) {
338
+ const nCodes = nodes.length / dims;
339
+ for (let d = 0; d < dims; d++) {
340
+ let mean = 0;
341
+ for (let u = 0; u < nUnits; u++) mean += points[u * dims + d];
342
+ mean /= nUnits;
343
+ for (let u = 0; u < nUnits; u++) points[u * dims + d] -= mean;
344
+ for (let c = 0; c < nCodes; c++) nodes[c * dims + d] -= mean;
345
+ }
346
+ }
347
+
266
348
  // Variance explained (= R's model$variance)
267
349
  const variance = computeVariance(points, nUnits, dims);
268
350
 
269
351
  // Adjacency key (= R's rotation$adjacency.key)
270
- const adjacencyKey = buildAdjacencyKey(codes);
352
+ const adjacencyKey = buildAdjacencyKey(codes, ordered);
271
353
 
272
354
  return new ENAModel({
273
355
  // top-level
@@ -371,38 +453,11 @@ export default async function loadENA() {
371
453
  );
372
454
  const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
373
455
 
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
- }
456
+ applyCodeMask(rawNetworks, codeMask, codes.length, nUnits, nConnections, ordered);
402
457
 
403
458
  return runPipeline(qe, rawNetworks, nUnits, nConnections, codes,
404
459
  unitLabels, metaData, rotMethod, groupA, groupB,
405
- dims, gParams, rowConnectionCounts);
460
+ dims, gParams, rowConnectionCounts, ordered);
406
461
  },
407
462
 
408
463
  /**
@@ -444,36 +499,9 @@ export default async function loadENA() {
444
499
  );
445
500
  const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
446
501
 
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
- }
502
+ applyCodeMask(networks, codeMask, codes.length, nUnits, nConnections, ordered);
475
503
 
476
- const connectionNames = qe.connection_names(codes);
504
+ const connectionNames = connectionNamesFor(qe, codes, ordered);
477
505
  return {
478
506
  connectionCounts: networks, rowConnectionCounts, unitLabels, connectionNames,
479
507
  metaData, nUnits, nConnections,
@@ -538,7 +566,8 @@ export default async function loadENA() {
538
566
  );
539
567
  const model = runPipeline(
540
568
  qe, raw, nUnits, nConnections, codes,
541
- unitLabels, metaData, 'svd', undefined, undefined, dims
569
+ unitLabels, metaData, 'svd', undefined, undefined, dims,
570
+ undefined, null, ordered
542
571
  );
543
572
  allPoints.push(model.points);
544
573
  }
@@ -676,12 +705,27 @@ export default async function loadENA() {
676
705
  * removed while keeping the model's goodness-of-fit within `threshold`
677
706
  * of the full model. Brute-force subset search matching R
678
707
  * 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.
708
+ * build the reduced model (drop every connection touching a removed
709
+ * code, as R's remove.codes.from.accum does), gate on min_d(gof_reduced[d] / gof_full[d]) >= threshold where
710
+ * gof is the per-dimension ena_correlation(points, centroids) on dims
711
+ * 1:2, then on point and retained-node correlations, and keep the
712
+ * subset with the MOST codes removed, then the highest reduced dim-1
713
+ * variance (= R's reduced.set$model$variance[1]).
714
+ *
715
+ * The full and reduced models are built from the same accumulation as
716
+ * fit() with the same options — weightModel, tensor and codeMask
717
+ * included — so PRIA scores the model the caller actually displays.
718
+ * The data are accumulated once; each candidate only drops the removed
719
+ * codes' connection columns and re-runs normalize → center → rotate →
720
+ * project.
684
721
  *
722
+ * @param {Object[]} rows
723
+ * @param {object} opts - fit() options (codes, units, conversations,
724
+ * window, binary, ordered, tensor, weightModel,
725
+ * codeMask, rotation, groupA, groupB, gParams)
726
+ * plus removeNum (default 3) and threshold
727
+ * (default 0.95). `dims` is ignored: scoring
728
+ * uses dims 1:2 and the variance uses all dims.
685
729
  * @returns {{ removed: string[], removedIndices: number[], k: number,
686
730
  * variance: (number|null) }}
687
731
  */
@@ -689,7 +733,8 @@ export default async function loadENA() {
689
733
  const {
690
734
  codes, units, conversations,
691
735
  window: windowSize = 4, binary = true, ordered = false,
692
- rotation = 'svd', dims = 2, gParams, groupA, groupB,
736
+ tensor: tensorDef, weightModel, codeMask,
737
+ rotation = 'svd', gParams, groupA, groupB,
693
738
  removeNum = 3, threshold = 0.95,
694
739
  } = opts;
695
740
  if (!codes?.length) throw new Error('opts.codes is required');
@@ -701,32 +746,77 @@ export default async function loadENA() {
701
746
  const empty = { removed: [], removedIndices: [], k: 0, variance: null };
702
747
  if (rn < 1) return empty;
703
748
 
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 };
749
+ // Accumulate once, exactly as fit() would for these options.
750
+ const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
751
+ unitOf, convoGroups, metaData } =
752
+ parseData(rows, codes, units, conversations);
753
+ const { networks: raw } = accumulateTensor(
754
+ qe, rows, codeMatrix, nRows, nCodes, nUnits,
755
+ unitOf, convoGroups, tensorDef ?? defaultTensor(windowSize),
756
+ ordered, binary, weightModelName(weightModel)
757
+ );
758
+ const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
759
+ applyCodeMask(raw, codeMask, nCodes, nUnits, nConnections, ordered);
760
+
761
+ // Build a model from the accumulation with `removedSet`'s codes
762
+ // DROPPED -- their connection columns removed and the code list
763
+ // shortened -- as R PRIA's remove.codes.from.accum() does. Zeroing
764
+ // the columns instead is not equivalent: libqe's GMR rotation is
765
+ // sensitive to all-zero columns. All dimensions are kept so
766
+ // model.variance is the full-spectrum ratio R uses; the gates read
767
+ // only dims 1:2.
768
+ const fitWithout = (removedSet) => {
769
+ let networks = raw, keptCodes = codes, nConn = nConnections;
770
+ if (removedSet) {
771
+ const keep = (c) => !removedSet.has(c);
772
+ const cols = [];
773
+ if (ordered) {
774
+ for (let i = 0; i < nCodes; i++)
775
+ for (let j = 0; j < nCodes; j++)
776
+ if (keep(i) && keep(j)) cols.push(i * nCodes + j);
777
+ } else {
778
+ let k = 0;
779
+ for (let col = 1; col < nCodes; col++)
780
+ for (let row = 0; row < col; row++, k++)
781
+ if (keep(row) && keep(col)) cols.push(k);
782
+ }
783
+ nConn = cols.length;
784
+ networks = new Float64Array(nUnits * nConn);
785
+ for (let u = 0; u < nUnits; u++)
786
+ for (let c = 0; c < nConn; c++)
787
+ networks[u * nConn + c] = raw[u * nConnections + cols[c]];
788
+ keptCodes = codes.filter((_, i) => keep(i));
789
+ }
790
+ return runPipeline(qe, networks, nUnits, nConn, keptCodes,
791
+ unitLabels, metaData, rotation, groupA, groupB,
792
+ nConn, gParams, null, ordered);
793
+ };
707
794
 
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) => {
795
+ const D = 2; // score on the first 2 dims (R's get_pria_scores_2Ds)
796
+ // D-column submatrix (given row indices) of a model's flat
797
+ // row-major (nRows × model.dims) array.
798
+ const sub = (mdl, flat, rowIdxs) => {
712
799
  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]; });
800
+ rowIdxs.forEach((r, t) => {
801
+ for (let d = 0; d < D; d++) out[t * D + d] = flat[r * mdl.dims + d];
802
+ });
714
803
  return out;
715
804
  };
716
805
  // 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);
806
+ const corr2 = (Aflat, Bflat, n) => {
807
+ const r = qe.ena_correlation(Array.from(Aflat), n, D,
808
+ Array.from(Bflat), n, D, 0.95);
720
809
  const out = [];
721
810
  for (let d = 0; d < D; d++) out.push(r.data[d * 3]);
722
811
  return out;
723
812
  };
724
- const full = api.fit(rows, baseOpts);
725
- const nUnits = full.nUnits;
813
+
814
+ const full = fitWithout(null);
726
815
  const unitRows = Array.from({ length: nUnits }, (_, i) => i);
727
- const gofOf = (mdl) => corr2(sub(mdl.points, unitRows), sub(mdl.model.centroids, unitRows), nUnits);
816
+ const gofOf = (mdl) => corr2(sub(mdl, mdl.points, unitRows),
817
+ sub(mdl, mdl.model.centroids, unitRows), nUnits);
728
818
  const gFull = gofOf(full);
729
- const fullPts2 = sub(full.points, unitRows);
819
+ const fullPts2 = sub(full, full.points, unitRows);
730
820
 
731
821
  // All k-subsets of [0..m) as index arrays.
732
822
  const combos = (n, k) => {
@@ -743,15 +833,7 @@ export default async function loadENA() {
743
833
  for (let k = 1; k <= rn; k++) {
744
834
  for (const idxs of combos(m, k)) {
745
835
  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 });
836
+ const red = fitWithout(removedSet);
755
837
 
756
838
  // Gate 1 — goodness-of-fit ratio vs full, per dim.
757
839
  const gRed = gofOf(red);
@@ -764,21 +846,22 @@ export default async function loadENA() {
764
846
  // Gate 2 — reduced points AND retained-code nodes must each
765
847
  // correlate >= threshold with the full model (per dim, with a
766
848
  // per-dim sign flip that negates the node corr alongside it).
849
+ // The reduced model's nodes are the retained codes only, in order.
767
850
  const retained = [];
768
851
  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);
852
+ const reducedRows = retained.map((_, t) => t);
853
+ let [pc1, pc2] = corr2(fullPts2, sub(red, red.points, unitRows), nUnits);
854
+ let [nc1, nc2] = corr2(sub(full, full.rotation.nodes, retained),
855
+ sub(red, red.rotation.nodes, reducedRows), retained.length);
772
856
  if (pc1 < 0) { pc1 = -pc1; nc1 = -nc1; }
773
857
  if (pc2 < 0) { pc2 = -pc2; nc2 = -nc2; }
774
858
  if (Math.min(pc1, pc2, nc1, nc2) < threshold) continue;
775
859
 
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;
860
+ // Dim-1 share of the variance of the projected points across
861
+ // ALL dimensions (= R's reduced.set$model$variance[1]). Not the
862
+ // eigenvalue ratio: means and GMR rotations report a zero
863
+ // eigenvalue for their first axis.
864
+ const vr1 = red.model.variance[0];
782
865
  // Prefer more codes removed; tie-break on higher dim-1 variance.
783
866
  if (k > bestK || (k === bestK && vr1 > bestVar)) {
784
867
  bestK = k; bestVar = vr1; bestRemoved = idxs.slice();
package/src/pipeline.js CHANGED
@@ -133,11 +133,15 @@ export function sphereNorm(qe, networks, nUnits, nConnections) {
133
133
  * Subtract column means (center the network space).
134
134
  * Excludes all-zero rows from the mean calculation (zero-network exclusion).
135
135
  *
136
+ * @param {boolean} [shiftZeroRows=false] Also subtract the mean from all-zero
137
+ * rows, as rENA's center(exclude_zero_networks = TRUE) does -- the ONA
138
+ * convention (ona::model). By default zero-network rows stay at zero, as in
139
+ * rENA's ena.make.set().
136
140
  * @returns {{ centered: Float64Array, centerVec: Float64Array }}
137
- * centered — mean-subtracted networks (zero-network rows left at zero)
141
+ * centered — mean-subtracted networks
138
142
  * centerVec — the column means used for centering (= R's rotation$center.vec)
139
143
  */
140
- export function center(qe, networks, nUnits, nConnections) {
144
+ export function center(qe, networks, nUnits, nConnections, shiftZeroRows = false) {
141
145
  // Identify non-zero rows
142
146
  const active = [];
143
147
  for (let u = 0; u < nUnits; u++) {
@@ -160,7 +164,7 @@ export function center(qe, networks, nUnits, nConnections) {
160
164
  const activeSet = new Set(active);
161
165
  const centered = new Float64Array(networks.length);
162
166
  for (let u = 0; u < nUnits; u++) {
163
- if (!activeSet.has(u)) continue; // leave zero-network row as zero
167
+ if (!shiftZeroRows && !activeSet.has(u)) continue; // leave zero-network row as zero
164
168
  for (let c = 0; c < nConnections; c++) {
165
169
  centered[u * nConnections + c] = networks[u * nConnections + c] - means[c];
166
170
  }
@@ -301,14 +305,17 @@ export function project(centered, nUnits, nConnections, rotation, rotRows, rotCo
301
305
  /**
302
306
  * Compute code node positions via least-squares (LWS).
303
307
  *
308
+ * @param {boolean} [directed=false] Use libqe's directed_node_positions (for
309
+ * ordered n² networks), as rENA's optimize() does for ordered sets.
304
310
  * @returns {{ nodes: Float64Array, nodeRows: number, nodeCols: number,
305
311
  * centroids: Float64Array|null }}
306
312
  * nodes — code positions in ENA space (= R's rotation$nodes)
307
313
  * centroids — LWS unit centroid positions (= R's model$centroids), or null
308
314
  * if libqe does not expose them
309
315
  */
310
- export function nodePositions(qe, networks, nUnits, nConnections, points, nDims) {
311
- const r = qe.node_positions(networks, nUnits, nConnections, points, nUnits, nDims, nDims);
316
+ export function nodePositions(qe, networks, nUnits, nConnections, points, nDims, directed = false) {
317
+ const solve = directed ? qe.directed_node_positions : qe.node_positions;
318
+ const r = solve(networks, nUnits, nConnections, points, nUnits, nDims, nDims);
312
319
  return {
313
320
  nodes: new Float64Array(r.nodes.data),
314
321
  nodeRows: r.nodes.rows,