@qe-libs/rena-wasm 0.1.2 → 0.1.5

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,30 @@ 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
+
70
94
  ## Accumulation Only
71
95
 
72
96
  Returns raw (un-normalised) network vectors without running the full pipeline.
@@ -92,7 +116,7 @@ columns in the data.
92
116
  // dims = [nRoleValues, 2] → Teacher uses window=4, Student uses window=2.
93
117
  const model = ena.fit(rows, {
94
118
  codes, units, conversations,
95
- ordered: true, // directed (n² connections) when using tensors
119
+ ordered: true, // directed (n² connections); also works without a tensor
96
120
  tensor: {
97
121
  dims: [2, 2], // [nRoleValues=2, weight/window=2]
98
122
  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.2",
3
+ "version": "0.1.5",
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.0"
34
+ "@qe-libs/libqe-wasm": "^0.1.5"
35
35
  },
36
36
  "devDependencies": {
37
37
  "jest": "^29.0.0"
package/src/index.js CHANGED
@@ -43,7 +43,7 @@
43
43
  import loadLibQE from '@qe-libs/libqe-wasm';
44
44
  import { parseData } from './data.js';
45
45
  import {
46
- accumulate, accumulateWithRows, sphereNorm, center,
46
+ sphereNorm, center,
47
47
  rotateSVD, rotateMeans, rotateGeneralized,
48
48
  project, nodePositions, spaceDistCorr,
49
49
  } from './pipeline.js';
@@ -187,6 +187,29 @@ class ENAModel {
187
187
  }
188
188
  }
189
189
 
190
+ // ── weight models (= R's `weight.by`) ────────────────────────────────────────
191
+
192
+ /**
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
+ * @param {string|false|undefined} name
201
+ * @returns {'sqrt'|'log1p'|'product'|null}
202
+ */
203
+ function weightModelName(name) {
204
+ switch (name) {
205
+ case 'sqrt': return 'sqrt';
206
+ case 'log':
207
+ case 'log1p': return 'log1p';
208
+ case 'product': return 'product';
209
+ default: return null; // binary / off / unknown
210
+ }
211
+ }
212
+
190
213
  // ── shared pipeline (post-accumulation) ──────────────────────────────────────
191
214
 
192
215
  function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
@@ -233,10 +256,11 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
233
256
  );
234
257
 
235
258
  // Node positions (LWS) → rotation.nodes + model.centroids
236
- // libqe.node_positions expects centered-normed networks (pointsForProjection),
237
- // not sphere-normed (lineWeights). Using lineWeights places code nodes in wrong locations.
259
+ // Matches rENA's lws.positions.sq, which regresses the projected points onto
260
+ // the SPHERE-normed line weights (enaset$line.weights) — not the centered
261
+ // networks. Verified node-for-node against R rENA on rs.data.new.csv.
238
262
  const { nodes, centroids } = nodePositions(
239
- qe, pointsForProjection, nUnits, nConnections, points, dims
263
+ qe, lineWeights, nUnits, nConnections, points, dims
240
264
  );
241
265
 
242
266
  // Variance explained (= R's model$variance)
@@ -291,10 +315,11 @@ export default async function loadENA() {
291
315
  * @param {string[]} opts.codes - Code column names
292
316
  * @param {string[]} opts.units - Unit identifier column(s)
293
317
  * @param {string[]} opts.conversations - Conversation identifier column(s)
294
- * @param {number} [opts.window=4] - Backward window (simple path; ignored when tensor provided)
295
- * @param {boolean} [opts.binary=true] - Binarise co-occurrences (simple path only)
296
- * @param {boolean} [opts.ordered=false] - Directed networks (tensor path only)
297
- * @param {object} [opts.tensor] - Context tensor definition
318
+ * @param {number} [opts.window=4] - Backward window; builds defaultTensor(window) when no tensor is given
319
+ * @param {boolean} [opts.binary=true] - Binarise each line's co-occurrences (unordered; ignored when weightModel is set)
320
+ * @param {boolean} [opts.ordered=false] - Directed networks
321
+ * @param {object} [opts.tensor] - Context tensor definition (overrides window)
322
+ * @param {string} [opts.weightModel] - 'product' | 'sqrt' | 'log' (alias 'log1p'); per line, before the unit sum
298
323
  * @param {string} [opts.rotation='svd'] - 'svd', 'mean', or 'generalized'
299
324
  * @param {number[]} [opts.groupA] - Unit indices for means rotation group A
300
325
  * @param {number[]} [opts.groupB] - Unit indices for means rotation group B
@@ -318,33 +343,33 @@ export default async function loadENA() {
318
343
  gParams,
319
344
  dims = 2,
320
345
  codeMask,
346
+ weightModel,
321
347
  } = opts;
322
348
 
323
349
  if (!codes?.length) throw new Error('opts.codes is required');
324
350
  if (!units?.length) throw new Error('opts.units is required');
325
351
  if (!conversations?.length) throw new Error('opts.conversations is required');
326
352
 
353
+ // Weight model = R's `weight.by`. R applies it per LINE (each row's
354
+ // "product" co-occurrence counts) BEFORE summing per unit, mirroring
355
+ // accumulate.data.R (lapply(.SD, weight.by) over the per-line
356
+ // co-occurrence table, then per-unit aggregation). Because
357
+ // sqrt(Σ) ≠ Σsqrt, libqe applies it to each row's counts inside
358
+ // accumulateTensor, not to the unit-summed network.
359
+ const weight = weightModelName(weightModel);
360
+
327
361
  const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
328
362
  unitOf, convoGroups, metaData } =
329
363
  parseData(rows, codes, units, conversations);
330
364
 
331
- let rawNetworks, rowConnectionCounts = null, nConnections;
332
-
333
- if (tensorDef) {
334
- rawNetworks = accumulateTensor(
335
- qe, rows, codeMatrix, nRows, nCodes, nUnits,
336
- unitOf, convoGroups, tensorDef, ordered
337
- );
338
- nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
339
- } else {
340
- const accumulated = accumulateWithRows(
341
- qe, codeMatrix, nRows, nCodes, nUnits,
342
- unitOf, convoGroups, windowSize, binary
343
- );
344
- rawNetworks = accumulated.networks;
345
- rowConnectionCounts = accumulated.rowConnectionCounts;
346
- nConnections = qe.choose_two(nCodes);
347
- }
365
+ // Every model accumulates through the tensor path; a plain moving
366
+ // window is expressed as defaultTensor(window).
367
+ const { networks: rawNetworks, rowConnectionCounts } = accumulateTensor(
368
+ qe, rows, codeMatrix, nRows, nCodes, nUnits,
369
+ unitOf, convoGroups, tensorDef ?? defaultTensor(windowSize),
370
+ ordered, binary, weight
371
+ );
372
+ const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
348
373
 
349
374
  // Apply code masking by zeroing out the masked connection columns across all units
350
375
  if (codeMask && codeMask.length === codes.length) {
@@ -385,7 +410,7 @@ export default async function loadENA() {
385
410
  * Returns raw (un-normalised) network vectors.
386
411
  *
387
412
  * @param {Object[]} rows
388
- * @param {object} opts - codes, units, conversations, window, binary, ordered, tensor
413
+ * @param {object} opts - codes, units, conversations, window, binary, ordered, tensor, weightModel
389
414
  * @returns {{
390
415
  * connectionCounts: Float64Array,
391
416
  * rowConnectionCounts: Float64Array | null,
@@ -405,29 +430,19 @@ export default async function loadENA() {
405
430
  ordered = false,
406
431
  tensor: tensorDef,
407
432
  codeMask,
433
+ weightModel,
408
434
  } = opts;
409
435
 
410
436
  const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
411
437
  unitOf, convoGroups, metaData } =
412
438
  parseData(rows, codes, units, conversations);
413
439
 
414
- let networks, rowConnectionCounts = null, nConnections;
415
-
416
- if (tensorDef) {
417
- networks = accumulateTensor(
418
- qe, rows, codeMatrix, nRows, nCodes, nUnits,
419
- unitOf, convoGroups, tensorDef, ordered
420
- );
421
- nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
422
- } else {
423
- const accumulated = accumulateWithRows(
424
- qe, codeMatrix, nRows, nCodes, nUnits,
425
- unitOf, convoGroups, windowSize, binary
426
- );
427
- networks = accumulated.networks;
428
- rowConnectionCounts = accumulated.rowConnectionCounts;
429
- nConnections = qe.choose_two(nCodes);
430
- }
440
+ const { networks, rowConnectionCounts } = accumulateTensor(
441
+ qe, rows, codeMatrix, nRows, nCodes, nUnits,
442
+ unitOf, convoGroups, tensorDef ?? defaultTensor(windowSize),
443
+ ordered, binary, weightModelName(weightModel)
444
+ );
445
+ const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
431
446
 
432
447
  // Apply code masking by zeroing out the masked connection columns across all units
433
448
  if (codeMask && codeMask.length === codes.length) {
@@ -465,7 +480,8 @@ export default async function loadENA() {
465
480
  // Retained so tuneWindowSize() can rebuild at other window sizes
466
481
  // (= R's ENAAccumulation$`_function.call`).
467
482
  _call: { rows, codes, units, conversations,
468
- window: windowSize, binary, ordered, tensor: tensorDef },
483
+ window: windowSize, binary, ordered, tensor: tensorDef,
484
+ weightModel },
469
485
  };
470
486
  },
471
487
 
@@ -503,21 +519,22 @@ export default async function loadENA() {
503
519
  'maxSize must be greater than minSize to compare windows.'
504
520
  );
505
521
 
506
- const { rows, codes, units, conversations, binary } = call;
522
+ const { rows, codes, units, conversations, binary, ordered, weightModel } = call;
507
523
 
508
524
  // Parse once; only the window size changes between iterations.
509
525
  const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
510
526
  unitOf, convoGroups, metaData } =
511
527
  parseData(rows, codes, units, conversations);
512
- const nConnections = qe.choose_two(nCodes);
528
+ const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
513
529
 
514
530
  // 1. Rebuild + fit at each window, collecting unit points.
515
531
  const dims = 2;
516
532
  const allPoints = [];
517
533
  for (const w of windowRange) {
518
- const raw = accumulate(
519
- qe, codeMatrix, nRows, nCodes, nUnits,
520
- unitOf, convoGroups, w, binary
534
+ const { networks: raw } = accumulateTensor(
535
+ qe, rows, codeMatrix, nRows, nCodes, nUnits,
536
+ unitOf, convoGroups, defaultTensor(w), ordered, binary,
537
+ weightModelName(weightModel)
521
538
  );
522
539
  const model = runPipeline(
523
540
  qe, raw, nUnits, nConnections, codes,
@@ -543,10 +560,71 @@ export default async function loadENA() {
543
560
 
544
561
  // 4. Rebuild the accumulation at the selected window size.
545
562
  return api.accumulate(rows, {
546
- codes, units, conversations, window: bestWindow, binary,
563
+ codes, units, conversations, window: bestWindow, binary, ordered,
564
+ weightModel,
547
565
  });
548
566
  },
549
567
 
568
+ /**
569
+ * Estimate the moving-window size via Cross-Covariance Decay (CCD)
570
+ * (= R's ena.ccd). Computes the noise-corrected cross-covariance decay
571
+ * curves over lags and returns the half-life lag as the window size.
572
+ *
573
+ * Unlike tuneWindowSize (which rebuilds+fits a full SVD model at each
574
+ * window), CCD runs directly on the raw code matrix per conversation —
575
+ * no accumulation/rotation — via the shared libqe kernel.
576
+ *
577
+ * @param {Object[]} rows
578
+ * @param {object} opts
579
+ * @param {string[]} opts.codes - Code column names
580
+ * @param {string[]} opts.conversations - Conversation identifier column(s)
581
+ * @param {number} [opts.maxWindow=20] - Maximum lag to evaluate
582
+ * @param {number} [opts.minOverlap=10] - Minimum overlapping rows per conversation at a lag
583
+ * @returns {{
584
+ * window_size: number,
585
+ * peak_lag: number,
586
+ * lag: number[],
587
+ * frob: number[],
588
+ * frob_sq_unbiased: number[],
589
+ * frob_unbiased_signed: number[],
590
+ * total_weight: number[],
591
+ * }}
592
+ */
593
+ ccd(rows, opts = {}) {
594
+ const { codes, conversations, maxWindow = 20, minOverlap = 10 } = opts;
595
+ if (!codes?.length) throw new Error('opts.codes is required');
596
+ if (!conversations?.length) throw new Error('opts.conversations is required');
597
+
598
+ // Units are irrelevant to CCD; reuse conversations as a placeholder
599
+ // so parseData can build codeMatrix + convoGroups.
600
+ const { codeMatrix, nRows, nCodes, convoGroups } =
601
+ parseData(rows, codes, conversations, conversations);
602
+
603
+ // Flatten conversation row groups into (sizes, indices) for the kernel.
604
+ const groupSizes = [];
605
+ const rowIndices = [];
606
+ for (const rowIdxs of convoGroups.values()) {
607
+ groupSizes.push(rowIdxs.length);
608
+ for (const ri of rowIdxs) rowIndices.push(ri);
609
+ }
610
+
611
+ return qe.ccd_window(
612
+ codeMatrix, nRows, nCodes, groupSizes, rowIndices, maxWindow, minOverlap
613
+ );
614
+ },
615
+
616
+ /**
617
+ * Convenience wrapper returning only the estimated window size
618
+ * (= R's ena.ccd.window).
619
+ *
620
+ * @param {Object[]} rows
621
+ * @param {object} opts - see ccd()
622
+ * @returns {number} estimated window size
623
+ */
624
+ ccdWindow(rows, opts = {}) {
625
+ return api.ccd(rows, opts).window_size;
626
+ },
627
+
550
628
  /**
551
629
  * Per-dimension t-based confidence intervals around the column means.
552
630
  * Matches R's conf.ints / libqe::mean_ci.
@@ -593,10 +671,135 @@ export default async function loadENA() {
593
671
  return qe.group_stats(g1Points, nG1, dims, g2Points, nG2, dims);
594
672
  },
595
673
 
674
+ /**
675
+ * PRIA — find the largest set of codes (up to removeNum) that can be
676
+ * removed while keeping the model's goodness-of-fit within `threshold`
677
+ * of the full model. Brute-force subset search matching R
678
+ * 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.
684
+ *
685
+ * @returns {{ removed: string[], removedIndices: number[], k: number,
686
+ * variance: (number|null) }}
687
+ */
688
+ pria(rows, opts = {}) {
689
+ const {
690
+ codes, units, conversations,
691
+ window: windowSize = 4, binary = true, ordered = false,
692
+ rotation = 'svd', dims = 2, gParams, groupA, groupB,
693
+ removeNum = 3, threshold = 0.95,
694
+ } = opts;
695
+ if (!codes?.length) throw new Error('opts.codes is required');
696
+ if (!units?.length) throw new Error('opts.units is required');
697
+ if (!conversations?.length) throw new Error('opts.conversations is required');
698
+
699
+ const m = codes.length;
700
+ const rn = Math.min(removeNum, m - 3); // never reduce below 3 codes
701
+ const empty = { removed: [], removedIndices: [], k: 0, variance: null };
702
+ if (rn < 1) return empty;
703
+
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
+
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) => {
712
+ 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]; });
714
+ return out;
715
+ };
716
+ // 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);
720
+ const out = [];
721
+ for (let d = 0; d < D; d++) out.push(r.data[d * 3]);
722
+ return out;
723
+ };
724
+ const full = api.fit(rows, baseOpts);
725
+ const nUnits = full.nUnits;
726
+ const unitRows = Array.from({ length: nUnits }, (_, i) => i);
727
+ const gofOf = (mdl) => corr2(sub(mdl.points, unitRows), sub(mdl.model.centroids, unitRows), nUnits);
728
+ const gFull = gofOf(full);
729
+ const fullPts2 = sub(full.points, unitRows);
730
+
731
+ // All k-subsets of [0..m) as index arrays.
732
+ const combos = (n, k) => {
733
+ const res = [], cur = [];
734
+ const rec = (start) => {
735
+ if (cur.length === k) { res.push(cur.slice()); return; }
736
+ for (let i = start; i < n; i++) { cur.push(i); rec(i + 1); cur.pop(); }
737
+ };
738
+ rec(0);
739
+ return res;
740
+ };
741
+
742
+ let bestK = 0, bestVar = -Infinity, bestRemoved = [];
743
+ for (let k = 1; k <= rn; k++) {
744
+ for (const idxs of combos(m, k)) {
745
+ 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 });
755
+
756
+ // Gate 1 — goodness-of-fit ratio vs full, per dim.
757
+ const gRed = gofOf(red);
758
+ let pass = true;
759
+ for (let d = 0; d < D; d++) {
760
+ if (gFull[d] === 0 || gRed[d] / gFull[d] < threshold) { pass = false; break; }
761
+ }
762
+ if (!pass) continue;
763
+
764
+ // Gate 2 — reduced points AND retained-code nodes must each
765
+ // correlate >= threshold with the full model (per dim, with a
766
+ // per-dim sign flip that negates the node corr alongside it).
767
+ const retained = [];
768
+ 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);
772
+ if (pc1 < 0) { pc1 = -pc1; nc1 = -nc1; }
773
+ if (pc2 < 0) { pc2 = -pc2; nc2 = -nc2; }
774
+ if (Math.min(pc1, pc2, nc1, nc2) < threshold) continue;
775
+
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;
782
+ // Prefer more codes removed; tie-break on higher dim-1 variance.
783
+ if (k > bestK || (k === bestK && vr1 > bestVar)) {
784
+ bestK = k; bestVar = vr1; bestRemoved = idxs.slice();
785
+ }
786
+ }
787
+ }
788
+ return {
789
+ removed: bestRemoved.map(i => codes[i]),
790
+ removedIndices: bestRemoved,
791
+ k: bestK,
792
+ variance: bestVar === -Infinity ? null : bestVar,
793
+ };
794
+ },
795
+
596
796
  /**
597
797
  * Helpers re-exported for consumers who want to build their own pipeline.
598
798
  */
599
799
  defaultTensor,
800
+
801
+ /** The underlying libqe WASM module, for custom low-level pipelines. */
802
+ qe,
600
803
  };
601
804
 
602
805
  return api;
package/src/pipeline.js CHANGED
@@ -116,109 +116,6 @@ export function spaceDistCorr(A, B, m, d, maxSampleSize = 100000, rand = Math.ra
116
116
  return pearson(distA, distB);
117
117
  }
118
118
 
119
- // ── accumulation ─────────────────────────────────────────────────────────────
120
-
121
- /**
122
- * Accumulate windowed co-occurrences for all units across all conversations.
123
- *
124
- * For each conversation group, runs qe.accumulate_stanza() on the conversation's
125
- * rows to get per-row connection vectors, then folds each row's vector into its
126
- * owning unit's running sum.
127
- *
128
- * @param {object} qe libqe WASM module
129
- * @param {Float64Array} codeMatrix n_rows × n_codes, row-major
130
- * @param {number} nRows
131
- * @param {number} nCodes
132
- * @param {number} nUnits
133
- * @param {Int32Array} unitOf unit index per row
134
- * @param {Map} convoGroups convoIdx → [rowIdx, ...]
135
- * @param {number} windowSize backward window (rows)
136
- * @param {boolean} binary binarise co-occurrences
137
- *
138
- * @returns {Float64Array} nUnits × nConnections, row-major
139
- */
140
- export function accumulate(qe, codeMatrix, nRows, nCodes, nUnits,
141
- unitOf, convoGroups, windowSize = 4, binary = true) {
142
- const nConnections = qe.choose_two(nCodes);
143
- const networks = new Float64Array(nUnits * nConnections);
144
-
145
- for (const [, rowIndices] of convoGroups) {
146
- const nConvo = rowIndices.length;
147
-
148
- // Extract code rows for this conversation (contiguous sub-matrix)
149
- const convoCodes = new Float64Array(nConvo * nCodes);
150
- for (let r = 0; r < nConvo; r++) {
151
- const src = rowIndices[r];
152
- convoCodes.set(
153
- codeMatrix.subarray(src * nCodes, src * nCodes + nCodes),
154
- r * nCodes
155
- );
156
- }
157
-
158
- // Windowed accumulation — returns per-row connection vectors
159
- const stanza = qe.accumulate_stanza(
160
- convoCodes, nConvo, nCodes, windowSize, 0, binary
161
- );
162
- // stanza.data: nConvo × nConnections, row-major
163
-
164
- // Fold each row into its unit's accumulator
165
- for (let r = 0; r < nConvo; r++) {
166
- const unit = unitOf[rowIndices[r]];
167
- const offset = r * nConnections;
168
- for (let c = 0; c < nConnections; c++) {
169
- networks[unit * nConnections + c] += stanza.data[offset + c];
170
- }
171
- }
172
- }
173
-
174
- return networks;
175
- }
176
-
177
- /**
178
- * Accumulate windowed co-occurrences and retain both per-unit and per-row
179
- * connection vectors.
180
- *
181
- * @returns {{ networks: Float64Array, rowConnectionCounts: Float64Array }}
182
- */
183
- export function accumulateWithRows(qe, codeMatrix, nRows, nCodes, nUnits,
184
- unitOf, convoGroups, windowSize = 4, binary = true) {
185
- const nConnections = qe.choose_two(nCodes);
186
- const networks = new Float64Array(nUnits * nConnections);
187
- const rowConnectionCounts = new Float64Array(nRows * nConnections);
188
-
189
- for (const [, rowIndices] of convoGroups) {
190
- const nConvo = rowIndices.length;
191
-
192
- const convoCodes = new Float64Array(nConvo * nCodes);
193
- for (let r = 0; r < nConvo; r++) {
194
- const src = rowIndices[r];
195
- convoCodes.set(
196
- codeMatrix.subarray(src * nCodes, src * nCodes + nCodes),
197
- r * nCodes
198
- );
199
- }
200
-
201
- const stanza = qe.accumulate_stanza(
202
- convoCodes, nConvo, nCodes, windowSize, 0, binary
203
- );
204
-
205
- for (let r = 0; r < nConvo; r++) {
206
- const src = rowIndices[r];
207
- const unit = unitOf[src];
208
- const offset = r * nConnections;
209
- const rowOut = src * nConnections;
210
-
211
- for (let c = 0; c < nConnections; c++) {
212
- const value = stanza.data[offset + c];
213
- rowConnectionCounts[rowOut + c] = value;
214
- networks[unit * nConnections + c] += value;
215
- }
216
- }
217
- }
218
-
219
- return { networks, rowConnectionCounts };
220
- }
221
-
222
119
  // ── normalization (sphere norm) ───────────────────────────────────────────────
223
120
 
224
121
  /**
package/src/tensor.js CHANGED
@@ -122,6 +122,10 @@ export function inferFactorLevels(rows, factors) {
122
122
  /**
123
123
  * Accumulate tensor networks for all units across all conversations.
124
124
  *
125
+ * Every accumulation — including plain windowed ENA, via defaultTensor(window)
126
+ * — runs through here, so the per-line finalisation (fold / binarize / weight)
127
+ * lives in exactly one place.
128
+ *
125
129
  * @param {object} qe libqe WASM module
126
130
  * @param {Object[]} rows Full dataset
127
131
  * @param {Float64Array} codeMatrix n_rows × n_codes, row-major
@@ -132,11 +136,21 @@ export function inferFactorLevels(rows, factors) {
132
136
  * @param {Map} convoGroups convoIdx → [rowIdx, ...]
133
137
  * @param {object} tensorDef Tensor definition (see module docstring)
134
138
  * @param {boolean} ordered true → directed (n²); false → undirected
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'
135
144
  *
136
- * @returns {Float64Array} nUnits × nConnections, row-major
145
+ * @returns {{ networks: Float64Array, rowConnectionCounts: Float64Array }}
146
+ * networks nUnits × nConnections, row-major
147
+ * rowConnectionCounts nRows × nConnections, row-major — each row's finalised
148
+ * (folded / binarized / weighted) connection vector;
149
+ * rows sum by unit to `networks`
137
150
  */
138
151
  export function accumulateTensor(qe, rows, codeMatrix, nRows, nCodes, nUnits,
139
- unitOf, convoGroups, tensorDef, ordered = false) {
152
+ unitOf, convoGroups, tensorDef, ordered = false,
153
+ binary = true, weight = null) {
140
154
  const {
141
155
  dims,
142
156
  dimsSender = [],
@@ -154,7 +168,11 @@ export function accumulateTensor(qe, rows, codeMatrix, nRows, nCodes, nUnits,
154
168
  ? nCodes * nCodes
155
169
  : qe.choose_two(nCodes);
156
170
 
157
- const networks = new Float64Array(nUnits * nConnections);
171
+ const networks = new Float64Array(nUnits * nConnections);
172
+ const rowConnectionCounts = new Float64Array(nRows * nConnections);
173
+
174
+ // libqe weight argument: a weight-model name, or the legacy binary flag.
175
+ const kernelWeight = weight ?? binary;
158
176
 
159
177
  const dimsArr = new Int32Array(dims);
160
178
  const senderArr = new Int32Array(dimsSender);
@@ -191,6 +209,12 @@ export function accumulateTensor(qe, rows, codeMatrix, nRows, nCodes, nUnits,
191
209
  for (const [unit, localRows] of unitConvoRows) {
192
210
  const unitRowsArr = new Int32Array(localRows);
193
211
 
212
+ // Always accumulate the DIRECTED per-response-row counts (ordered
213
+ // kernel), exactly as tma does — it calls apply_tensor with the
214
+ // default ordered=TRUE and defers the ordered-vs-unordered decision
215
+ // to aggregation. Passing the unordered flag here would make the
216
+ // kernel emit an already-symmetric matrix that the fold below would
217
+ // then double-count.
194
218
  const result = qe.accumulate_tensor_unit(
195
219
  tensorData, dimsArr,
196
220
  senderArr, receiverArr, modeArr,
@@ -198,17 +222,32 @@ export function accumulateTensor(qe, rows, codeMatrix, nRows, nCodes, nUnits,
198
222
  unitRowsArr,
199
223
  convoCodes, nConvo, nCodes,
200
224
  times,
201
- ordered
225
+ true
202
226
  );
203
227
 
204
- // Add connection counts to this unit's accumulator
205
- for (let c = 0; c < nConnections; c++) {
206
- networks[unit * nConnections + c] += result.connection_counts[c];
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].
233
+ const rcc = result.row_connection_counts;
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;
241
+ for (let c = 0; c < nConnections; c++) {
242
+ const v = fin.data[finOff + c];
243
+ rowConnectionCounts[rowOff + c] = v;
244
+ networks[unitOff + c] += v;
245
+ }
207
246
  }
208
247
  }
209
248
  }
210
249
 
211
- return networks;
250
+ return { networks, rowConnectionCounts };
212
251
  }
213
252
 
214
253
  /**