@qe-libs/rena-wasm 0.1.8 → 0.1.10

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
@@ -111,6 +111,67 @@ everyone's windows.
111
111
 
112
112
  ---
113
113
 
114
+ ## Infinite Windows
115
+
116
+ `window: Infinity` (= R's `window.size.back = Inf`) connects each line to
117
+ every earlier line in its conversation. It also works as a window in a
118
+ context tensor.
119
+
120
+ ```js
121
+ const model = ena.fit(rows, { codes, units, conversations, window: Infinity });
122
+ ```
123
+
124
+ ---
125
+
126
+ ## Normalization and Centring
127
+
128
+ `sphereNorm` (default `true`, = R's `norm.by = fun_sphere_norm`) scales each
129
+ unit's network to length 1. With `false` (= `fun_skip_sphere_norm`) every
130
+ network is scaled by the longest network's length instead, keeping relative
131
+ magnitudes; for ordered (ONA) models this is
132
+ `ona::model(normalize = skip_sphere_norm)`. `centerAlignToOrigin` (default
133
+ `true`, = `ena.make.set`'s `center.align.to.origin`) leaves zero-network units
134
+ out of the centring mean and at the origin; with `false` the mean is taken
135
+ over, and subtracted from, every unit. It applies to unordered models only —
136
+ ordered models always centre as `ona::model()` does. Both work in `fit()` and
137
+ `pria()`.
138
+
139
+ ```js
140
+ const model = ena.fit(rows, { codes, units, conversations, window: 4,
141
+ sphereNorm: false, centerAlignToOrigin: false });
142
+ ```
143
+
144
+ ---
145
+
146
+ ## Projecting Into Another Model (Custom Rotation)
147
+
148
+ `rotationSet` (= R's `rotation.set` in `ena.make.set()`) projects a model into
149
+ another model's space instead of rotating it: the units are centred on the
150
+ rotation set's `centerVec` and projected with its `rotationMatrix`, and the
151
+ nodes are the rotation set's. Pass `codes` (the rotation set's code order) to
152
+ have it reordered to the model's codes; a rotation set built from other codes
153
+ is rejected. Centroids use the pinned nodes, so goodness of fit is measured
154
+ against the nodes shown. Ordered models keep their own centring, as
155
+ rENA.api's ordered pipeline does.
156
+
157
+ ```js
158
+ const source = ena.fit(rowsA, { codes, units, conversations, window: 4 });
159
+ const model = ena.fit(rowsB, { codes, units, conversations, window: 4,
160
+ rotationSet: {
161
+ rotationMatrix: source.rotationMatrix, rotationCols: source.dims,
162
+ nodes: source.rotation.nodes,
163
+ centerVec: source.rotation.centerVec,
164
+ codes,
165
+ },
166
+ });
167
+ ```
168
+
169
+ `rotationMatrix` and `nodes` may also be arrays of rows (one per connection
170
+ and per code), as R stores them. `pria()` ignores `rotationSet`: it cannot
171
+ rotate the reduced models.
172
+
173
+ ---
174
+
114
175
  ## Reduced-Code Search (PRIA)
115
176
 
116
177
  `pria()` finds the largest set of codes (up to `removeNum`, never leaving
@@ -195,6 +256,40 @@ data[a + nA*b + nA*nB*1] → window for factor combination (a, b)
195
256
  | `dimsReceiver` | These axes use the *response* row's factor values (overrides ground) |
196
257
  | `dimsMode` | These axes use a shared mode value |
197
258
 
259
+ ### Time-based windows
260
+
261
+ Set `timesCol` to measure windows in time instead of lines, and `timeUnit`
262
+ ('secs' (default), 'mins', 'hours', 'days', 'weeks') to give the tensor's
263
+ windows in that unit. Numeric time columns are used as-is; otherwise each
264
+ value is parsed as elapsed time (`MM:SS`, `HH:MM:SS`) or a date-time, in
265
+ seconds, as rENA.api's `parse_date` does. `timesEndCol` uses each row's end
266
+ time instead: a numeric column is a duration added to the start, anything
267
+ else is the end timestamp.
268
+
269
+ ```js
270
+ const tensor = { ...modalityTensor, timesCol: 'Timestamp', timeUnit: 'mins' };
271
+ ```
272
+
273
+ ### Flexible horizons
274
+
275
+ `horizons` gives every unit its own horizon of observation (= tma
276
+ `contexts()` with HOO rules, as rENA.api builds them for flexible horizons).
277
+ `by` is the discriminator column and `rules` maps each of its values to the
278
+ columns that define that value's horizon: a row is in unit U's horizon when
279
+ each of those columns holds a value U has. Each horizon is split on the rules'
280
+ columns — not on the discriminator, which only picks the rule — and windows
281
+ run within each piece. Rows whose discriminator value has no rule are left
282
+ out.
283
+
284
+ ```js
285
+ const model = ena.fit(rows, {
286
+ codes, units, conversations: ['Modality'], tensor,
287
+ horizons: { by: 'Modality', rules: { chat: ['GroupName'], log: ['UserName'] } },
288
+ });
289
+ ```
290
+
291
+ `horizons` works in `fit()`, `accumulate()`, `tuneWindowSize()` and `pria()`.
292
+
198
293
  ### `defaultTensor` helper
199
294
 
200
295
  Express simple windowed accumulation as a tensor (IS_DEFAULT path):
@@ -233,4 +328,9 @@ npm test
233
328
  ```
234
329
 
235
330
  Tests cover the simple windowed pipeline (`test/ena.test.js`) and the
236
- context-tensor path (`test/tensor.test.js`).
331
+ context-tensor path (`test/tensor.test.js`), and check against R: infinite and
332
+ time-based windows, flexible horizons and custom rotations
333
+ (`test/windows-horizons-rotation.test.js`), the normalization and centring
334
+ options against `ena.make.set()` (`test/norm-center.test.js`), and ordered
335
+ models with and without sphere normalization against `ona::model()`
336
+ (`test/ona-model.test.js`, `test/ona-sphere-norm.test.js`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qe-libs/rena-wasm",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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.5"
34
+ "@qe-libs/libqe-wasm": "^0.1.6"
35
35
  },
36
36
  "devDependencies": {
37
37
  "jest": "^29.0.0"
package/src/index.js CHANGED
@@ -43,9 +43,9 @@
43
43
  import loadLibQE from '@qe-libs/libqe-wasm';
44
44
  import { parseData } from './data.js';
45
45
  import {
46
- sphereNorm, center,
46
+ sphereNorm, scaleNetworks, center,
47
47
  rotateSVD, rotateMeans, rotateGeneralized,
48
- project, nodePositions, spaceDistCorr,
48
+ project, nodePositions, spaceDistCorr, matmul,
49
49
  } from './pipeline.js';
50
50
  import { accumulateTensor, defaultTensor } from './tensor.js';
51
51
 
@@ -64,7 +64,7 @@ export { detectParams, isBinary, scoreUnit, scoreConvo } from './detect.js';
64
64
  * @param {number} dims
65
65
  * @returns {number[]} length dims, sums to 1
66
66
  */
67
- function computeVariance(points, nUnits, dims) {
67
+ function computeVariance(points, nUnits, dims, total = null) {
68
68
  if (nUnits < 2) return Array.from({ length: dims }, () => 1 / dims);
69
69
 
70
70
  // Column means
@@ -83,11 +83,29 @@ function computeVariance(points, nUnits, dims) {
83
83
  }
84
84
  for (let d = 0; d < dims; d++) variances[d] /= (nUnits - 1);
85
85
 
86
- const total = Array.from(variances).reduce((a, b) => a + b, 0);
86
+ if (total == null) total = Array.from(variances).reduce((a, b) => a + b, 0);
87
87
  if (total === 0) return Array.from({ length: dims }, () => 1 / dims);
88
88
  return Array.from(variances).map(v => v / total);
89
89
  }
90
90
 
91
+ /**
92
+ * Sum of the column sample variances of a row-major matrix (n-1 denominator,
93
+ * as R's var()).
94
+ */
95
+ function totalVariance(m, nRows, nCols) {
96
+ if (nRows < 2) return 0;
97
+ let total = 0;
98
+ for (let c = 0; c < nCols; c++) {
99
+ let mean = 0;
100
+ for (let r = 0; r < nRows; r++) mean += m[r * nCols + c];
101
+ mean /= nRows;
102
+ let ss = 0;
103
+ for (let r = 0; r < nRows; r++) { const d = m[r * nCols + c] - mean; ss += d * d; }
104
+ total += ss / (nRows - 1);
105
+ }
106
+ return total;
107
+ }
108
+
91
109
  /**
92
110
  * Build the adjacency key — list of [codeA, codeB] pairs for each connection.
93
111
  * Matches R's rotation$adjacency.key.
@@ -272,22 +290,125 @@ function applyCodeMask(networks, codeMask, nCodes, nUnits, nConnections, ordered
272
290
  }
273
291
  }
274
292
 
293
+ // ── custom rotation (= R's rotation.set) ─────────────────────────────────────
294
+
295
+ // Rows of a matrix given as an array of row arrays, or as flat row-major data
296
+ // with `cols` columns.
297
+ function matrixRows(m, cols) {
298
+ if (m == null) return null;
299
+ if (Array.isArray(m) && (m.length === 0 || Array.isArray(m[0]) || ArrayBuffer.isView(m[0]))) {
300
+ return m.map(r => Array.from(r, Number));
301
+ }
302
+ const flat = Array.from(m, Number);
303
+ if (!cols) throw new Error('a flat rotation matrix needs its column count');
304
+ const rows = [];
305
+ for (let i = 0; i < flat.length; i += cols) rows.push(flat.slice(i, i + cols));
306
+ return rows;
307
+ }
308
+
309
+ /**
310
+ * Validate a supplied rotation set (= R's ena.rotation.set, e.g. another
311
+ * model's rotation) against this model's codes, and reorder it to this
312
+ * model's code order. Throws when it cannot rotate this model.
313
+ *
314
+ * @param {object} rs { rotationMatrix, nodes, centerVec, codes?,
315
+ * rotationCols?, eigenvalues?, columnNames? }
316
+ * rotationMatrix: nConnections rows (array of rows, or
317
+ * flat row-major with rotationCols); nodes: one row per
318
+ * code; centerVec: one entry per connection.
319
+ * @returns {{ rotation: Float64Array, rotRows: number, rotCols: number,
320
+ * nodes: number[][], centerVec: Float64Array,
321
+ * eigenvalues: number[]|null, columnNames: string[] }}
322
+ */
323
+ function resolveRotationSet(rs, codes, nConnections, ordered) {
324
+ const rot = matrixRows(rs.rotationMatrix, rs.rotationCols);
325
+ if (!rot || rot.length !== nConnections) {
326
+ throw new Error(
327
+ `rotationSet has ${rot ? rot.length : 0} connections but this model has ` +
328
+ `${nConnections} -- it was built from different codes` +
329
+ (ordered ? ' or is not an ordered model' : ' or is an ordered model')
330
+ );
331
+ }
332
+ const rotCols = rot[0].length;
333
+ const nodes = matrixRows(rs.nodes, rotCols);
334
+ const center = rs.centerVec ? Array.from(rs.centerVec, Number) : null;
335
+ if (!nodes || nodes.length !== codes.length) {
336
+ throw new Error('rotationSet.nodes must have one row per code');
337
+ }
338
+ if (!ordered && (!center || center.length !== nConnections)) {
339
+ throw new Error('rotationSet.centerVec must have one entry per connection');
340
+ }
341
+
342
+ // Map this model's codes/connections onto the rotation set's order.
343
+ let codeIdx = codes.map((_, i) => i);
344
+ let connIdx = Array.from({ length: nConnections }, (_, k) => k);
345
+ if (rs.codes) {
346
+ const src = rs.codes.map(String);
347
+ codeIdx = codes.map(c => src.indexOf(String(c)));
348
+ if (src.length !== codes.length || codeIdx.some(i => i < 0)) {
349
+ throw new Error('rotationSet.codes do not match this model\'s codes');
350
+ }
351
+ const srcKey = new Map(buildAdjacencyKey(src, ordered).map(([a, b], k) => [a + '\u0000' + b, k]));
352
+ connIdx = buildAdjacencyKey(codes, ordered).map(([a, b]) => {
353
+ const k = srcKey.get(a + '\u0000' + b) ?? (ordered ? undefined : srcKey.get(b + '\u0000' + a));
354
+ if (k === undefined) throw new Error(`rotationSet has no connection '${a} & ${b}'`);
355
+ return k;
356
+ });
357
+ }
358
+
359
+ const rotation = new Float64Array(nConnections * rotCols);
360
+ connIdx.forEach((k, r) => rotation.set(rot[k], r * rotCols));
361
+ return {
362
+ rotation, rotRows: nConnections, rotCols,
363
+ nodes: codeIdx.map(i => nodes[i]),
364
+ centerVec: center ? Float64Array.from(connIdx, k => center[k]) : null,
365
+ eigenvalues: rs.eigenvalues ? Array.from(rs.eigenvalues, Number) : null,
366
+ columnNames: (rs.columnNames && rs.columnNames.length >= rotCols)
367
+ ? rs.columnNames.slice(0, rotCols).map(String)
368
+ : Array.from({ length: rotCols }, (_, d) => `Dim${d + 1}`),
369
+ };
370
+ }
371
+
275
372
  // ── shared pipeline (post-accumulation) ──────────────────────────────────────
276
373
 
374
+ // norm: { sphereNorm = true, centerAlignToOrigin = true } (= R's norm.by /
375
+ // model(normalize =) and ena.make.set's center.align.to.origin). sphereNorm
376
+ // applies to every model; centerAlignToOrigin to unordered models only --
377
+ // ordered (ONA) models always centre as ona::model() does.
277
378
  function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
278
379
  metaData, rotMethod, groupA, groupB, dims, gParams,
279
- rowConnectionCounts = null, ordered = false) {
380
+ rowConnectionCounts = null, ordered = false, rotationSet = null,
381
+ norm = {}) {
280
382
  const connectionNames = connectionNamesFor(qe, codes, ordered);
383
+ const useSphereNorm = norm.sphereNorm !== false;
384
+ const alignToOrigin = ordered || norm.centerAlignToOrigin !== false;
281
385
 
282
- // Sphere norm → lineWeights (= R's set$line.weights)
283
- const lineWeights = sphereNorm(qe, rawNetworks, nUnits, nConnections);
386
+ // Sphere norm → lineWeights (= R's set$line.weights). Without it, every
387
+ // network is scaled by the largest network's length (fun_skip_sphere_norm).
388
+ const lineWeights = useSphereNorm
389
+ ? sphereNorm(qe, rawNetworks, nUnits, nConnections)
390
+ : scaleNetworks(qe, rawNetworks, nUnits, nConnections);
284
391
 
285
392
  // Center → pointsForProjection (= R's model$points.for.projection)
286
393
  // 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.
394
+ // Zero-network units are left out of the mean and stay at the origin,
395
+ // unless centerAlignToOrigin is false (mean over, and shift of, every
396
+ // unit). Ordered (ONA) models follow ona::model(): zero-network units are
397
+ // left out of the mean but still shifted by it.
289
398
  const { centered: pointsForProjection, centerVec } =
290
- center(qe, lineWeights, nUnits, nConnections, ordered);
399
+ center(qe, lineWeights, nUnits, nConnections, ordered, !alignToOrigin);
400
+
401
+ // Custom rotation (= R's rotation.set): project into another model's
402
+ // space -- its rotation matrix and node positions, and, for unordered
403
+ // models, its centering vector (rENA's center.projection). Ordered models
404
+ // keep their own centering, as rENA.api's ordered pipeline does.
405
+ if (rotationSet) {
406
+ return runCustomRotation(qe, rawNetworks, lineWeights, pointsForProjection,
407
+ centerVec, nUnits, nConnections, codes, unitLabels,
408
+ metaData, dims, rowConnectionCounts, ordered,
409
+ resolveRotationSet(rotationSet, codes, nConnections, ordered),
410
+ alignToOrigin);
411
+ }
291
412
 
292
413
  // Rotate
293
414
  let rot;
@@ -378,6 +499,81 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
378
499
  });
379
500
  }
380
501
 
502
+ function runCustomRotation(qe, rawNetworks, lineWeights, ownCentered, ownCenterVec,
503
+ nUnits, nConnections, codes, unitLabels, metaData, dims,
504
+ rowConnectionCounts, ordered, rs, alignToOrigin = true) {
505
+ let pointsForProjection = ownCentered;
506
+ let centerVec = ownCenterVec;
507
+ if (!ordered) {
508
+ // rENA center.projection: subtract the rotation set's center vector
509
+ // (zero-network units stay at the origin unless centerAlignToOrigin
510
+ // is false, as center() leaves them).
511
+ centerVec = rs.centerVec;
512
+ pointsForProjection = new Float64Array(lineWeights.length);
513
+ for (let u = 0; u < nUnits; u++) {
514
+ let rowSum = 0;
515
+ for (let c = 0; c < nConnections; c++) rowSum += Math.abs(lineWeights[u * nConnections + c]);
516
+ if (rowSum === 0 && alignToOrigin) continue;
517
+ for (let c = 0; c < nConnections; c++) {
518
+ pointsForProjection[u * nConnections + c] = lineWeights[u * nConnections + c] - centerVec[c];
519
+ }
520
+ }
521
+ }
522
+
523
+ dims = Math.min(dims, rs.rotCols);
524
+ const rotationMatrix = new Float64Array(rs.rotRows * dims);
525
+ for (let r = 0; r < rs.rotRows; r++)
526
+ for (let d = 0; d < dims; d++)
527
+ rotationMatrix[r * dims + d] = rs.rotation[r * rs.rotCols + d];
528
+
529
+ const points = project(pointsForProjection, nUnits, nConnections,
530
+ rs.rotation, rs.rotRows, rs.rotCols, dims);
531
+
532
+ // Nodes are the rotation set's (R keeps rotation.set$nodes). Centroids:
533
+ // ordered models keep optimize()'s centroids, as rENA.api does; unordered
534
+ // ones place each unit at its normalised node-weight average of the
535
+ // pinned nodes -- libqe's node_positions weights × the rotation set's
536
+ // nodes -- so goodness of fit is measured against the nodes shown.
537
+ const nodes = new Float64Array(codes.length * dims);
538
+ rs.nodes.forEach((row, c) => { for (let d = 0; d < dims; d++) nodes[c * dims + d] = Number(row[d]) || 0; });
539
+ let centroids;
540
+ if (ordered) {
541
+ centroids = nodePositions(qe, lineWeights, nUnits, nConnections, points, dims, true).centroids;
542
+ } else {
543
+ const w = qe.node_positions(lineWeights, nUnits, nConnections, points, nUnits, dims, dims).weights;
544
+ centroids = matmul(Float64Array.from(w.data), nUnits, w.cols, nodes, dims);
545
+ }
546
+
547
+ return new ENAModel({
548
+ connectionCounts: rawNetworks,
549
+ rowConnectionCounts,
550
+ lineWeights,
551
+ points,
552
+ rotationMatrix,
553
+ metaData,
554
+ connectionNames: connectionNamesFor(qe, codes, ordered),
555
+ nUnits,
556
+ nConnections,
557
+ dims,
558
+ centroids,
559
+ // Share of the TOTAL variance, as R reports it with the full rotation
560
+ // matrix (ena.make.set: diag(var(points)) / sum over every dimension).
561
+ // A rotation is a complete basis, so that total is the variance of the
562
+ // centred, unrotated data -- which keeps the percentages right when the
563
+ // rotation set carries only the first few dimensions.
564
+ variance: computeVariance(points, nUnits, dims,
565
+ totalVariance(pointsForProjection, nUnits, nConnections)),
566
+ unitLabels,
567
+ pointsForProjection,
568
+ nodes,
569
+ columnNames: rs.columnNames.slice(0, dims),
570
+ eigenvalues: rs.eigenvalues,
571
+ centerVec,
572
+ codes,
573
+ adjacencyKey: buildAdjacencyKey(codes, ordered),
574
+ });
575
+ }
576
+
381
577
  // ── main factory ─────────────────────────────────────────────────────────────
382
578
 
383
579
  /**
@@ -409,6 +605,23 @@ export default async function loadENA() {
409
605
  * @param {number} [opts.dims=2] - Number of dimensions to return
410
606
  * @param {string[]} [opts.unitsUsed] - Unit keys to model (= R's units.used);
411
607
  * other units' rows remain as context
608
+ * @param {object} [opts.horizons] - Flexible horizons { by, rules: { value: [cols] } }
609
+ * (see tensor.js buildHorizonContexts); replaces
610
+ * the conversations as the windowing contexts
611
+ * @param {object} [opts.rotationSet] - Project into another model's space (= R's
612
+ * rotation.set): { rotationMatrix, nodes,
613
+ * centerVec, codes?, rotationCols?,
614
+ * eigenvalues?, columnNames? }; overrides
615
+ * opts.rotation
616
+ * @param {boolean} [opts.sphereNorm=true] - Sphere-normalize each network (= R's
617
+ * norm.by = fun_sphere_norm); false scales
618
+ * all networks by the longest one
619
+ * (fun_skip_sphere_norm; for ordered models,
620
+ * ona::model(normalize = skip_sphere_norm)).
621
+ * @param {boolean} [opts.centerAlignToOrigin=true] - Keep zero-network units at the
622
+ * origin and out of the centring mean (= R's
623
+ * center.align.to.origin); false centres on
624
+ * every unit. Unordered only.
412
625
  *
413
626
  * @returns {ENAModel}
414
627
  */
@@ -429,6 +642,10 @@ export default async function loadENA() {
429
642
  codeMask,
430
643
  weightModel,
431
644
  unitsUsed,
645
+ horizons,
646
+ rotationSet,
647
+ sphereNorm: useSphereNorm = true,
648
+ centerAlignToOrigin = true,
432
649
  } = opts;
433
650
 
434
651
  if (!codes?.length) throw new Error('opts.codes is required');
@@ -452,7 +669,7 @@ export default async function loadENA() {
452
669
  const { networks: rawNetworks, rowConnectionCounts } = accumulateTensor(
453
670
  qe, rows, codeMatrix, nRows, nCodes, nUnits,
454
671
  unitOf, convoGroups, tensorDef ?? defaultTensor(windowSize),
455
- ordered, binary, weight
672
+ ordered, binary, weight, horizons
456
673
  );
457
674
  const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
458
675
 
@@ -460,7 +677,8 @@ export default async function loadENA() {
460
677
 
461
678
  return runPipeline(qe, rawNetworks, nUnits, nConnections, codes,
462
679
  unitLabels, metaData, rotMethod, groupA, groupB,
463
- dims, gParams, rowConnectionCounts, ordered);
680
+ dims, gParams, rowConnectionCounts, ordered,
681
+ rotationSet, { sphereNorm: useSphereNorm, centerAlignToOrigin });
464
682
  },
465
683
 
466
684
  /**
@@ -468,7 +686,8 @@ export default async function loadENA() {
468
686
  * Returns raw (un-normalised) network vectors.
469
687
  *
470
688
  * @param {Object[]} rows
471
- * @param {object} opts - codes, units, conversations, window, binary, ordered, tensor, weightModel
689
+ * @param {object} opts - codes, units, conversations, window, binary, ordered, tensor,
690
+ * weightModel, codeMask, unitsUsed, horizons
472
691
  * @returns {{
473
692
  * connectionCounts: Float64Array,
474
693
  * rowConnectionCounts: Float64Array | null,
@@ -490,6 +709,7 @@ export default async function loadENA() {
490
709
  codeMask,
491
710
  weightModel,
492
711
  unitsUsed,
712
+ horizons,
493
713
  } = opts;
494
714
 
495
715
  const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
@@ -499,7 +719,7 @@ export default async function loadENA() {
499
719
  const { networks, rowConnectionCounts } = accumulateTensor(
500
720
  qe, rows, codeMatrix, nRows, nCodes, nUnits,
501
721
  unitOf, convoGroups, tensorDef ?? defaultTensor(windowSize),
502
- ordered, binary, weightModelName(weightModel)
722
+ ordered, binary, weightModelName(weightModel), horizons
503
723
  );
504
724
  const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
505
725
 
@@ -513,7 +733,7 @@ export default async function loadENA() {
513
733
  // (= R's ENAAccumulation$`_function.call`).
514
734
  _call: { rows, codes, units, conversations,
515
735
  window: windowSize, binary, ordered, tensor: tensorDef,
516
- weightModel, unitsUsed },
736
+ weightModel, unitsUsed, horizons },
517
737
  };
518
738
  },
519
739
 
@@ -551,7 +771,8 @@ export default async function loadENA() {
551
771
  'maxSize must be greater than minSize to compare windows.'
552
772
  );
553
773
 
554
- const { rows, codes, units, conversations, binary, ordered, weightModel, unitsUsed } = call;
774
+ const { rows, codes, units, conversations, binary, ordered, weightModel, unitsUsed,
775
+ horizons } = call;
555
776
 
556
777
  // Parse once; only the window size changes between iterations.
557
778
  const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
@@ -566,7 +787,7 @@ export default async function loadENA() {
566
787
  const { networks: raw } = accumulateTensor(
567
788
  qe, rows, codeMatrix, nRows, nCodes, nUnits,
568
789
  unitOf, convoGroups, defaultTensor(w), ordered, binary,
569
- weightModelName(weightModel)
790
+ weightModelName(weightModel), horizons
570
791
  );
571
792
  const model = runPipeline(
572
793
  qe, raw, nUnits, nConnections, codes,
@@ -594,7 +815,7 @@ export default async function loadENA() {
594
815
  // 4. Rebuild the accumulation at the selected window size.
595
816
  return api.accumulate(rows, {
596
817
  codes, units, conversations, window: bestWindow, binary, ordered,
597
- weightModel, unitsUsed,
818
+ weightModel, unitsUsed, horizons,
598
819
  });
599
820
  },
600
821
 
@@ -727,7 +948,11 @@ export default async function loadENA() {
727
948
  * @param {object} opts - fit() options (codes, units, conversations,
728
949
  * window, binary, ordered, tensor, weightModel,
729
950
  * codeMask, rotation, groupA, groupB, gParams,
730
- * unitsUsed)
951
+ * unitsUsed, horizons, sphereNorm,
952
+ * centerAlignToOrigin). A rotationSet is
953
+ * ignored: it cannot rotate the reduced
954
+ * models, so every candidate is scored in
955
+ * opts.rotation.
731
956
  * plus removeNum (default 3) and threshold
732
957
  * (default 0.95). `dims` is ignored: scoring
733
958
  * uses dims 1:2 and the variance uses all dims.
@@ -740,7 +965,8 @@ export default async function loadENA() {
740
965
  window: windowSize = 4, binary = true, ordered = false,
741
966
  tensor: tensorDef, weightModel, codeMask,
742
967
  rotation = 'svd', gParams, groupA, groupB,
743
- removeNum = 3, threshold = 0.95, unitsUsed,
968
+ removeNum = 3, threshold = 0.95, unitsUsed, horizons,
969
+ sphereNorm = true, centerAlignToOrigin = true,
744
970
  } = opts;
745
971
  if (!codes?.length) throw new Error('opts.codes is required');
746
972
  if (!units?.length) throw new Error('opts.units is required');
@@ -758,7 +984,7 @@ export default async function loadENA() {
758
984
  const { networks: raw } = accumulateTensor(
759
985
  qe, rows, codeMatrix, nRows, nCodes, nUnits,
760
986
  unitOf, convoGroups, tensorDef ?? defaultTensor(windowSize),
761
- ordered, binary, weightModelName(weightModel)
987
+ ordered, binary, weightModelName(weightModel), horizons
762
988
  );
763
989
  const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
764
990
  applyCodeMask(raw, codeMask, nCodes, nUnits, nConnections, ordered);
@@ -794,7 +1020,8 @@ export default async function loadENA() {
794
1020
  }
795
1021
  return runPipeline(qe, networks, nUnits, nConn, keptCodes,
796
1022
  unitLabels, metaData, rotation, groupA, groupB,
797
- nConn, gParams, null, ordered);
1023
+ nConn, gParams, null, ordered, null,
1024
+ { sphereNorm, centerAlignToOrigin });
798
1025
  };
799
1026
 
800
1027
  const D = 2; // score on the first 2 dims (R's get_pria_scores_2Ds)
package/src/pipeline.js CHANGED
@@ -127,6 +127,16 @@ export function sphereNorm(qe, networks, nUnits, nConnections) {
127
127
  return new Float64Array(result.data);
128
128
  }
129
129
 
130
+ /**
131
+ * Scale every network by the reciprocal of the largest row L2 norm, keeping
132
+ * relative magnitudes across units (= rENA's fun_skip_sphere_norm, used when
133
+ * sphere normalization is off).
134
+ */
135
+ export function scaleNetworks(qe, networks, nUnits, nConnections) {
136
+ const result = qe.scale_networks(networks, nUnits, nConnections);
137
+ return new Float64Array(result.data);
138
+ }
139
+
130
140
  // ── centering ─────────────────────────────────────────────────────────────────
131
141
 
132
142
  /**
@@ -137,17 +147,21 @@ export function sphereNorm(qe, networks, nUnits, nConnections) {
137
147
  * rows, as rENA's center(exclude_zero_networks = TRUE) does -- the ONA
138
148
  * convention (ona::model). By default zero-network rows stay at zero, as in
139
149
  * rENA's ena.make.set().
150
+ * @param {boolean} [includeZeroRows=false] Take the mean over every row,
151
+ * zero networks included, and shift every row by it: rENA's
152
+ * ena.make.set(center.align.to.origin = FALSE). Overrides shiftZeroRows.
140
153
  * @returns {{ centered: Float64Array, centerVec: Float64Array }}
141
154
  * centered — mean-subtracted networks
142
155
  * centerVec — the column means used for centering (= R's rotation$center.vec)
143
156
  */
144
- export function center(qe, networks, nUnits, nConnections, shiftZeroRows = false) {
145
- // Identify non-zero rows
157
+ export function center(qe, networks, nUnits, nConnections, shiftZeroRows = false,
158
+ includeZeroRows = false) {
159
+ // Identify non-zero rows (every row when zero networks are included)
146
160
  const active = [];
147
161
  for (let u = 0; u < nUnits; u++) {
148
162
  let rowSum = 0;
149
163
  for (let c = 0; c < nConnections; c++) rowSum += Math.abs(networks[u * nConnections + c]);
150
- if (rowSum > 0) active.push(u);
164
+ if (rowSum > 0 || includeZeroRows) active.push(u);
151
165
  }
152
166
 
153
167
  // Compute column means over active rows only (= R's rotation$center.vec)
@@ -164,7 +178,7 @@ export function center(qe, networks, nUnits, nConnections, shiftZeroRows = false
164
178
  const activeSet = new Set(active);
165
179
  const centered = new Float64Array(networks.length);
166
180
  for (let u = 0; u < nUnits; u++) {
167
- if (!shiftZeroRows && !activeSet.has(u)) continue; // leave zero-network row as zero
181
+ if (!shiftZeroRows && !includeZeroRows && !activeSet.has(u)) continue; // leave zero-network row as zero
168
182
  for (let c = 0; c < nConnections; c++) {
169
183
  centered[u * nConnections + c] = networks[u * nConnections + c] - means[c];
170
184
  }
package/src/tensor.js CHANGED
@@ -36,9 +36,25 @@
36
36
  *
37
37
  * // Optional column name for per-row timestamps.
38
38
  * // Defaults to row index (0, 1, 2, ...) — equivalent to unit-step time.
39
+ * // Numeric columns are used as-is; otherwise every value is parsed as
40
+ * // elapsed time (MM:SS, HH:MM:SS) or a date-time, in seconds (parseTimes).
39
41
  * timesCol: 'timestamp',
42
+ *
43
+ * // Optional unit the windows in `data` are expressed in when timesCol is
44
+ * // set: 'secs' (default) | 'mins' | 'hours' | 'days' | 'weeks'. Windows
45
+ * // are converted to seconds before accumulation, as rENA.api does with
46
+ * // mins_to_seconds() etc.
47
+ * timeUnit: 'mins',
48
+ *
49
+ * // Optional end-time column (rENA.api's timeend_column). A numeric column
50
+ * // is a duration added to the start time; anything else is parsed as the
51
+ * // row's end timestamp. Either way the row's time becomes its end time.
52
+ * timesEndCol: 'duration',
40
53
  * }
41
54
  *
55
+ * A window of Infinity (e.g. defaultTensor(Infinity), = rENA's
56
+ * window.size.back = Inf) includes every earlier line of the conversation.
57
+ *
42
58
  * Simple windowed accumulation (IS_DEFAULT path):
43
59
  * Pass `{ dims: [2], dimsSender: [], dimsReceiver: [], dimsMode: [],
44
60
  * factors: [], data: Float64Array.of(weight, window) }`
@@ -75,25 +91,123 @@ export function buildContextLookup(rows, rowIndices, factors, factorLevels) {
75
91
  }
76
92
 
77
93
  /**
78
- * Build the context_lookup with explicit timestamp column.
94
+ * Build the context_lookup with explicit per-row times.
95
+ *
96
+ * @param {Float64Array} allTimes time of every row in `rows` (see parseTimes)
79
97
  */
80
- export function buildContextLookupWithTimes(rows, rowIndices, factors, factorLevels, timesCol) {
81
- const nRows = rowIndices.length;
82
- const nFactor = factors.length;
98
+ export function buildContextLookupWithTimes(rows, rowIndices, factors, factorLevels, allTimes) {
99
+ const ctx = buildContextLookup(rows, rowIndices, factors, factorLevels);
100
+ for (let i = 0; i < rowIndices.length; i++) ctx.times[i] = allTimes[rowIndices[i]];
101
+ return ctx;
102
+ }
83
103
 
84
- const contextLookup = new Int32Array(nRows * nFactor);
85
- const times = new Float64Array(nRows);
104
+ // Seconds per window unit (= rENA.api's mins_to_seconds, hours_to_seconds, ...).
105
+ const SECONDS_PER = { secs: 1, mins: 60, hours: 3600, days: 86400, weeks: 604800 };
86
106
 
87
- for (let i = 0; i < nRows; i++) {
88
- const row = rows[rowIndices[i]];
89
- times[i] = Number(row[timesCol]) || i;
90
- for (let f = 0; f < nFactor; f++) {
91
- const col = factors[f];
92
- contextLookup[i * nFactor + f] = factorLevels[col][row[col]] ?? 0;
107
+ /**
108
+ * Seconds per unit of a time-window unit name ('secs', 'mins', 'hours',
109
+ * 'days', 'weeks'; quotes and a trailing 's' are optional). 'lines' and empty
110
+ * values return null (no time scaling).
111
+ */
112
+ export function secondsPerTimeUnit(unit) {
113
+ if (unit == null) return null;
114
+ let u = String(unit).replace(/['"]/g, '').trim().toLowerCase();
115
+ if (u === '' || u === 'lines' || u === 'auto') return null;
116
+ if (u === 'minutes' || u === 'min') u = 'mins';
117
+ if (u === 'seconds' || u === 'sec') u = 'secs';
118
+ if (!u.endsWith('s')) u += 's';
119
+ if (!(u in SECONDS_PER)) throw new Error(`Unknown time unit '${unit}'`);
120
+ return SECONDS_PER[u];
121
+ }
122
+
123
+ const pad2 = (s) => (s.length === 1 ? '0' + s : s);
124
+
125
+ // Date-time formats rENA.api's parse_date tries, in the same order. Each
126
+ // pattern captures the fields named in `order`; a match is accepted only when
127
+ // the fields form a real date (so 9/17/2013 is rejected as d/m/Y and falls
128
+ // through to m/d/Y, exactly as as.POSIXct returns NA for it in R).
129
+ const DATE_FORMATS = [
130
+ { re: /^(\d{4})-(\d{2})-(\d{2})$/, order: 'Ymd' },
131
+ { re: /^(\d{2})\/(\d{2})\/(\d{4})$/, order: 'dmY' },
132
+ { re: /^(\d{2})\/(\d{2})\/(\d{4})$/, order: 'mdY' },
133
+ { re: /^(\d{4})\/(\d{2})\/(\d{2})$/, order: 'Ymd' },
134
+ { re: /^(\d{2})-(\d{2})-(\d{4})$/, order: 'mdY' },
135
+ { re: /^(\d{2})-(\d{2})-(\d{4})$/, order: 'dmY' },
136
+ { re: /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/, order: 'YmdHMS' },
137
+ { re: /^(\d{2})\/(\d{2})\/(\d{4}) (\d{2}):(\d{2}):(\d{2})$/, order: 'dmYHMS' },
138
+ { re: /^(\d{2})\/(\d{2})\/(\d{4}) (\d{2}):(\d{2}):(\d{2})$/, order: 'mdYHMS' },
139
+ { re: /^(\d{2})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2})$/, order: 'mdyHM' },
140
+ { re: /^(\d{2})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2})$/, order: 'dmyHM' },
141
+ { re: /^(\d{1,2})\/(\d{1,2})\/(\d{4}) (\d{2}):(\d{2})$/, order: 'mdYHM' },
142
+ { re: /^(\d{1,2})\/(\d{1,2})\/(\d{2}) (\d{2}):(\d{2})$/, order: 'mdyHM' },
143
+ ];
144
+
145
+ /**
146
+ * Parse one time value to seconds (= rENA.api's parse_date): elapsed
147
+ * "MM:SS" / "HH:MM:SS" → total seconds; a date-time → seconds since the
148
+ * epoch (UTC). Returns NaN when the value matches no supported format.
149
+ */
150
+ export function parseTimeValue(value) {
151
+ let s = String(value).trim();
152
+ s = s.replace(/ (\d):/g, ' 0$1:')
153
+ .replace(/^(\d)(\/|-)/, '0$1$2')
154
+ .replace(/(\/|-)(\d)(\/|-)/g, (_, a, d, b) => a + '0' + d + b);
155
+
156
+ let m;
157
+ if ((m = /^(\d{1,2}):(\d{2})$/.exec(s))) return (+m[1]) * 60 + (+m[2]);
158
+ if ((m = /^(\d{1,3}):(\d{2}):(\d{2})$/.exec(s))) return (+m[1]) * 3600 + (+m[2]) * 60 + (+m[3]);
159
+
160
+ for (const { re, order } of DATE_FORMATS) {
161
+ if (!(m = re.exec(s))) continue;
162
+ const f = { Y: null, m: 1, d: 1, H: 0, M: 0, S: 0 };
163
+ for (let i = 0; i < order.length; i++) {
164
+ const v = +m[i + 1];
165
+ if (order[i] === 'y') f.Y = v <= 68 ? 2000 + v : 1900 + v; // R's %y pivot
166
+ else f[order[i]] = v;
93
167
  }
168
+ if (f.m < 1 || f.m > 12 || f.d < 1 || f.H > 23 || f.M > 59 || f.S > 61) continue;
169
+ const ms = Date.UTC(f.Y, f.m - 1, f.d, f.H, f.M, f.S);
170
+ if (new Date(ms).getUTCDate() !== f.d) continue; // e.g. 31/02
171
+ return ms / 1000;
94
172
  }
173
+ return NaN;
174
+ }
95
175
 
96
- return { contextLookup, clRows: nRows, clCols: nFactor, times };
176
+ /**
177
+ * Per-row times, in seconds, for a time column (= rENA.api's handling of
178
+ * time_column / timeend_column before tma::accumulate). A column whose values
179
+ * are all numeric is used as-is; otherwise every value is parsed with
180
+ * parseTimeValue(). With `endCol`, a numeric end column is a duration added
181
+ * to the start, and any other end column is parsed as the end timestamp.
182
+ *
183
+ * @param {Object[]} rows
184
+ * @param {string} col
185
+ * @param {string|null} [endCol=null]
186
+ * @returns {Float64Array}
187
+ * @throws when a value cannot be parsed as a time
188
+ */
189
+ export function parseTimes(rows, col, endCol = null) {
190
+ const column = (name) => {
191
+ const vals = rows.map(r => r[name]);
192
+ const blank = (v) => v === undefined || v === null || String(v).trim() === '';
193
+ const nums = vals.map(v => (blank(v) ? NaN : Number(v)));
194
+ const numeric = nums.every((n, i) => !Number.isNaN(n) || blank(vals[i]));
195
+ const out = new Float64Array(rows.length);
196
+ for (let i = 0; i < rows.length; i++) {
197
+ const t = numeric ? nums[i] : parseTimeValue(vals[i]);
198
+ if (Number.isNaN(t)) {
199
+ throw new Error(`Unable to read '${vals[i]}' (row ${i + 1}) in time column '${name}' as a time`);
200
+ }
201
+ out[i] = t;
202
+ }
203
+ return { out, numeric };
204
+ };
205
+
206
+ const start = column(col).out;
207
+ if (!endCol) return start;
208
+ const end = column(endCol);
209
+ if (end.numeric) for (let i = 0; i < start.length; i++) end.out[i] += start[i];
210
+ return end.out;
97
211
  }
98
212
 
99
213
  /**
@@ -119,6 +233,79 @@ export function inferFactorLevels(rows, factors) {
119
233
  return levels;
120
234
  }
121
235
 
236
+ // libqe's apply_tensor_unit reads the IS_DEFAULT window as an int, so an
237
+ // infinite window (or one too large for int32) is clamped to INT32_MAX — still
238
+ // longer than any conversation, in lines or in seconds.
239
+ const MAX_WINDOW = 2147483647;
240
+
241
+ /**
242
+ * The tensor data libqe accumulates with: windows (the second half of the
243
+ * column-major data, index 1 along the last axis) converted from `timeUnit`
244
+ * to seconds when a time column is in use, and infinite / oversized windows
245
+ * clamped to MAX_WINDOW. Weights are left unchanged.
246
+ */
247
+ export function resolveTensorData(tensorDef) {
248
+ const data = Float64Array.from(tensorDef.data);
249
+ const nComb = data.length / 2;
250
+ const scale = tensorDef.timesCol ? (secondsPerTimeUnit(tensorDef.timeUnit) ?? 1) : 1;
251
+ for (let c = nComb; c < data.length; c++) {
252
+ const w = data[c] * scale;
253
+ data[c] = (Number.isNaN(w) || w > MAX_WINDOW) ? MAX_WINDOW : w;
254
+ }
255
+ return data;
256
+ }
257
+
258
+ /**
259
+ * Per-unit horizon-of-observation contexts (= tma::contexts with rENA.api's
260
+ * flexible-horizon rules). `horizons.rules` maps each value of the
261
+ * discriminator column `horizons.by` to the columns that define that
262
+ * horizon. A row with discriminator value x is in unit U's context when every
263
+ * rule-x column holds a value U has on at least one of its rows
264
+ * (`col %in% UNIT$col`); rows whose value has no rule are in no context. Each
265
+ * unit's context is then split on the union of every rule's columns (NOT on
266
+ * the discriminator, which only picks the rule), and the moving window runs
267
+ * within each piece.
268
+ *
269
+ * @param {Object[]} rows
270
+ * @param {Int32Array} unitOf unit index per row (-1 = not a modeled unit)
271
+ * @param {number} nUnits
272
+ * @param {{ by: string, rules: Object<string, string[]> }} horizons
273
+ * @returns {number[][][]} per unit, its context pieces as ascending row indices
274
+ */
275
+ export function buildHorizonContexts(rows, unitOf, nUnits, horizons) {
276
+ const { by, rules } = horizons || {};
277
+ if (!by) throw new Error('horizons.by (the discriminator column) is required');
278
+ const ruleCols = new Map();
279
+ for (const [value, cols] of Object.entries(rules || {})) {
280
+ if (!Array.isArray(cols) || cols.length === 0) {
281
+ throw new Error(`horizon '${value}' of '${by}' has no columns assigned`);
282
+ }
283
+ ruleCols.set(String(value), cols);
284
+ }
285
+ if (ruleCols.size === 0) throw new Error('horizons.rules must define at least one horizon');
286
+ const splitCols = [...new Set([].concat(...ruleCols.values()))];
287
+
288
+ // UNIT$col: every value each unit takes on each rule column.
289
+ const unitVals = Array.from({ length: nUnits }, () => new Map(splitCols.map(c => [c, new Set()])));
290
+ for (let i = 0; i < rows.length; i++) {
291
+ const u = unitOf[i];
292
+ if (u < 0) continue;
293
+ for (const c of splitCols) unitVals[u].get(c).add(String(rows[i][c]));
294
+ }
295
+
296
+ return unitVals.map((vals) => {
297
+ const pieces = new Map(); // split key → row indices (first-appearance order)
298
+ for (let i = 0; i < rows.length; i++) {
299
+ const cols = ruleCols.get(String(rows[i][by]));
300
+ if (!cols || !cols.every(c => vals.get(c).has(String(rows[i][c])))) continue;
301
+ const key = splitCols.map(c => String(rows[i][c])).join('\u0000');
302
+ if (!pieces.has(key)) pieces.set(key, []);
303
+ pieces.get(key).push(i);
304
+ }
305
+ return [...pieces.values()];
306
+ });
307
+ }
308
+
122
309
  /**
123
310
  * Accumulate tensor networks for all units across all conversations.
124
311
  *
@@ -141,6 +328,9 @@ export function inferFactorLevels(rows, factors) {
141
328
  * @param {string|null} [weight=null] libqe weight model applied per line before
142
329
  * the unit sum: 'binary' | 'product' |
143
330
  * 'sqrt' | 'log1p'
331
+ * @param {object|null} [horizons=null] flexible horizons (see
332
+ * buildHorizonContexts); replaces
333
+ * convoGroups as the windowing contexts
144
334
  *
145
335
  * @returns {{ networks: Float64Array, rowConnectionCounts: Float64Array }}
146
336
  * networks nUnits × nConnections, row-major
@@ -150,19 +340,21 @@ export function inferFactorLevels(rows, factors) {
150
340
  */
151
341
  export function accumulateTensor(qe, rows, codeMatrix, nRows, nCodes, nUnits,
152
342
  unitOf, convoGroups, tensorDef, ordered = false,
153
- binary = true, weight = null) {
343
+ binary = true, weight = null, horizons = null) {
154
344
  const {
155
345
  dims,
156
346
  dimsSender = [],
157
347
  dimsReceiver = [],
158
348
  dimsMode = [],
159
349
  factors = [],
160
- data: tensorData,
161
350
  timesCol,
351
+ timesEndCol,
162
352
  } = tensorDef;
353
+ const tensorData = resolveTensorData(tensorDef);
163
354
 
164
355
  // Resolve factor levels (infer if not provided)
165
356
  const factorLevels = tensorDef.factorLevels ?? inferFactorLevels(rows, factors);
357
+ const allTimes = timesCol ? parseTimes(rows, timesCol, timesEndCol || null) : null;
166
358
 
167
359
  const nConnections = ordered
168
360
  ? nCodes * nCodes
@@ -179,74 +371,91 @@ export function accumulateTensor(qe, rows, codeMatrix, nRows, nCodes, nUnits,
179
371
  const receiverArr = new Int32Array(dimsReceiver);
180
372
  const modeArr = new Int32Array(dimsMode);
181
373
 
182
- for (const [, rowIndices] of convoGroups) {
183
- const nConvo = rowIndices.length;
374
+ // Accumulate `unit`'s response rows (`localRows`, indices into
375
+ // `rowIndices`) within one context of rows.
376
+ const accumulateContext = (rowIndices, unit, localRows) => {
377
+ const nCtx = rowIndices.length;
184
378
 
185
- // Extract code rows for this conversation
186
- const convoCodes = new Float64Array(nConvo * nCodes);
187
- for (let r = 0; r < nConvo; r++) {
379
+ // Extract code rows for this context
380
+ const ctxCodes = new Float64Array(nCtx * nCodes);
381
+ for (let r = 0; r < nCtx; r++) {
188
382
  const src = rowIndices[r];
189
- convoCodes.set(
383
+ ctxCodes.set(
190
384
  codeMatrix.subarray(src * nCodes, src * nCodes + nCodes),
191
385
  r * nCodes
192
386
  );
193
387
  }
194
388
 
195
- // Build context_lookup and times for this conversation
196
- const { contextLookup, clRows, clCols, times } = timesCol
197
- ? buildContextLookupWithTimes(rows, rowIndices, factors, factorLevels, timesCol)
389
+ // Build context_lookup and times for this context
390
+ const { contextLookup, clRows, clCols, times } = allTimes
391
+ ? buildContextLookupWithTimes(rows, rowIndices, factors, factorLevels, allTimes)
198
392
  : buildContextLookup(rows, rowIndices, factors, factorLevels);
199
393
 
394
+ // Always accumulate the DIRECTED per-response-row counts (ordered
395
+ // kernel), exactly as tma does — it calls apply_tensor with the
396
+ // default ordered=TRUE and defers the ordered-vs-unordered decision
397
+ // to aggregation. Passing the unordered flag here would make the
398
+ // kernel emit an already-symmetric matrix that the fold below would
399
+ // then double-count.
400
+ const result = qe.accumulate_tensor_unit(
401
+ tensorData, dimsArr,
402
+ senderArr, receiverArr, modeArr,
403
+ contextLookup, clRows, clCols,
404
+ new Int32Array(localRows),
405
+ ctxCodes, nCtx, nCodes,
406
+ times,
407
+ true
408
+ );
409
+
410
+ // Finalise each raw per-response-row connection vector with libqe's
411
+ // shared kernel, exactly as tma does in R: fold to the upper
412
+ // triangle (unordered) or keep the directed row (ordered), then
413
+ // apply the weight model per line (binary clamp, product, sqrt,
414
+ // log1p) before the per-unit sum. Row i is local row localRows[i].
415
+ const rcc = result.row_connection_counts;
416
+ const fin = qe.finalize_row_connections(
417
+ rcc.data, rcc.rows, rcc.cols, nCodes, ordered, kernelWeight
418
+ );
419
+ const unitOff = unit * nConnections;
420
+ for (let i = 0; i < fin.rows; i++) {
421
+ const rowOff = rowIndices[localRows[i]] * nConnections;
422
+ const finOff = i * fin.cols;
423
+ for (let c = 0; c < nConnections; c++) {
424
+ const v = fin.data[finOff + c];
425
+ rowConnectionCounts[rowOff + c] = v;
426
+ networks[unitOff + c] += v;
427
+ }
428
+ }
429
+ };
430
+
431
+ if (horizons) {
432
+ // Flexible horizons: every unit has its own contexts.
433
+ const unitContexts = buildHorizonContexts(rows, unitOf, nUnits, horizons);
434
+ unitContexts.forEach((pieces, unit) => {
435
+ for (const rowIndices of pieces) {
436
+ const localRows = [];
437
+ for (let r = 0; r < rowIndices.length; r++) {
438
+ if (unitOf[rowIndices[r]] === unit) localRows.push(r);
439
+ }
440
+ if (localRows.length) accumulateContext(rowIndices, unit, localRows);
441
+ }
442
+ });
443
+ return { networks, rowConnectionCounts };
444
+ }
445
+
446
+ for (const [, rowIndices] of convoGroups) {
200
447
  // Group response rows by unit (within this conversation). Rows with
201
448
  // no unit (unitOf = -1, excluded via unitsUsed) are never responses,
202
- // but stay in convoCodes as context for the other units' windows.
449
+ // but stay in the context for the other units' windows.
203
450
  const unitConvoRows = new Map();
204
- for (let r = 0; r < nConvo; r++) {
451
+ for (let r = 0; r < rowIndices.length; r++) {
205
452
  const unit = unitOf[rowIndices[r]];
206
453
  if (unit < 0) continue;
207
454
  if (!unitConvoRows.has(unit)) unitConvoRows.set(unit, []);
208
455
  unitConvoRows.get(unit).push(r); // local (conversation-relative) index
209
456
  }
210
-
211
- // Accumulate per unit
212
457
  for (const [unit, localRows] of unitConvoRows) {
213
- const unitRowsArr = new Int32Array(localRows);
214
-
215
- // Always accumulate the DIRECTED per-response-row counts (ordered
216
- // kernel), exactly as tma does — it calls apply_tensor with the
217
- // default ordered=TRUE and defers the ordered-vs-unordered decision
218
- // to aggregation. Passing the unordered flag here would make the
219
- // kernel emit an already-symmetric matrix that the fold below would
220
- // then double-count.
221
- const result = qe.accumulate_tensor_unit(
222
- tensorData, dimsArr,
223
- senderArr, receiverArr, modeArr,
224
- contextLookup, clRows, clCols,
225
- unitRowsArr,
226
- convoCodes, nConvo, nCodes,
227
- times,
228
- true
229
- );
230
-
231
- // Finalise each raw per-response-row connection vector with libqe's
232
- // shared kernel, exactly as tma does in R: fold to the upper
233
- // triangle (unordered) or keep the directed row (ordered), then
234
- // apply the weight model per line (binary clamp, product, sqrt,
235
- // log1p) before the per-unit sum. Row i is local row localRows[i].
236
- const rcc = result.row_connection_counts;
237
- const fin = qe.finalize_row_connections(
238
- rcc.data, rcc.rows, rcc.cols, nCodes, ordered, kernelWeight
239
- );
240
- const unitOff = unit * nConnections;
241
- for (let i = 0; i < fin.rows; i++) {
242
- const rowOff = rowIndices[localRows[i]] * nConnections;
243
- const finOff = i * fin.cols;
244
- for (let c = 0; c < nConnections; c++) {
245
- const v = fin.data[finOff + c];
246
- rowConnectionCounts[rowOff + c] = v;
247
- networks[unitOff + c] += v;
248
- }
249
- }
458
+ accumulateContext(rowIndices, unit, localRows);
250
459
  }
251
460
  }
252
461