@qe-libs/rena-wasm 0.1.0

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 ADDED
@@ -0,0 +1,164 @@
1
+ # @qe-libs/rena-wasm
2
+
3
+ JavaScript/WebAssembly ENA pipeline — thin orchestration layer over
4
+ [`@qe-libs/libqe-wasm`](../../libqe/wasm/README.md).
5
+
6
+ Handles data parsing, unit/conversation grouping, and the full
7
+ accumulate → normalize → center → rotate → project → node-positions pipeline.
8
+ All math is delegated to the libqe WASM module; no C++ compilation required here.
9
+
10
+ ---
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ npm install @qe-libs/rena-wasm
16
+ ```
17
+
18
+ ---
19
+
20
+ ## Quick Start
21
+
22
+ ```js
23
+ import loadENA from '@qe-libs/rena-wasm';
24
+
25
+ const ena = await loadENA();
26
+
27
+ const model = ena.fit(rows, {
28
+ codes: ['Data', 'Technical.Constraints', 'Performance.Parameters'],
29
+ units: ['UserName', 'Condition'],
30
+ conversations: ['Condition', 'GroupName'],
31
+ window: 4,
32
+ dims: 2,
33
+ });
34
+
35
+ model.centroids // Float64Array nUnits × dims
36
+ model.networks // Float64Array nUnits × nConnections (normed)
37
+ model.positions // Float64Array nCodes × dims
38
+ model.connectionNames // ['Data & Technical.Constraints', ...]
39
+ model.unitLabels // ['UserName1_ConditionA', ...]
40
+ model.columnNames // ['SVD1', 'SVD2']
41
+
42
+ // Per-unit helpers
43
+ model.centroid('Alice_A') // number[] length = dims
44
+ model.network('Alice_A') // number[] length = nConnections
45
+ ```
46
+
47
+ ---
48
+
49
+ ## Rotation Methods
50
+
51
+ | Method | `rotation` option | Extra options |
52
+ |---|---|---|
53
+ | SVD (default) | `'svd'` | — |
54
+ | Means | `'mean'` | `groupA: [unitIdx, ...]`, `groupB: [unitIdx, ...]` |
55
+
56
+ ```js
57
+ // Means rotation — groupA/groupB are unit indices (position in unitLabels)
58
+ const model = ena.fit(rows, {
59
+ codes, units, conversations,
60
+ rotation: 'mean',
61
+ groupA: [0, 1, 2],
62
+ groupB: [3, 4, 5],
63
+ });
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Accumulation Only
69
+
70
+ Returns raw (un-normalised) network vectors without running the full pipeline.
71
+
72
+ ```js
73
+ const { networks, unitLabels, connectionNames, nUnits, nConnections } =
74
+ ena.accumulate(rows, { codes, units, conversations, window: 4 });
75
+ ```
76
+
77
+ ---
78
+
79
+ ## Advanced: Context Tensor Accumulation
80
+
81
+ Context tensors give per-factor-combination control over window sizes and
82
+ weights — the JS equivalent of `tma::accumulate_contexts()` with HOO rules.
83
+
84
+ The tensor is a multi-dimensional array whose last axis is always size 2
85
+ (index 0 = weight, index 1 = window). Earlier axes correspond to factor
86
+ columns in the data.
87
+
88
+ ```js
89
+ // Example: sender role (T=Teacher, S=Student) controls the window size.
90
+ // dims = [nRoleValues, 2] → Teacher uses window=4, Student uses window=2.
91
+ const model = ena.fit(rows, {
92
+ codes, units, conversations,
93
+ ordered: true, // directed (n² connections) when using tensors
94
+ tensor: {
95
+ dims: [2, 2], // [nRoleValues=2, weight/window=2]
96
+ dimsSender: [0], // axis 0 is a sender factor
97
+ dimsReceiver: [],
98
+ dimsMode: [],
99
+ factors: ['Role'], // column in the data
100
+ // Optional: explicit value → index mapping. Inferred automatically if omitted.
101
+ factorLevels: { Role: { 'Teacher': 0, 'Student': 1 } },
102
+ // Flat column-major: [weight_T, weight_S, window_T, window_S]
103
+ data: Float64Array.of(1, 1, 4, 2),
104
+ },
105
+ });
106
+ ```
107
+
108
+ ### Tensor layout
109
+
110
+ The `data` array is **column-major** with shape `dims`. The last axis
111
+ selects weight (`0`) or window (`1`). For `dims = [nA, nB, 2]`:
112
+
113
+ ```
114
+ data[a + nA*b + nA*nB*0] → weight for factor combination (a, b)
115
+ data[a + nA*b + nA*nB*1] → window for factor combination (a, b)
116
+ ```
117
+
118
+ ### Factor axis roles
119
+
120
+ | Option | Meaning |
121
+ |---|---|
122
+ | `dimsSender` | These axes use the *ground* row's factor values when looking up the window |
123
+ | `dimsReceiver` | These axes use the *response* row's factor values (overrides ground) |
124
+ | `dimsMode` | These axes use a shared mode value |
125
+
126
+ ### `defaultTensor` helper
127
+
128
+ Express simple windowed accumulation as a tensor (IS_DEFAULT path):
129
+
130
+ ```js
131
+ import { defaultTensor } from '@qe-libs/rena-wasm/src/tensor.js';
132
+
133
+ const tensor = defaultTensor(4); // window=4, weight=1
134
+ const tensor = defaultTensor(4, 0.5); // window=4, weight=0.5
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Input Format
140
+
141
+ `rows` is an array of plain objects — one per utterance/event.
142
+
143
+ ```js
144
+ const rows = [
145
+ { UserName: 'Alice', Condition: 'A', GroupName: 'G1', Role: 'Teacher', Data: 1, Reasoning: 0 },
146
+ ...
147
+ ];
148
+ ```
149
+
150
+ Code column values should be numeric (0/1 for binary codes). Factor column
151
+ values can be any string or number — they are mapped to 0-based indices
152
+ automatically unless `factorLevels` is provided explicitly.
153
+
154
+ ---
155
+
156
+ ## Testing
157
+
158
+ ```bash
159
+ npm install
160
+ npm test
161
+ ```
162
+
163
+ Tests cover the simple windowed pipeline (`test/ena.test.js`) and the
164
+ context-tensor path (`test/tensor.test.js`).
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@qe-libs/rena-wasm",
3
+ "version": "0.1.0",
4
+ "description": "JavaScript/WebAssembly ENA pipeline — thin orchestration layer over @qe-libs/libqe-wasm",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "files": [
8
+ "src/"
9
+ ],
10
+ "scripts": {
11
+ "test": "node --experimental-vm-modules node_modules/.bin/jest"
12
+ },
13
+ "publishConfig": {
14
+ "registry": "https://gitlab.com/api/v4/projects/22522458/packages/npm/"
15
+ },
16
+ "keywords": ["wasm", "ena", "epistemic-network-analysis", "quantitative-ethnography"],
17
+ "license": "GPL-3.0-only",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://gitlab.com/epistemic-analytics/qe-packages/rENA",
21
+ "directory": "wasm"
22
+ },
23
+ "jest": {
24
+ "testEnvironment": "node",
25
+ "transform": {},
26
+ "resolver": "<rootDir>/jest-resolver.cjs"
27
+ },
28
+ "dependencies": {
29
+ "@qe-libs/libqe-wasm": "^0.1.0"
30
+ },
31
+ "devDependencies": {
32
+ "jest": "^29.0.0"
33
+ }
34
+ }
package/src/data.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * data.js — Data parsing and unit/conversation grouping for ENA.
3
+ *
4
+ * Converts an array of row objects (e.g. parsed CSV rows) into the flat
5
+ * Float64Array code matrix and unit/conversation index structures that the
6
+ * pipeline needs. Also extracts per-unit metadata (non-code, non-unit,
7
+ * non-conversation columns), mirroring R's enadata$metadata / set$meta.data.
8
+ */
9
+
10
+ /**
11
+ * Build a composite key from multiple column values.
12
+ * @param {Object} row
13
+ * @param {string[]} cols
14
+ * @returns {string}
15
+ */
16
+ export function rowKey(row, cols) {
17
+ return cols.map(c => row[c]).join('__');
18
+ }
19
+
20
+ /**
21
+ * Parse tabular data into ENA-ready structures.
22
+ *
23
+ * @param {Object[]} rows - Array of row objects (one per utterance/event)
24
+ * @param {string[]} codes - Code column names
25
+ * @param {string[]} unitCols - Column(s) that identify a unit (e.g. ['UserName', 'Condition'])
26
+ * @param {string[]} convoCols - Column(s) that identify a conversation (e.g. ['Condition', 'GroupName'])
27
+ *
28
+ * @returns {{
29
+ * codeMatrix: Float64Array, // n_rows × n_codes, row-major
30
+ * nRows: number,
31
+ * nCodes: number,
32
+ * nUnits: number,
33
+ * unitLabels: string[], // unit key for each unit index
34
+ * unitOf: Int32Array, // unit index for each row
35
+ * convoOf: Int32Array, // conversation index for each row
36
+ * convoGroups: Map<number, number[]>, // convoIdx → [rowIdx, ...]
37
+ * metaData: Object[], // one metadata object per unit (first-row representative)
38
+ * metaCols: string[], // names of metadata columns
39
+ * }}
40
+ */
41
+ export function parseData(rows, codes, unitCols, convoCols) {
42
+ const nRows = rows.length;
43
+ const nCodes = codes.length;
44
+
45
+ const codeMatrix = new Float64Array(nRows * nCodes);
46
+ const unitOf = new Int32Array(nRows);
47
+ const convoOf = new Int32Array(nRows);
48
+
49
+ const unitIndex = new Map(); // key → index
50
+ const convoIndex = new Map(); // key → index
51
+ const convoGroups = new Map(); // convoIdx → [rowIdx, ...]
52
+
53
+ // Metadata columns: every column that is NOT a code, unit, or convo column.
54
+ const excludedCols = new Set([...codes, ...unitCols, ...convoCols]);
55
+ const allCols = nRows > 0 ? Object.keys(rows[0]) : [];
56
+ const metaCols = allCols.filter(c => !excludedCols.has(c));
57
+
58
+ // Per-unit metadata rows — populated on first occurrence of each unit.
59
+ const metaData = [];
60
+
61
+ for (let i = 0; i < nRows; i++) {
62
+ const row = rows[i];
63
+
64
+ // Code values
65
+ for (let c = 0; c < nCodes; c++) {
66
+ codeMatrix[i * nCodes + c] = Number(row[codes[c]]) || 0;
67
+ }
68
+
69
+ // Unit index
70
+ const uKey = rowKey(row, unitCols);
71
+ if (!unitIndex.has(uKey)) {
72
+ const uIdx = unitIndex.size;
73
+ unitIndex.set(uKey, uIdx);
74
+ // First row for this unit → representative metadata
75
+ const meta = {};
76
+ for (const col of metaCols) meta[col] = row[col];
77
+ metaData[uIdx] = meta;
78
+ }
79
+ unitOf[i] = unitIndex.get(uKey);
80
+
81
+ // Conversation index
82
+ const cKey = rowKey(row, convoCols);
83
+ if (!convoIndex.has(cKey)) convoIndex.set(cKey, convoIndex.size);
84
+ const cIdx = convoIndex.get(cKey);
85
+ convoOf[i] = cIdx;
86
+
87
+ if (!convoGroups.has(cIdx)) convoGroups.set(cIdx, []);
88
+ convoGroups.get(cIdx).push(i);
89
+ }
90
+
91
+ const unitLabels = Array.from(unitIndex.keys());
92
+ const nUnits = unitLabels.length;
93
+
94
+ return { codeMatrix, nRows, nCodes, nUnits, unitLabels, unitOf, convoOf, convoGroups,
95
+ metaData, metaCols };
96
+ }
package/src/index.js ADDED
@@ -0,0 +1,433 @@
1
+ /**
2
+ * @qe-libs/rena-wasm
3
+ *
4
+ * JavaScript/WebAssembly ENA pipeline.
5
+ * Thin orchestration layer over @qe-libs/libqe-wasm — handles data parsing,
6
+ * unit/conversation grouping, and the full accumulate→normalize→center→
7
+ * rotate→project→node-positions pipeline.
8
+ *
9
+ * Output structure mirrors R's ena.set object (without R-specific S3 class
10
+ * attributes and without metadata columns prepended to every matrix).
11
+ * Metadata lives in metaData / model.metaData instead.
12
+ *
13
+ * Top-level fields (= R's set$...):
14
+ * connectionCounts Float64Array (nUnits × nConnections) — raw accumulation
15
+ * lineWeights Float64Array (nUnits × nConnections) — sphere-normed
16
+ * points Float64Array (nUnits × dims) — projected positions
17
+ * rotationMatrix Float64Array (nConnections × dims) — rotation vectors
18
+ * metaData Object[] one object per unit, non-code/unit/convo cols
19
+ * connectionNames string[]
20
+ * columnClasses Object matrix name → R column class string
21
+ * nUnits number
22
+ * nConnections number
23
+ * dims number
24
+ *
25
+ * model sub-object (= R's set$model$...):
26
+ * model.centroids Float64Array (nUnits × dims) — LWS centroids
27
+ * model.variance number[] variance explained per dim
28
+ * model.unitLabels string[]
29
+ * model.pointsForProjection Float64Array (nUnits × nConnections) — centered normed
30
+ *
31
+ * rotation sub-object (= R's set$rotation$...):
32
+ * rotation.rotationMatrix Float64Array same reference as top-level rotationMatrix
33
+ * rotation.nodes Float64Array (nCodes × dims) — code positions
34
+ * rotation.columnNames string[] axis labels e.g. ['SVD1','SVD2']
35
+ * rotation.eigenvalues number[]
36
+ * rotation.centerVec Float64Array (nConnections) — centering vector
37
+ * rotation.codes string[]
38
+ * rotation.adjacencyKey string[][] [[codeA,codeB], ...] per connection
39
+ */
40
+
41
+ import loadLibQE from '@qe-libs/libqe-wasm';
42
+ import { parseData } from './data.js';
43
+ import {
44
+ accumulate, sphereNorm, center,
45
+ rotateSVD, rotateMeans,
46
+ project, nodePositions,
47
+ } from './pipeline.js';
48
+ import { accumulateTensor, defaultTensor } from './tensor.js';
49
+
50
+ // ── helpers ───────────────────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Compute per-dimension variance explained from projected unit positions.
54
+ * Matches R: diagonal(var(points)) / sum(diagonal(var(points)))
55
+ *
56
+ * @param {Float64Array} points nUnits × dims, row-major
57
+ * @param {number} nUnits
58
+ * @param {number} dims
59
+ * @returns {number[]} length dims, sums to 1
60
+ */
61
+ function computeVariance(points, nUnits, dims) {
62
+ if (nUnits < 2) return Array.from({ length: dims }, () => 1 / dims);
63
+
64
+ // Column means
65
+ const means = new Float64Array(dims);
66
+ for (let u = 0; u < nUnits; u++)
67
+ for (let d = 0; d < dims; d++)
68
+ means[d] += points[u * dims + d];
69
+ for (let d = 0; d < dims; d++) means[d] /= nUnits;
70
+
71
+ // Sample variance (n-1 denominator, matching R's var())
72
+ const variances = new Float64Array(dims);
73
+ for (let u = 0; u < nUnits; u++)
74
+ for (let d = 0; d < dims; d++) {
75
+ const diff = points[u * dims + d] - means[d];
76
+ variances[d] += diff * diff;
77
+ }
78
+ for (let d = 0; d < dims; d++) variances[d] /= (nUnits - 1);
79
+
80
+ const total = Array.from(variances).reduce((a, b) => a + b, 0);
81
+ if (total === 0) return Array.from({ length: dims }, () => 1 / dims);
82
+ return Array.from(variances).map(v => v / total);
83
+ }
84
+
85
+ /**
86
+ * Build the adjacency key — list of [codeA, codeB] pairs for each connection.
87
+ * Matches R's enadata$adjacency.matrix (column-major upper triangle order).
88
+ *
89
+ * @param {string[]} codes
90
+ * @returns {string[][]} length nConnections, each entry is [codeI, codeJ]
91
+ */
92
+ function buildAdjacencyKey(codes) {
93
+ const key = [];
94
+ for (let j = 1; j < codes.length; j++)
95
+ for (let i = 0; i < j; i++)
96
+ key.push([codes[i], codes[j]]);
97
+ return key;
98
+ }
99
+
100
+ // ── ENA result object ─────────────────────────────────────────────────────────
101
+
102
+ class ENAModel {
103
+ constructor(opts) {
104
+ // ── top-level fields (= R's set$...) ────────────────────────────────
105
+ this.connectionCounts = opts.connectionCounts; // raw networks
106
+ this.lineWeights = opts.lineWeights; // sphere-normed networks
107
+ this.points = opts.points; // projected unit positions
108
+ this.rotationMatrix = opts.rotationMatrix; // n_connections × dims
109
+ this.metaData = opts.metaData; // array of unit metadata objects
110
+ this.connectionNames = opts.connectionNames;
111
+ this.nUnits = opts.nUnits;
112
+ this.nConnections = opts.nConnections;
113
+ this.dims = opts.dims;
114
+
115
+ // ── model sub-object (= R's set$model$...) ───────────────────────────
116
+ this.model = {
117
+ centroids: opts.centroids, // LWS positions
118
+ variance: opts.variance, // variance explained
119
+ unitLabels: opts.unitLabels,
120
+ pointsForProjection: opts.pointsForProjection, // centered normed networks
121
+ };
122
+
123
+ // ── rotation sub-object (= R's set$rotation$...) ────────────────────
124
+ this.rotation = {
125
+ rotationMatrix: opts.rotationMatrix, // same reference as top-level
126
+ nodes: opts.nodes, // code positions
127
+ columnNames: opts.columnNames, // axis labels ['SVD1','SVD2']
128
+ eigenvalues: opts.eigenvalues,
129
+ centerVec: opts.centerVec,
130
+ codes: opts.codes,
131
+ adjacencyKey: opts.adjacencyKey,
132
+ };
133
+
134
+ // ── column class annotations (mirrors R's S3 class tags) ─────────────
135
+ this.columnClasses = {
136
+ connectionCounts: 'ena.co.occurrence',
137
+ lineWeights: 'ena.co.occurrence',
138
+ points: 'ena.dimension',
139
+ pointsForProjection: 'ena.co.occurrence',
140
+ rotationMatrix: 'ena.dimension',
141
+ nodes: 'ena.dimension',
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Projected position for a single unit (= R's set$points[unit,]).
147
+ * @param {string} unitLabel
148
+ * @returns {number[]}
149
+ */
150
+ point(unitLabel) {
151
+ const idx = this.model.unitLabels.indexOf(unitLabel);
152
+ if (idx < 0) throw new Error(`Unknown unit: ${unitLabel}`);
153
+ return Array.from(this.points.subarray(idx * this.dims, (idx + 1) * this.dims));
154
+ }
155
+
156
+ /**
157
+ * LWS centroid for a single unit (= R's set$model$centroids[unit,]).
158
+ * @param {string} unitLabel
159
+ * @returns {number[]}
160
+ */
161
+ centroid(unitLabel) {
162
+ const idx = this.model.unitLabels.indexOf(unitLabel);
163
+ if (idx < 0) throw new Error(`Unknown unit: ${unitLabel}`);
164
+ if (!this.model.centroids) throw new Error('LWS centroids not available from libqe');
165
+ return Array.from(this.model.centroids.subarray(idx * this.dims, (idx + 1) * this.dims));
166
+ }
167
+
168
+ /**
169
+ * Normed network vector for a single unit (= R's set$line.weights[unit,]).
170
+ * @param {string} unitLabel
171
+ * @returns {number[]}
172
+ */
173
+ network(unitLabel) {
174
+ const idx = this.model.unitLabels.indexOf(unitLabel);
175
+ if (idx < 0) throw new Error(`Unknown unit: ${unitLabel}`);
176
+ return Array.from(this.lineWeights.subarray(
177
+ idx * this.nConnections, (idx + 1) * this.nConnections
178
+ ));
179
+ }
180
+ }
181
+
182
+ // ── shared pipeline (post-accumulation) ──────────────────────────────────────
183
+
184
+ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
185
+ metaData, rotMethod, groupA, groupB, dims) {
186
+ const connectionNames = qe.connection_names(codes);
187
+
188
+ // Sphere norm → lineWeights (= R's set$line.weights)
189
+ const lineWeights = sphereNorm(qe, rawNetworks, nUnits, nConnections);
190
+
191
+ // Center → pointsForProjection (= R's model$points.for.projection)
192
+ // centerVec (= R's rotation$center.vec)
193
+ const { centered: pointsForProjection, centerVec } =
194
+ center(qe, lineWeights, nUnits, nConnections);
195
+
196
+ // Rotate
197
+ let rot;
198
+ if (rotMethod === 'mean') {
199
+ if (!groupA || !groupB) throw new Error(
200
+ 'opts.groupA and opts.groupB are required for means rotation'
201
+ );
202
+ rot = rotateMeans(qe, pointsForProjection, nUnits, nConnections, groupA, groupB);
203
+ } else {
204
+ rot = rotateSVD(qe, pointsForProjection, nUnits, nConnections);
205
+ }
206
+
207
+ // Truncate rotation matrix to dims columns (= R's set$rotation.matrix)
208
+ const rotationMatrix = new Float64Array(rot.rotRows * dims);
209
+ for (let r = 0; r < rot.rotRows; r++)
210
+ for (let d = 0; d < dims; d++)
211
+ rotationMatrix[r * dims + d] = rot.rotation[r * rot.rotCols + d];
212
+
213
+ const columnNames = rot.columnNames.slice(0, dims);
214
+
215
+ // Project → points (= R's set$points)
216
+ const points = project(
217
+ pointsForProjection, nUnits, nConnections,
218
+ rot.rotation, rot.rotRows, rot.rotCols, dims
219
+ );
220
+
221
+ // Node positions (LWS) → rotation.nodes + model.centroids
222
+ // libqe.node_positions expects centered-normed networks (pointsForProjection),
223
+ // not sphere-normed (lineWeights). Using lineWeights places code nodes in wrong locations.
224
+ const { nodes, centroids } = nodePositions(
225
+ qe, pointsForProjection, nUnits, nConnections, points, dims
226
+ );
227
+
228
+ // Variance explained (= R's model$variance)
229
+ const variance = computeVariance(points, nUnits, dims);
230
+
231
+ // Adjacency key (= R's rotation$adjacency.key)
232
+ const adjacencyKey = buildAdjacencyKey(codes);
233
+
234
+ return new ENAModel({
235
+ // top-level
236
+ connectionCounts: rawNetworks,
237
+ lineWeights,
238
+ points,
239
+ rotationMatrix,
240
+ metaData,
241
+ connectionNames,
242
+ nUnits,
243
+ nConnections,
244
+ dims,
245
+ // model sub
246
+ centroids,
247
+ variance,
248
+ unitLabels,
249
+ pointsForProjection,
250
+ // rotation sub
251
+ nodes,
252
+ columnNames,
253
+ eigenvalues: rot.eigenvalues,
254
+ centerVec,
255
+ codes,
256
+ adjacencyKey,
257
+ });
258
+ }
259
+
260
+ // ── main factory ─────────────────────────────────────────────────────────────
261
+
262
+ /**
263
+ * Load the ENA module. Returns an object with `fit()` and `accumulate()`.
264
+ *
265
+ * @returns {Promise<{fit: Function, accumulate: Function}>}
266
+ */
267
+ export default async function loadENA() {
268
+ const qe = await loadLibQE();
269
+
270
+ return {
271
+ /**
272
+ * Run the full ENA pipeline.
273
+ *
274
+ * @param {Object[]} rows - Tabular data (array of row objects)
275
+ * @param {object} opts
276
+ * @param {string[]} opts.codes - Code column names
277
+ * @param {string[]} opts.units - Unit identifier column(s)
278
+ * @param {string[]} opts.conversations - Conversation identifier column(s)
279
+ * @param {number} [opts.window=4] - Backward window (simple path; ignored when tensor provided)
280
+ * @param {boolean} [opts.binary=true] - Binarise co-occurrences (simple path only)
281
+ * @param {boolean} [opts.ordered=false] - Directed networks (tensor path only)
282
+ * @param {object} [opts.tensor] - Context tensor definition
283
+ * @param {string} [opts.rotation='svd'] - 'svd' or 'mean'
284
+ * @param {number[]} [opts.groupA] - Unit indices for means rotation group A
285
+ * @param {number[]} [opts.groupB] - Unit indices for means rotation group B
286
+ * @param {number} [opts.dims=2] - Number of dimensions to return
287
+ *
288
+ * @returns {ENAModel}
289
+ */
290
+ fit(rows, opts = {}) {
291
+ const {
292
+ codes,
293
+ units,
294
+ conversations,
295
+ window: windowSize = 4,
296
+ binary = true,
297
+ ordered = false,
298
+ tensor: tensorDef,
299
+ rotation: rotMethod = 'svd',
300
+ groupA,
301
+ groupB,
302
+ dims = 2,
303
+ } = opts;
304
+
305
+ if (!codes?.length) throw new Error('opts.codes is required');
306
+ if (!units?.length) throw new Error('opts.units is required');
307
+ if (!conversations?.length) throw new Error('opts.conversations is required');
308
+
309
+ const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
310
+ unitOf, convoGroups, metaData } =
311
+ parseData(rows, codes, units, conversations);
312
+
313
+ let rawNetworks, nConnections;
314
+
315
+ if (tensorDef) {
316
+ rawNetworks = accumulateTensor(
317
+ qe, rows, codeMatrix, nRows, nCodes, nUnits,
318
+ unitOf, convoGroups, tensorDef, ordered
319
+ );
320
+ nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
321
+ } else {
322
+ rawNetworks = accumulate(
323
+ qe, codeMatrix, nRows, nCodes, nUnits,
324
+ unitOf, convoGroups, windowSize, binary
325
+ );
326
+ nConnections = qe.choose_two(nCodes);
327
+ }
328
+
329
+ return runPipeline(qe, rawNetworks, nUnits, nConnections, codes,
330
+ unitLabels, metaData, rotMethod, groupA, groupB, dims);
331
+ },
332
+
333
+ /**
334
+ * Run only the accumulation step (= R's ena.accumulate.data()).
335
+ * Returns raw (un-normalised) network vectors.
336
+ *
337
+ * @param {Object[]} rows
338
+ * @param {object} opts - codes, units, conversations, window, binary, ordered, tensor
339
+ * @returns {{
340
+ * connectionCounts: Float64Array,
341
+ * unitLabels: string[],
342
+ * connectionNames: string[],
343
+ * metaData: Object[],
344
+ * nUnits: number,
345
+ * nConnections: number,
346
+ * }}
347
+ */
348
+ accumulate(rows, opts = {}) {
349
+ const {
350
+ codes, units, conversations,
351
+ window: windowSize = 4,
352
+ binary = true,
353
+ ordered = false,
354
+ tensor: tensorDef,
355
+ } = opts;
356
+
357
+ const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
358
+ unitOf, convoGroups, metaData } =
359
+ parseData(rows, codes, units, conversations);
360
+
361
+ let networks, nConnections;
362
+
363
+ if (tensorDef) {
364
+ networks = accumulateTensor(
365
+ qe, rows, codeMatrix, nRows, nCodes, nUnits,
366
+ unitOf, convoGroups, tensorDef, ordered
367
+ );
368
+ nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
369
+ } else {
370
+ networks = accumulate(
371
+ qe, codeMatrix, nRows, nCodes, nUnits,
372
+ unitOf, convoGroups, windowSize, binary
373
+ );
374
+ nConnections = qe.choose_two(nCodes);
375
+ }
376
+
377
+ const connectionNames = qe.connection_names(codes);
378
+ return { connectionCounts: networks, unitLabels, connectionNames,
379
+ metaData, nUnits, nConnections };
380
+ },
381
+
382
+ /**
383
+ * Per-dimension t-based confidence intervals around the column means.
384
+ * Matches R's conf.ints / libqe::mean_ci.
385
+ *
386
+ * @param {Float64Array} points nUnits × dims, row-major
387
+ * @param {number} nUnits
388
+ * @param {number} dims
389
+ * @param {number} [confLevel=0.95]
390
+ * @returns {{ data: Float64Array, rows: number, cols: number }}
391
+ * rows=dims, cols=3 — columns: mean, lower CI, upper CI
392
+ */
393
+ confInts(points, nUnits, dims, confLevel = 0.95) {
394
+ return qe.mean_ci(points, nUnits, dims, confLevel);
395
+ },
396
+
397
+ /**
398
+ * Per-dimension Tukey-fence outlier intervals (Q1-k*IQR, Q3+k*IQR).
399
+ * Matches R's outlier.ints / libqe::outlier_ci.
400
+ *
401
+ * @param {Float64Array} points nUnits × dims, row-major
402
+ * @param {number} nUnits
403
+ * @param {number} dims
404
+ * @param {number} [iqrFactor=1.5]
405
+ * @returns {{ data: Float64Array, rows: number, cols: number }}
406
+ * rows=dims, cols=2 — columns: lower fence, upper fence
407
+ */
408
+ outlierInts(points, nUnits, dims, iqrFactor = 1.5) {
409
+ return qe.outlier_ci(points, nUnits, dims, iqrFactor);
410
+ },
411
+
412
+ /**
413
+ * Per-dimension parametric and non-parametric two-group statistics.
414
+ * Matches R's set$tests / libqe::group_stats.
415
+ *
416
+ * @param {Float64Array} g1Points group 1 points, nG1 × dims, row-major
417
+ * @param {number} nG1
418
+ * @param {Float64Array} g2Points group 2 points, nG2 × dims, row-major
419
+ * @param {number} nG2
420
+ * @param {number} dims
421
+ * @returns {{ n1, n2, t, df, pvalue_t, cohens_d, means, sds,
422
+ * U, pvalue_u, effect_r, medians }}
423
+ */
424
+ compareGroups(g1Points, nG1, g2Points, nG2, dims) {
425
+ return qe.group_stats(g1Points, nG1, dims, g2Points, nG2, dims);
426
+ },
427
+
428
+ /**
429
+ * Helpers re-exported for consumers who want to build their own pipeline.
430
+ */
431
+ defaultTensor,
432
+ };
433
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * pipeline.js — ENA pipeline steps backed by @qe-libs/libqe-wasm.
3
+ *
4
+ * All functions take a libqe module instance (`qe`) as first argument so
5
+ * they work in both Node and browser contexts without global state.
6
+ */
7
+
8
+ // ── helpers ───────────────────────────────────────────────────────────────────
9
+
10
+ /**
11
+ * Multiply two row-major matrices: A (m×k) @ B (k×n) → C (m×n).
12
+ * Used for projection (networks @ rotation_matrix).
13
+ */
14
+ export function matmul(A, m, k, B, n) {
15
+ const C = new Float64Array(m * n);
16
+ for (let r = 0; r < m; r++) {
17
+ for (let c = 0; c < n; c++) {
18
+ let sum = 0;
19
+ for (let i = 0; i < k; i++) sum += A[r * k + i] * B[i * n + c];
20
+ C[r * n + c] = sum;
21
+ }
22
+ }
23
+ return C;
24
+ }
25
+
26
+ /**
27
+ * Extract the first `nDims` columns of a row-major matrix.
28
+ */
29
+ export function sliceCols(data, nRows, nCols, nDims) {
30
+ if (nDims >= nCols) return data;
31
+ const out = new Float64Array(nRows * nDims);
32
+ for (let r = 0; r < nRows; r++)
33
+ for (let c = 0; c < nDims; c++)
34
+ out[r * nDims + c] = data[r * nCols + c];
35
+ return out;
36
+ }
37
+
38
+ // ── accumulation ─────────────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Accumulate windowed co-occurrences for all units across all conversations.
42
+ *
43
+ * For each conversation group, runs qe.accumulate_stanza() on the conversation's
44
+ * rows to get per-row connection vectors, then folds each row's vector into its
45
+ * owning unit's running sum.
46
+ *
47
+ * @param {object} qe libqe WASM module
48
+ * @param {Float64Array} codeMatrix n_rows × n_codes, row-major
49
+ * @param {number} nRows
50
+ * @param {number} nCodes
51
+ * @param {number} nUnits
52
+ * @param {Int32Array} unitOf unit index per row
53
+ * @param {Map} convoGroups convoIdx → [rowIdx, ...]
54
+ * @param {number} windowSize backward window (rows)
55
+ * @param {boolean} binary binarise co-occurrences
56
+ *
57
+ * @returns {Float64Array} nUnits × nConnections, row-major
58
+ */
59
+ export function accumulate(qe, codeMatrix, nRows, nCodes, nUnits,
60
+ unitOf, convoGroups, windowSize = 4, binary = true) {
61
+ const nConnections = qe.choose_two(nCodes);
62
+ const networks = new Float64Array(nUnits * nConnections);
63
+
64
+ for (const [, rowIndices] of convoGroups) {
65
+ const nConvo = rowIndices.length;
66
+
67
+ // Extract code rows for this conversation (contiguous sub-matrix)
68
+ const convoCodes = new Float64Array(nConvo * nCodes);
69
+ for (let r = 0; r < nConvo; r++) {
70
+ const src = rowIndices[r];
71
+ convoCodes.set(
72
+ codeMatrix.subarray(src * nCodes, src * nCodes + nCodes),
73
+ r * nCodes
74
+ );
75
+ }
76
+
77
+ // Windowed accumulation — returns per-row connection vectors
78
+ const stanza = qe.accumulate_stanza(
79
+ convoCodes, nConvo, nCodes, windowSize, 0, binary
80
+ );
81
+ // stanza.data: nConvo × nConnections, row-major
82
+
83
+ // Fold each row into its unit's accumulator
84
+ for (let r = 0; r < nConvo; r++) {
85
+ const unit = unitOf[rowIndices[r]];
86
+ const offset = r * nConnections;
87
+ for (let c = 0; c < nConnections; c++) {
88
+ networks[unit * nConnections + c] += stanza.data[offset + c];
89
+ }
90
+ }
91
+ }
92
+
93
+ return networks;
94
+ }
95
+
96
+ // ── normalization (sphere norm) ───────────────────────────────────────────────
97
+
98
+ /**
99
+ * L2-normalize each row (sphere normalization).
100
+ * Rows with zero norm are left as zero.
101
+ */
102
+ export function sphereNorm(qe, networks, nUnits, nConnections) {
103
+ const result = qe.normalize_networks(networks, nUnits, nConnections);
104
+ return new Float64Array(result.data);
105
+ }
106
+
107
+ // ── centering ─────────────────────────────────────────────────────────────────
108
+
109
+ /**
110
+ * Subtract column means (center the network space).
111
+ * Excludes all-zero rows from the mean calculation (zero-network exclusion).
112
+ *
113
+ * @returns {{ centered: Float64Array, centerVec: Float64Array }}
114
+ * centered — mean-subtracted networks (zero-network rows left at zero)
115
+ * centerVec — the column means used for centering (= R's rotation$center.vec)
116
+ */
117
+ export function center(qe, networks, nUnits, nConnections) {
118
+ // Identify non-zero rows
119
+ const active = [];
120
+ for (let u = 0; u < nUnits; u++) {
121
+ let rowSum = 0;
122
+ for (let c = 0; c < nConnections; c++) rowSum += Math.abs(networks[u * nConnections + c]);
123
+ if (rowSum > 0) active.push(u);
124
+ }
125
+
126
+ // Compute column means over active rows only (= R's rotation$center.vec)
127
+ const means = new Float64Array(nConnections);
128
+ for (const u of active) {
129
+ for (let c = 0; c < nConnections; c++) means[c] += networks[u * nConnections + c];
130
+ }
131
+ if (active.length > 0) {
132
+ for (let c = 0; c < nConnections; c++) means[c] /= active.length;
133
+ }
134
+
135
+ // Subtract means from non-zero rows only.
136
+ // Zero-network rows remain at zero (R: center.align.to.origin = TRUE default).
137
+ const activeSet = new Set(active);
138
+ const centered = new Float64Array(networks.length);
139
+ for (let u = 0; u < nUnits; u++) {
140
+ if (!activeSet.has(u)) continue; // leave zero-network row as zero
141
+ for (let c = 0; c < nConnections; c++) {
142
+ centered[u * nConnections + c] = networks[u * nConnections + c] - means[c];
143
+ }
144
+ }
145
+
146
+ return { centered, centerVec: means };
147
+ }
148
+
149
+ // ── rotation ──────────────────────────────────────────────────────────────────
150
+
151
+ /**
152
+ * SVD rotation (default ENA rotation).
153
+ * @returns {{ rotation: Float64Array, rotRows: number, rotCols: number,
154
+ * eigenvalues: number[], columnNames: string[] }}
155
+ */
156
+ export function rotateSVD(qe, centered, nUnits, nConnections) {
157
+ const r = qe.ena_svd(centered, nUnits, nConnections);
158
+ return {
159
+ rotation: r.rotation.data,
160
+ rotRows: r.rotation.rows,
161
+ rotCols: r.rotation.cols,
162
+ eigenvalues: r.eigenvalues,
163
+ columnNames: r.column_names,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Means rotation.
169
+ * @param {Int32Array[]} groupA Row indices of group A
170
+ * @param {Int32Array[]} groupB Row indices of group B
171
+ */
172
+ export function rotateMeans(qe, centered, nUnits, nConnections, groupA, groupB) {
173
+ const groupPairs = [{ a: new Int32Array(groupA), b: new Int32Array(groupB) }];
174
+ const r = qe.means_rotation(centered, nUnits, nConnections, groupPairs);
175
+ return {
176
+ rotation: r.rotation.data,
177
+ rotRows: r.rotation.rows,
178
+ rotCols: r.rotation.cols,
179
+ eigenvalues: r.eigenvalues,
180
+ columnNames: r.column_names,
181
+ };
182
+ }
183
+
184
+ // ── projection & node positions ───────────────────────────────────────────────
185
+
186
+ /**
187
+ * Project centered networks into the rotated ENA space.
188
+ * @returns {Float64Array} nUnits × nDims, row-major
189
+ */
190
+ export function project(centered, nUnits, nConnections, rotation, rotRows, rotCols, nDims) {
191
+ const fullPoints = matmul(centered, nUnits, nConnections, rotation, rotCols);
192
+ return sliceCols(fullPoints, nUnits, rotCols, nDims);
193
+ }
194
+
195
+ /**
196
+ * Compute code node positions via least-squares (LWS).
197
+ *
198
+ * @returns {{ nodes: Float64Array, nodeRows: number, nodeCols: number,
199
+ * centroids: Float64Array|null }}
200
+ * nodes — code positions in ENA space (= R's rotation$nodes)
201
+ * centroids — LWS unit centroid positions (= R's model$centroids), or null
202
+ * if libqe does not expose them
203
+ */
204
+ export function nodePositions(qe, networks, nUnits, nConnections, points, nDims) {
205
+ const r = qe.node_positions(networks, nUnits, nConnections, points, nUnits, nDims, nDims);
206
+ return {
207
+ nodes: new Float64Array(r.nodes.data),
208
+ nodeRows: r.nodes.rows,
209
+ nodeCols: r.nodes.cols,
210
+ centroids: r.centroids ? new Float64Array(r.centroids.data) : null,
211
+ };
212
+ }
package/src/tensor.js ADDED
@@ -0,0 +1,232 @@
1
+ /**
2
+ * tensor.js — Context-tensor accumulation for rENA WASM.
3
+ *
4
+ * Maps tma's apply_tensor() semantics to the JS/WASM layer. Each unit's
5
+ * network is accumulated by calling qe.accumulate_tensor_unit() with a
6
+ * multi-dimensional tensor that encodes per-factor-combination window sizes
7
+ * and weights.
8
+ *
9
+ * Tensor definition (passed as `opts.tensor` to fit/accumulate):
10
+ *
11
+ * {
12
+ * // Tensor shape. Last entry is always 2 (index 0 = weight, index 1 = window).
13
+ * // Earlier entries correspond to the factor columns in order.
14
+ * dims: [nValsFactor0, nValsFactor1, ..., 2],
15
+ *
16
+ * // Which factor axes (0-based, NOT including the last weight/window axis)
17
+ * // act as sender / receiver / mode dimensions.
18
+ * dimsSender: [0], // e.g. axis 0 is a sender factor
19
+ * dimsReceiver: [1], // e.g. axis 1 is a receiver factor
20
+ * dimsMode: [],
21
+ *
22
+ * // Column names in the data — one per factor axis, matching dims[0..n-2].
23
+ * factors: ['SenderType', 'ReceiverType'],
24
+ *
25
+ * // Maps each factor column's string values to 0-based integer indices.
26
+ * // If omitted, values are encoded in order of first appearance.
27
+ * factorLevels: {
28
+ * SenderType: { 'A': 0, 'B': 1, 'C': 2 },
29
+ * ReceiverType: { 'X': 0, 'Y': 1, 'Z': 2 },
30
+ * },
31
+ *
32
+ * // Flat column-major tensor data: weight and window for every factor
33
+ * // combination. Length must equal product(dims).
34
+ * // Index 0 along last axis = weight; index 1 along last axis = window.
35
+ * data: new Float64Array([...]),
36
+ *
37
+ * // Optional column name for per-row timestamps.
38
+ * // Defaults to row index (0, 1, 2, ...) — equivalent to unit-step time.
39
+ * timesCol: 'timestamp',
40
+ * }
41
+ *
42
+ * Simple windowed accumulation (IS_DEFAULT path):
43
+ * Pass `{ dims: [2], dimsSender: [], dimsReceiver: [], dimsMode: [],
44
+ * factors: [], data: Float64Array.of(weight, window) }`
45
+ * or just use the `window` shorthand on the outer opts instead.
46
+ */
47
+
48
+ /**
49
+ * Build the context_lookup matrix and times vector from row data.
50
+ *
51
+ * @param {Object[]} rows All data rows (full dataset)
52
+ * @param {number[]} rowIndices Indices into rows for this conversation
53
+ * @param {string[]} factors Factor column names
54
+ * @param {Object} factorLevels { colName: { value: index } }
55
+ * @returns {{ contextLookup: Int32Array, clRows: number, clCols: number,
56
+ * times: Float64Array }}
57
+ */
58
+ export function buildContextLookup(rows, rowIndices, factors, factorLevels) {
59
+ const nRows = rowIndices.length;
60
+ const nFactor = factors.length;
61
+
62
+ const contextLookup = new Int32Array(nRows * nFactor);
63
+ const times = new Float64Array(nRows);
64
+
65
+ for (let i = 0; i < nRows; i++) {
66
+ const row = rows[rowIndices[i]];
67
+ times[i] = i; // default: row index as time
68
+ for (let f = 0; f < nFactor; f++) {
69
+ const col = factors[f];
70
+ contextLookup[i * nFactor + f] = factorLevels[col][row[col]] ?? 0;
71
+ }
72
+ }
73
+
74
+ return { contextLookup, clRows: nRows, clCols: nFactor, times };
75
+ }
76
+
77
+ /**
78
+ * Build the context_lookup with explicit timestamp column.
79
+ */
80
+ export function buildContextLookupWithTimes(rows, rowIndices, factors, factorLevels, timesCol) {
81
+ const nRows = rowIndices.length;
82
+ const nFactor = factors.length;
83
+
84
+ const contextLookup = new Int32Array(nRows * nFactor);
85
+ const times = new Float64Array(nRows);
86
+
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;
93
+ }
94
+ }
95
+
96
+ return { contextLookup, clRows: nRows, clCols: nFactor, times };
97
+ }
98
+
99
+ /**
100
+ * Auto-build factorLevels from data when not explicitly provided.
101
+ * Scans all rows and assigns integer indices in order of first appearance.
102
+ *
103
+ * @param {Object[]} rows
104
+ * @param {string[]} factors
105
+ * @returns {Object} { colName: { value: index, ... }, ... }
106
+ */
107
+ export function inferFactorLevels(rows, factors) {
108
+ const levels = {};
109
+ for (const col of factors) {
110
+ levels[col] = {};
111
+ let idx = 0;
112
+ for (const row of rows) {
113
+ const v = row[col];
114
+ if (v !== undefined && !(v in levels[col])) {
115
+ levels[col][v] = idx++;
116
+ }
117
+ }
118
+ }
119
+ return levels;
120
+ }
121
+
122
+ /**
123
+ * Accumulate tensor networks for all units across all conversations.
124
+ *
125
+ * @param {object} qe libqe WASM module
126
+ * @param {Object[]} rows Full dataset
127
+ * @param {Float64Array} codeMatrix n_rows × n_codes, row-major
128
+ * @param {number} nRows
129
+ * @param {number} nCodes
130
+ * @param {number} nUnits
131
+ * @param {Int32Array} unitOf unit index per row
132
+ * @param {Map} convoGroups convoIdx → [rowIdx, ...]
133
+ * @param {object} tensorDef Tensor definition (see module docstring)
134
+ * @param {boolean} ordered true → directed (n²); false → undirected
135
+ *
136
+ * @returns {Float64Array} nUnits × nConnections, row-major
137
+ */
138
+ export function accumulateTensor(qe, rows, codeMatrix, nRows, nCodes, nUnits,
139
+ unitOf, convoGroups, tensorDef, ordered = false) {
140
+ const {
141
+ dims,
142
+ dimsSender = [],
143
+ dimsReceiver = [],
144
+ dimsMode = [],
145
+ factors = [],
146
+ data: tensorData,
147
+ timesCol,
148
+ } = tensorDef;
149
+
150
+ // Resolve factor levels (infer if not provided)
151
+ const factorLevels = tensorDef.factorLevels ?? inferFactorLevels(rows, factors);
152
+
153
+ const nConnections = ordered
154
+ ? nCodes * nCodes
155
+ : qe.choose_two(nCodes);
156
+
157
+ const networks = new Float64Array(nUnits * nConnections);
158
+
159
+ const dimsArr = new Int32Array(dims);
160
+ const senderArr = new Int32Array(dimsSender);
161
+ const receiverArr = new Int32Array(dimsReceiver);
162
+ const modeArr = new Int32Array(dimsMode);
163
+
164
+ for (const [, rowIndices] of convoGroups) {
165
+ const nConvo = rowIndices.length;
166
+
167
+ // Extract code rows for this conversation
168
+ const convoCodes = new Float64Array(nConvo * nCodes);
169
+ for (let r = 0; r < nConvo; r++) {
170
+ const src = rowIndices[r];
171
+ convoCodes.set(
172
+ codeMatrix.subarray(src * nCodes, src * nCodes + nCodes),
173
+ r * nCodes
174
+ );
175
+ }
176
+
177
+ // Build context_lookup and times for this conversation
178
+ const { contextLookup, clRows, clCols, times } = timesCol
179
+ ? buildContextLookupWithTimes(rows, rowIndices, factors, factorLevels, timesCol)
180
+ : buildContextLookup(rows, rowIndices, factors, factorLevels);
181
+
182
+ // Group response rows by unit (within this conversation)
183
+ const unitConvoRows = new Map();
184
+ for (let r = 0; r < nConvo; r++) {
185
+ const unit = unitOf[rowIndices[r]];
186
+ if (!unitConvoRows.has(unit)) unitConvoRows.set(unit, []);
187
+ unitConvoRows.get(unit).push(r); // local (conversation-relative) index
188
+ }
189
+
190
+ // Accumulate per unit
191
+ for (const [unit, localRows] of unitConvoRows) {
192
+ const unitRowsArr = new Int32Array(localRows);
193
+
194
+ const result = qe.accumulate_tensor_unit(
195
+ tensorData, dimsArr,
196
+ senderArr, receiverArr, modeArr,
197
+ contextLookup, clRows, clCols,
198
+ unitRowsArr,
199
+ convoCodes, nConvo, nCodes,
200
+ times,
201
+ ordered
202
+ );
203
+
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];
207
+ }
208
+ }
209
+ }
210
+
211
+ return networks;
212
+ }
213
+
214
+ /**
215
+ * Build an IS_DEFAULT tensor definition from a simple window + weight.
216
+ * Use this to express simple windowed accumulation through the tensor path.
217
+ *
218
+ * @param {number} windowSize
219
+ * @param {number} [weight=1]
220
+ * @returns {object} tensorDef
221
+ */
222
+ export function defaultTensor(windowSize, weight = 1) {
223
+ return {
224
+ dims: [2],
225
+ dimsSender: [],
226
+ dimsReceiver: [],
227
+ dimsMode: [],
228
+ factors: [],
229
+ factorLevels: {},
230
+ data: Float64Array.of(weight, windowSize),
231
+ };
232
+ }