@qe-libs/rena-wasm 0.1.4 → 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
@@ -34,8 +34,8 @@ const model = ena.fit(rows, {
34
34
 
35
35
  model.model.centroids // Float64Array nUnits × dims
36
36
  model.lineWeights // Float64Array nUnits × nConnections (normed)
37
- model.connectionCounts // Float64Array nUnits × nConnections (raw unit counts)
38
- model.rowConnectionCounts // Float64Array nRows × nConnections (raw row counts)
37
+ model.connectionCounts // Float64Array nUnits × nConnections (unit counts, before normalisation)
38
+ model.rowConnectionCounts // Float64Array nRows × nConnections (each row's counts after binarize / weight; rows sum to connectionCounts)
39
39
  model.rotation.nodes // Float64Array nCodes × dims
40
40
  model.connectionNames // ['Data & Technical.Constraints', ...]
41
41
  model.model.unitLabels // ['UserName1_ConditionA', ...]
@@ -67,6 +67,50 @@ const model = ena.fit(rows, {
67
67
 
68
68
  ---
69
69
 
70
+ ## Weight Models
71
+
72
+ `weightModel` (= R's `weight.by` / tma's `weight_by`) is applied to each line's
73
+ co-occurrence counts **before** they are summed into the unit network, by
74
+ libqe's shared `finalize_row_connections` kernel — the same stage and results
75
+ as rENA's `ena.accumulate.data()` and `tma::accumulate()`.
76
+
77
+ | `weightModel` | Per line |
78
+ |---|---|
79
+ | — (default) | binary: each positive count becomes 1 (`binary: false` keeps raw counts) |
80
+ | `'product'` | the raw, non-binarized counts |
81
+ | `'sqrt'` | square root of each line's count |
82
+ | `'log'` (alias `'log1p'`) | `log(1 + x)` of each line's count |
83
+
84
+ For ordered (directed) networks the weight is applied to each directed cell,
85
+ and the default keeps the raw directed counts. `weightModel` works in `fit()`,
86
+ `accumulate()` and `tuneWindowSize()`.
87
+
88
+ ```js
89
+ const model = ena.fit(rows, { codes, units, conversations, window: 4, weightModel: 'sqrt' });
90
+ ```
91
+
92
+ ---
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
+
70
114
  ## Accumulation Only
71
115
 
72
116
  Returns raw (un-normalised) network vectors without running the full pipeline.
@@ -92,7 +136,7 @@ columns in the data.
92
136
  // dims = [nRoleValues, 2] → Teacher uses window=4, Student uses window=2.
93
137
  const model = ena.fit(rows, {
94
138
  codes, units, conversations,
95
- ordered: true, // directed (n² connections) when using tensors
139
+ ordered: true, // directed (n² connections); also works without a tensor
96
140
  tensor: {
97
141
  dims: [2, 2], // [nRoleValues=2, weight/window=2]
98
142
  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.4",
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",
@@ -31,7 +31,7 @@
31
31
  "resolver": "<rootDir>/jest-resolver.cjs"
32
32
  },
33
33
  "dependencies": {
34
- "@qe-libs/libqe-wasm": "^0.1.4"
34
+ "@qe-libs/libqe-wasm": "^0.1.5"
35
35
  },
36
36
  "devDependencies": {
37
37
  "jest": "^29.0.0"
package/src/index.js CHANGED
@@ -190,30 +190,67 @@ class ENAModel {
190
190
  // ── weight models (= R's `weight.by`) ────────────────────────────────────────
191
191
 
192
192
  /**
193
- * Map a weight-model name to its element-wise transform, matching the webtool's
194
- * server.js mapping to R weight.by functions:
195
- * 'sqrt' → Math.sqrt (R "sqrt")
196
- * 'log' → Math.log1p (R "log1p", i.e. log(x+1); guards log(0))
197
- * 'product' → identity (raw non-binary counts; R's "product" string
198
- * is not a function, so no transform)
199
- * A falsy value or 'binary' returns null (binary accumulation, no transform).
193
+ * Map a rena-wasm weight-model name to the libqe weight model applied per line
194
+ * (before the per-unit sum) by libqe's finalize_row_connections:
195
+ * 'sqrt' → 'sqrt' (R weight.by = sqrt)
196
+ * 'log' | 'log1p' → 'log1p' (log(x+1); guards log(0))
197
+ * 'product' → 'product' (the raw, non-binarized line counts)
198
+ * A falsy value, 'binary' or an unknown name returns null (binary / `binary`
199
+ * flag accumulation, no weight model).
200
200
  * @param {string|false|undefined} name
201
- * @returns {((x:number)=>number)|null}
201
+ * @returns {'sqrt'|'log1p'|'product'|null}
202
202
  */
203
- function weightModelTransform(name) {
203
+ function weightModelName(name) {
204
204
  switch (name) {
205
- case 'sqrt': return Math.sqrt;
206
- case 'log': return Math.log1p;
207
- case 'product': return (x) => x;
205
+ case 'sqrt': return 'sqrt';
206
+ case 'log':
207
+ case 'log1p': return 'log1p';
208
+ case 'product': return 'product';
208
209
  default: return null; // binary / off / unknown
209
210
  }
210
211
  }
211
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
+
212
249
  // ── shared pipeline (post-accumulation) ──────────────────────────────────────
213
250
 
214
251
  function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
215
252
  metaData, rotMethod, groupA, groupB, dims, gParams,
216
- rowConnectionCounts = null) {
253
+ rowConnectionCounts = null, directedNodes = false) {
217
254
  const connectionNames = qe.connection_names(codes);
218
255
 
219
256
  // Sphere norm → lineWeights (= R's set$line.weights)
@@ -240,6 +277,9 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
240
277
  rot = rotateSVD(qe, pointsForProjection, nUnits, nConnections);
241
278
  }
242
279
 
280
+ // Never ask for more dimensions than the rotation has columns.
281
+ dims = Math.min(dims, rot.rotCols);
282
+
243
283
  // Truncate rotation matrix to dims columns (= R's set$rotation.matrix)
244
284
  const rotationMatrix = new Float64Array(rot.rotRows * dims);
245
285
  for (let r = 0; r < rot.rotRows; r++)
@@ -258,8 +298,10 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
258
298
  // Matches rENA's lws.positions.sq, which regresses the projected points onto
259
299
  // the SPHERE-normed line weights (enaset$line.weights) — not the centered
260
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.
261
303
  const { nodes, centroids } = nodePositions(
262
- qe, lineWeights, nUnits, nConnections, points, dims
304
+ qe, lineWeights, nUnits, nConnections, points, dims, directedNodes
263
305
  );
264
306
 
265
307
  // Variance explained (= R's model$variance)
@@ -318,7 +360,7 @@ export default async function loadENA() {
318
360
  * @param {boolean} [opts.binary=true] - Binarise each line's co-occurrences (unordered; ignored when weightModel is set)
319
361
  * @param {boolean} [opts.ordered=false] - Directed networks
320
362
  * @param {object} [opts.tensor] - Context tensor definition (overrides window)
321
- * @param {string} [opts.weightModel] - 'product' | 'sqrt' | 'log' (per-line, before the unit sum)
363
+ * @param {string} [opts.weightModel] - 'product' | 'sqrt' | 'log' (alias 'log1p'); per line, before the unit sum
322
364
  * @param {string} [opts.rotation='svd'] - 'svd', 'mean', or 'generalized'
323
365
  * @param {number[]} [opts.groupA] - Unit indices for means rotation group A
324
366
  * @param {number[]} [opts.groupB] - Unit indices for means rotation group B
@@ -353,12 +395,9 @@ export default async function loadENA() {
353
395
  // "product" co-occurrence counts) BEFORE summing per unit, mirroring
354
396
  // accumulate.data.R (lapply(.SD, weight.by) over the per-line
355
397
  // co-occurrence table, then per-unit aggregation). Because
356
- // sqrt(Σ) ≠ Σsqrt, the transform is applied to each row's counts
357
- // inside accumulateTensor, not to the unit-summed network.
358
- // `product` is the non-binarized row counts themselves (identity);
359
- // any weight model therefore forces non-binary accumulation.
360
- const weightFn = weightModelTransform(weightModel);
361
- const effBinary = weightFn ? false : binary;
398
+ // sqrt(Σ) ≠ Σsqrt, libqe applies it to each row's counts inside
399
+ // accumulateTensor, not to the unit-summed network.
400
+ const weight = weightModelName(weightModel);
362
401
 
363
402
  const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
364
403
  unitOf, convoGroups, metaData } =
@@ -369,38 +408,11 @@ export default async function loadENA() {
369
408
  const { networks: rawNetworks, rowConnectionCounts } = accumulateTensor(
370
409
  qe, rows, codeMatrix, nRows, nCodes, nUnits,
371
410
  unitOf, convoGroups, tensorDef ?? defaultTensor(windowSize),
372
- ordered, effBinary, weightFn
411
+ ordered, binary, weight
373
412
  );
374
413
  const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
375
414
 
376
- // Apply code masking by zeroing out the masked connection columns across all units
377
- if (codeMask && codeMask.length === codes.length) {
378
- console.log('[rena-wasm] Applying code mask to raw networks...');
379
- if (ordered) {
380
- for (let j = 0; j < codes.length; j++) {
381
- for (let i = 0; i < codes.length; i++) {
382
- if (codeMask[j] && codeMask[j][i] === 0) {
383
- const k = i * codes.length + j;
384
- for (let u = 0; u < nUnits; u++) {
385
- rawNetworks[u * nConnections + k] = 0;
386
- }
387
- }
388
- }
389
- }
390
- } else {
391
- let k = 0;
392
- for (let col = 1; col < codes.length; col++) {
393
- for (let row = 0; row < col; row++) {
394
- if ((codeMask[row] && codeMask[row][col] === 0) || (codeMask[col] && codeMask[col][row] === 0)) {
395
- for (let u = 0; u < nUnits; u++) {
396
- rawNetworks[u * nConnections + k] = 0;
397
- }
398
- }
399
- k++;
400
- }
401
- }
402
- }
403
- }
415
+ applyCodeMask(rawNetworks, codeMask, codes.length, nUnits, nConnections, ordered);
404
416
 
405
417
  return runPipeline(qe, rawNetworks, nUnits, nConnections, codes,
406
418
  unitLabels, metaData, rotMethod, groupA, groupB,
@@ -412,7 +424,7 @@ export default async function loadENA() {
412
424
  * Returns raw (un-normalised) network vectors.
413
425
  *
414
426
  * @param {Object[]} rows
415
- * @param {object} opts - codes, units, conversations, window, binary, ordered, tensor
427
+ * @param {object} opts - codes, units, conversations, window, binary, ordered, tensor, weightModel
416
428
  * @returns {{
417
429
  * connectionCounts: Float64Array,
418
430
  * rowConnectionCounts: Float64Array | null,
@@ -432,6 +444,7 @@ export default async function loadENA() {
432
444
  ordered = false,
433
445
  tensor: tensorDef,
434
446
  codeMask,
447
+ weightModel,
435
448
  } = opts;
436
449
 
437
450
  const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
@@ -441,38 +454,11 @@ export default async function loadENA() {
441
454
  const { networks, rowConnectionCounts } = accumulateTensor(
442
455
  qe, rows, codeMatrix, nRows, nCodes, nUnits,
443
456
  unitOf, convoGroups, tensorDef ?? defaultTensor(windowSize),
444
- ordered, binary
457
+ ordered, binary, weightModelName(weightModel)
445
458
  );
446
459
  const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
447
460
 
448
- // Apply code masking by zeroing out the masked connection columns across all units
449
- if (codeMask && codeMask.length === codes.length) {
450
- console.log('[rena-wasm] Applying code mask to accumulated networks...');
451
- if (ordered) {
452
- for (let j = 0; j < codes.length; j++) {
453
- for (let i = 0; i < codes.length; i++) {
454
- if (codeMask[j] && codeMask[j][i] === 0) {
455
- const k = i * codes.length + j;
456
- for (let u = 0; u < nUnits; u++) {
457
- networks[u * nConnections + k] = 0;
458
- }
459
- }
460
- }
461
- }
462
- } else {
463
- let k = 0;
464
- for (let col = 1; col < codes.length; col++) {
465
- for (let row = 0; row < col; row++) {
466
- if ((codeMask[row] && codeMask[row][col] === 0) || (codeMask[col] && codeMask[col][row] === 0)) {
467
- for (let u = 0; u < nUnits; u++) {
468
- networks[u * nConnections + k] = 0;
469
- }
470
- }
471
- k++;
472
- }
473
- }
474
- }
475
- }
461
+ applyCodeMask(networks, codeMask, codes.length, nUnits, nConnections, ordered);
476
462
 
477
463
  const connectionNames = qe.connection_names(codes);
478
464
  return {
@@ -481,7 +467,8 @@ export default async function loadENA() {
481
467
  // Retained so tuneWindowSize() can rebuild at other window sizes
482
468
  // (= R's ENAAccumulation$`_function.call`).
483
469
  _call: { rows, codes, units, conversations,
484
- window: windowSize, binary, ordered, tensor: tensorDef },
470
+ window: windowSize, binary, ordered, tensor: tensorDef,
471
+ weightModel },
485
472
  };
486
473
  },
487
474
 
@@ -519,7 +506,7 @@ export default async function loadENA() {
519
506
  'maxSize must be greater than minSize to compare windows.'
520
507
  );
521
508
 
522
- const { rows, codes, units, conversations, binary, ordered } = call;
509
+ const { rows, codes, units, conversations, binary, ordered, weightModel } = call;
523
510
 
524
511
  // Parse once; only the window size changes between iterations.
525
512
  const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
@@ -533,7 +520,8 @@ export default async function loadENA() {
533
520
  for (const w of windowRange) {
534
521
  const { networks: raw } = accumulateTensor(
535
522
  qe, rows, codeMatrix, nRows, nCodes, nUnits,
536
- unitOf, convoGroups, defaultTensor(w), ordered, binary
523
+ unitOf, convoGroups, defaultTensor(w), ordered, binary,
524
+ weightModelName(weightModel)
537
525
  );
538
526
  const model = runPipeline(
539
527
  qe, raw, nUnits, nConnections, codes,
@@ -560,6 +548,7 @@ export default async function loadENA() {
560
548
  // 4. Rebuild the accumulation at the selected window size.
561
549
  return api.accumulate(rows, {
562
550
  codes, units, conversations, window: bestWindow, binary, ordered,
551
+ weightModel,
563
552
  });
564
553
  },
565
554
 
@@ -674,12 +663,27 @@ export default async function loadENA() {
674
663
  * removed while keeping the model's goodness-of-fit within `threshold`
675
664
  * of the full model. Brute-force subset search matching R
676
665
  * PRIA::pria(): for k = 1..removeNum, over every k-subset of codes,
677
- * build the reduced model (fit with a codeMask that drops every
678
- * connection touching a removed code), gate on
679
- * min_d(gof_reduced[d] / gof_full[d]) >= threshold where gof is the
680
- * per-dimension ena_correlation(points, centroids), and keep the subset
681
- * 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.
682
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.
683
687
  * @returns {{ removed: string[], removedIndices: number[], k: number,
684
688
  * variance: (number|null) }}
685
689
  */
@@ -687,7 +691,8 @@ export default async function loadENA() {
687
691
  const {
688
692
  codes, units, conversations,
689
693
  window: windowSize = 4, binary = true, ordered = false,
690
- rotation = 'svd', dims = 2, gParams, groupA, groupB,
694
+ tensor: tensorDef, weightModel, codeMask,
695
+ rotation = 'svd', gParams, groupA, groupB,
691
696
  removeNum = 3, threshold = 0.95,
692
697
  } = opts;
693
698
  if (!codes?.length) throw new Error('opts.codes is required');
@@ -699,32 +704,79 @@ export default async function loadENA() {
699
704
  const empty = { removed: [], removedIndices: [], k: 0, variance: null };
700
705
  if (rn < 1) return empty;
701
706
 
702
- const gofDims = Math.min(dims, 2); // R scores on dims 1:2
703
- const baseOpts = { codes, units, conversations, window: windowSize,
704
- 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
+ };
705
754
 
706
- const D = gofDims; // score on the first 2 dims (R's get_pria_scores_2Ds)
707
- // Extract a D-column submatrix (given row indices) from a flat
708
- // row-major (nRows × dims) array.
709
- 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) => {
710
759
  const out = new Float64Array(rowIdxs.length * D);
711
- 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
+ });
712
763
  return out;
713
764
  };
714
765
  // ena_correlation(A, B) → [r_dim0, r_dim1] (col 0 of the dims×3 result).
715
- const corr2 = (Aflat, Bflat, nRows) => {
716
- const r = qe.ena_correlation(Array.from(Aflat), nRows, D,
717
- 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);
718
769
  const out = [];
719
770
  for (let d = 0; d < D; d++) out.push(r.data[d * 3]);
720
771
  return out;
721
772
  };
722
- const full = api.fit(rows, baseOpts);
723
- const nUnits = full.nUnits;
773
+
774
+ const full = fitWithout(null);
724
775
  const unitRows = Array.from({ length: nUnits }, (_, i) => i);
725
- 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);
726
778
  const gFull = gofOf(full);
727
- const fullPts2 = sub(full.points, unitRows);
779
+ const fullPts2 = sub(full, full.points, unitRows);
728
780
 
729
781
  // All k-subsets of [0..m) as index arrays.
730
782
  const combos = (n, k) => {
@@ -741,15 +793,7 @@ export default async function loadENA() {
741
793
  for (let k = 1; k <= rn; k++) {
742
794
  for (const idxs of combos(m, k)) {
743
795
  const removedSet = new Set(idxs);
744
- const mask = [];
745
- for (let i = 0; i < m; i++) {
746
- const row = [];
747
- for (let j = 0; j < m; j++) {
748
- row.push((removedSet.has(i) || removedSet.has(j)) ? 0 : 1);
749
- }
750
- mask.push(row);
751
- }
752
- const red = api.fit(rows, { ...baseOpts, codeMask: mask });
796
+ const red = fitWithout(removedSet);
753
797
 
754
798
  // Gate 1 — goodness-of-fit ratio vs full, per dim.
755
799
  const gRed = gofOf(red);
@@ -762,21 +806,22 @@ export default async function loadENA() {
762
806
  // Gate 2 — reduced points AND retained-code nodes must each
763
807
  // correlate >= threshold with the full model (per dim, with a
764
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.
765
810
  const retained = [];
766
811
  for (let i = 0; i < m; i++) if (!removedSet.has(i)) retained.push(i);
767
- let [pc1, pc2] = corr2(fullPts2, sub(red.points, unitRows), nUnits);
768
- let [nc1, nc2] = corr2(sub(full.rotation.nodes, retained),
769
- 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);
770
816
  if (pc1 < 0) { pc1 = -pc1; nc1 = -nc1; }
771
817
  if (pc2 < 0) { pc2 = -pc2; nc2 = -nc2; }
772
818
  if (Math.min(pc1, pc2, nc1, nc2) < threshold) continue;
773
819
 
774
- // Dim-1 variance proportion over the FULL rotation spectrum
775
- // (= R's set$model$variance[1] = diag(var(points.rotated))/sum);
776
- // NOT model.variance, which is the 2-dim projected ratio.
777
- const ev = red.rotation.eigenvalues;
778
- let evSum = 0; for (let i = 0; i < ev.length; i++) evSum += ev[i];
779
- 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];
780
825
  // Prefer more codes removed; tie-break on higher dim-1 variance.
781
826
  if (k > bestK || (k === bestK && vr1 > bestVar)) {
782
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,
package/src/tensor.js CHANGED
@@ -119,54 +119,6 @@ export function inferFactorLevels(rows, factors) {
119
119
  return levels;
120
120
  }
121
121
 
122
- /**
123
- * Weighted aggregation of a unit's per-response-row directed connection counts.
124
- *
125
- * Mirrors the kernel's aggregate_row_connections but applies a weight-model
126
- * transform (R's weight.by) per LINE before the per-unit sum, so weight models
127
- * behave identically on the tensor path and the windowed path. Non-binary by
128
- * construction (the weight replaces the binarize step).
129
- *
130
- * unordered: fold each directed row (m + mᵀ, upper triangle, column-major —
131
- * the same order as choose_two/connection_names), weight each
132
- * folded cell, then sum across rows.
133
- * ordered: weight each directed cell, then sum across rows.
134
- *
135
- * @param {Float64Array|number[]} data Row-major (nRccRows × nCodes²) counts.
136
- * @param {number} nRccRows Number of response rows for this unit.
137
- * @param {number} nCodes
138
- * @param {boolean} ordered
139
- * @param {(x:number)=>number} weightFn
140
- * @returns {Float64Array} length nCodes² (ordered) or choose_two(nCodes).
141
- */
142
- function aggregateRowConnectionsWeighted(data, nRccRows, nCodes, ordered, weightFn) {
143
- const nSq = nCodes * nCodes;
144
- if (ordered) {
145
- const out = new Float64Array(nSq);
146
- for (let r = 0; r < nRccRows; r++) {
147
- const base = r * nSq;
148
- for (let c = 0; c < nSq; c++) out[c] += weightFn(data[base + c]);
149
- }
150
- return out;
151
- }
152
- const nTri = (nCodes * (nCodes - 1)) / 2;
153
- const out = new Float64Array(nTri);
154
- for (let r = 0; r < nRccRows; r++) {
155
- const base = r * nSq;
156
- let k = 0;
157
- for (let col = 1; col < nCodes; col++) {
158
- for (let row = 0; row < col; row++) {
159
- // fold: m(row,col) + m(col,row), column-major reshape of the row
160
- const folded = data[base + col * nCodes + row] +
161
- data[base + row * nCodes + col];
162
- out[k] += weightFn(folded);
163
- k++;
164
- }
165
- }
166
- }
167
- return out;
168
- }
169
-
170
122
  /**
171
123
  * Accumulate tensor networks for all units across all conversations.
172
124
  *
@@ -184,8 +136,11 @@ function aggregateRowConnectionsWeighted(data, nRccRows, nCodes, ordered, weight
184
136
  * @param {Map} convoGroups convoIdx → [rowIdx, ...]
185
137
  * @param {object} tensorDef Tensor definition (see module docstring)
186
138
  * @param {boolean} ordered true → directed (n²); false → undirected
187
- * @param {boolean} [binary=true] binarize each folded row (unordered only)
188
- * @param {((x:number)=>number)|null} [weightFn=null] per-line weight transform
139
+ * @param {boolean} [binary=true] binarize each folded row (unordered only);
140
+ * used when no weight model is given
141
+ * @param {string|null} [weight=null] libqe weight model applied per line before
142
+ * the unit sum: 'binary' | 'product' |
143
+ * 'sqrt' | 'log1p'
189
144
  *
190
145
  * @returns {{ networks: Float64Array, rowConnectionCounts: Float64Array }}
191
146
  * networks nUnits × nConnections, row-major
@@ -195,7 +150,7 @@ function aggregateRowConnectionsWeighted(data, nRccRows, nCodes, ordered, weight
195
150
  */
196
151
  export function accumulateTensor(qe, rows, codeMatrix, nRows, nCodes, nUnits,
197
152
  unitOf, convoGroups, tensorDef, ordered = false,
198
- binary = true, weightFn = null) {
153
+ binary = true, weight = null) {
199
154
  const {
200
155
  dims,
201
156
  dimsSender = [],
@@ -216,6 +171,9 @@ export function accumulateTensor(qe, rows, codeMatrix, nRows, nCodes, nUnits,
216
171
  const networks = new Float64Array(nUnits * nConnections);
217
172
  const rowConnectionCounts = new Float64Array(nRows * nConnections);
218
173
 
174
+ // libqe weight argument: a weight-model name, or the legacy binary flag.
175
+ const kernelWeight = weight ?? binary;
176
+
219
177
  const dimsArr = new Int32Array(dims);
220
178
  const senderArr = new Int32Array(dimsSender);
221
179
  const receiverArr = new Int32Array(dimsReceiver);
@@ -267,24 +225,23 @@ export function accumulateTensor(qe, rows, codeMatrix, nRows, nCodes, nUnits,
267
225
  true
268
226
  );
269
227
 
270
- // Finalise each raw per-response-row connection vector exactly as
271
- // tma does in R: fold to the upper triangle and binarize per row
272
- // (unordered), or keep the directed row (ordered). A weight model
273
- // (R's weight.by) replaces the binarize step with its transform,
274
- // applied per line before the per-unit sum. Unweighted rows go
275
- // through the libqe kernel so fold/binarize semantics are shared
276
- // across every binding. Row i of rcc is local row localRows[i].
228
+ // Finalise each raw per-response-row connection vector with libqe's
229
+ // shared kernel, exactly as tma does in R: fold to the upper
230
+ // triangle (unordered) or keep the directed row (ordered), then
231
+ // apply the weight model per line (binary clamp, product, sqrt,
232
+ // log1p) before the per-unit sum. Row i is local row localRows[i].
277
233
  const rcc = result.row_connection_counts;
278
- for (let i = 0; i < rcc.rows; i++) {
279
- const one = rcc.data.slice(i * rcc.cols, (i + 1) * rcc.cols);
280
- const rowVec = weightFn
281
- ? aggregateRowConnectionsWeighted(one, 1, nCodes, ordered, weightFn)
282
- : qe.aggregate_row_connections(one, 1, rcc.cols, nCodes, ordered, binary);
283
- const rowOff = rowIndices[localRows[i]] * nConnections;
284
- const unitOff = unit * nConnections;
234
+ const fin = qe.finalize_row_connections(
235
+ rcc.data, rcc.rows, rcc.cols, nCodes, ordered, kernelWeight
236
+ );
237
+ const unitOff = unit * nConnections;
238
+ for (let i = 0; i < fin.rows; i++) {
239
+ const rowOff = rowIndices[localRows[i]] * nConnections;
240
+ const finOff = i * fin.cols;
285
241
  for (let c = 0; c < nConnections; c++) {
286
- rowConnectionCounts[rowOff + c] = rowVec[c];
287
- networks[unitOff + c] += rowVec[c];
242
+ const v = fin.data[finOff + c];
243
+ rowConnectionCounts[rowOff + c] = v;
244
+ networks[unitOff + c] += v;
288
245
  }
289
246
  }
290
247
  }