@qe-libs/rena-wasm 0.1.0 → 0.1.2

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
@@ -32,12 +32,14 @@ const model = ena.fit(rows, {
32
32
  dims: 2,
33
33
  });
34
34
 
35
- model.centroids // Float64Array nUnits × dims
36
- model.networks // Float64Array nUnits × nConnections (normed)
37
- model.positions // Float64Array nCodes × dims
35
+ model.model.centroids // Float64Array nUnits × dims
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)
39
+ model.rotation.nodes // Float64Array nCodes × dims
38
40
  model.connectionNames // ['Data & Technical.Constraints', ...]
39
- model.unitLabels // ['UserName1_ConditionA', ...]
40
- model.columnNames // ['SVD1', 'SVD2']
41
+ model.model.unitLabels // ['UserName1_ConditionA', ...]
42
+ model.rotation.columnNames // ['SVD1', 'SVD2']
41
43
 
42
44
  // Per-unit helpers
43
45
  model.centroid('Alice_A') // number[] length = dims
@@ -70,7 +72,7 @@ const model = ena.fit(rows, {
70
72
  Returns raw (un-normalised) network vectors without running the full pipeline.
71
73
 
72
74
  ```js
73
- const { networks, unitLabels, connectionNames, nUnits, nConnections } =
75
+ const { connectionCounts, rowConnectionCounts, unitLabels, connectionNames, nUnits, nConnections } =
74
76
  ena.accumulate(rows, { codes, units, conversations, window: 4 });
75
77
  ```
76
78
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qe-libs/rena-wasm",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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",
@@ -13,7 +13,12 @@
13
13
  "publishConfig": {
14
14
  "registry": "https://gitlab.com/api/v4/projects/22522458/packages/npm/"
15
15
  },
16
- "keywords": ["wasm", "ena", "epistemic-network-analysis", "quantitative-ethnography"],
16
+ "keywords": [
17
+ "wasm",
18
+ "ena",
19
+ "epistemic-network-analysis",
20
+ "quantitative-ethnography"
21
+ ],
17
22
  "license": "GPL-3.0-only",
18
23
  "repository": {
19
24
  "type": "git",
package/src/detect.js ADDED
@@ -0,0 +1,325 @@
1
+ /**
2
+ * detect.js — heuristic ENA model-parameter detection.
3
+ *
4
+ * Given parsed tabular rows (array of plain objects, one per row, keyed by
5
+ * column name), suggest which columns are ENA codes, units, and conversations
6
+ * (a.k.a. horizons), plus a likely group/comparison column.
7
+ *
8
+ * Pure JS heuristics — no WASM required, so this is exported synchronously
9
+ * alongside the async `loadENA()` factory. Parse data however you like
10
+ * (PapaParse, d.ply, etc.), then pass the rows:
11
+ *
12
+ * import { detectParams } from '@qe-libs/rena-wasm';
13
+ * const r = detectParams(rows);
14
+ * // r.codes / r.units / r.conversations / r.groups / r.columns
15
+ * // r.recommended / r.confidence / r.reason
16
+ */
17
+
18
+ // Helper to split a column name into lowercase alphanumeric tokens.
19
+ // Handles camelCase, snake_case, dot.case, hyphen-case, etc.
20
+ function getTokens(str) {
21
+ const camelSplit = str.replace(/([a-z])([A-Z])/g, '$1 $2');
22
+ return camelSplit.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
23
+ }
24
+
25
+ // ── name-hint vocabularies ──────────────────────────────────────────────────────
26
+
27
+ const UNIT_HINTS = new Set(['user', 'student', 'participant', 'speaker', 'person',
28
+ 'author', 'subject', 'respondent', 'id', 'name', 'who',
29
+ 'group', 'condition', 'class', 'team',
30
+ 'player', 'character', 'actor', 'agent']);
31
+ const CONVO_HINTS = new Set(['group', 'condition', 'session', 'conversation', 'convo',
32
+ 'episode', 'class', 'team', 'day', 'week', 'period',
33
+ 'context', 'case', 'trial', 'horizon', 'activity', 'task',
34
+ 'problem', 'scenario', 'phase', 'stage', 'run', 'round',
35
+ 'turn', 'half',
36
+ 'play', 'act', 'scene', 'chapter', 'section']);
37
+ // Names implying free text, scores, or metadata — never codes/units/conversations.
38
+ const EXCLUDE = new Set([
39
+ 'timestamp', 'timestamps', 'time', 'times', 'date', 'dates', 'text', 'texts',
40
+ 'utterance', 'utterances', 'message', 'messages', 'content', 'contents',
41
+ 'line', 'lines', 'row', 'rows', 'index', 'indices', 'notes', 'note',
42
+ 'score', 'scores', 'grade', 'grades', 'level', 'levels', 'change', 'changes',
43
+ 'pre', 'post', 'confidence', 'values', 'value', 'vals', 'val', 'rates', 'rate',
44
+ 'rating', 'ratings'
45
+ ]);
46
+
47
+ // ── column classifiers ──────────────────────────────────────────────────────────
48
+
49
+ /** A code column: every non-empty value is 0 or 1. */
50
+ export function isBinary(rows, col) {
51
+ const vals = new Set(rows.map(r => r[col]).filter(v => v !== '' && v != null));
52
+ return vals.size > 0 && [...vals].every(v => v === '0' || v === '1' || v === 0 || v === 1);
53
+ }
54
+
55
+ /** Score a column as a UNIT-of-analysis candidate (0–1). 0 = not a candidate. */
56
+ export function scoreUnit(col, nUnique, nRows) {
57
+ const tokens = getTokens(col);
58
+ if (nUnique < 2 || nUnique > Math.min(nRows * 0.8, 1000)) return 0;
59
+ let s = 0.3;
60
+ if (tokens.some(t => UNIT_HINTS.has(t))) s += 0.5;
61
+ if (nUnique >= 2 && nUnique <= 200) s += 0.2;
62
+ return Math.min(s, 1.0);
63
+ }
64
+
65
+ /** Score a column as a CONVERSATION / horizon (segmentation) candidate (0–1). */
66
+ export function scoreConvo(col, nUnique, nRows) {
67
+ const tokens = getTokens(col);
68
+ if (nUnique < 2 || nUnique > Math.min(nRows * 0.5, 500)) return 0;
69
+ let s = 0.2;
70
+ if (tokens.some(t => CONVO_HINTS.has(t))) s += 0.6;
71
+ if (nUnique >= 2 && nUnique <= 100) s += 0.2;
72
+ return Math.min(s, 1.0);
73
+ }
74
+
75
+ const round2 = v => Math.round(v * 100) / 100;
76
+
77
+ // ── main entry point ────────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * Detect ENA model parameters from parsed rows.
81
+ *
82
+ * @param {Object[]} rows Array of row objects keyed by column name.
83
+ * @returns {{
84
+ * recommended: boolean,
85
+ * confidence: 'high'|'medium'|'low',
86
+ * reason: string,
87
+ * nRows: number,
88
+ * nCols: number,
89
+ * codes: string[],
90
+ * units: {column:string,nUnique:number,score:number}[],
91
+ * conversations: {column:string,nUnique:number,score:number}[],
92
+ * groups: Record<string,string[]>,
93
+ * columns: {name:string,nUnique:number,role:string}[],
94
+ * }}
95
+ */
96
+ export function detectParams(rows) {
97
+ const nRows = Array.isArray(rows) ? rows.length : 0;
98
+ const headers = nRows > 0 ? Object.keys(rows[0]) : [];
99
+
100
+ const codeCols = [];
101
+ const columnsData = {};
102
+
103
+ // First pass: identify roles, compute nUnique, basic unit/convo scores
104
+ for (const col of headers) {
105
+ const tokens = getTokens(col);
106
+ const vals = rows.map(r => r[col]).filter(v => v !== '' && v != null);
107
+ const nUnique = new Set(vals).size;
108
+
109
+ // Calculate variance of string lengths to identify free-text/dialogue columns
110
+ let lenVar = 0;
111
+ if (vals.length > 1) {
112
+ const lengths = vals.map(v => String(v).length);
113
+ const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
114
+ lenVar = lengths.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / lengths.length;
115
+ }
116
+
117
+ if (tokens.some(t => EXCLUDE.has(t)) || lenVar > 100) {
118
+ columnsData[col] = { role: 'metadata', nUnique, uScore: 0, cScore: 0 };
119
+ continue;
120
+ }
121
+
122
+ if (isBinary(rows, col)) {
123
+ codeCols.push(col);
124
+ columnsData[col] = { role: 'code', nUnique, uScore: 0, cScore: 0 };
125
+ continue;
126
+ }
127
+
128
+ const uScore = scoreUnit(col, nUnique, nRows);
129
+ const cScore = scoreConvo(col, nUnique, nRows);
130
+ columnsData[col] = { role: 'candidate', nUnique, uScore, cScore };
131
+ }
132
+
133
+ // Find the primary unit column (highest uScore, tie-break by nUnique)
134
+ let primaryUnitCol = null;
135
+ let maxUScore = -1;
136
+ let maxUnique = -1;
137
+
138
+ for (const col of headers) {
139
+ const data = columnsData[col];
140
+ if (data.role !== 'candidate') continue;
141
+ if (data.uScore > maxUScore || (data.uScore === maxUScore && data.nUnique > maxUnique)) {
142
+ maxUScore = data.uScore;
143
+ maxUnique = data.nUnique;
144
+ primaryUnitCol = col;
145
+ }
146
+ }
147
+
148
+ // Second pass: apply split penalty for units, and nesting penalty for conversations
149
+ const unitCands = []; // [score, col, nUnique]
150
+ const convoCands = [];
151
+ const columns = [];
152
+
153
+ // Identify which columns are valid conversation candidates to check nesting
154
+ const convoCandidateCols = headers.filter(col => {
155
+ const data = columnsData[col];
156
+ return data && data.role === 'candidate' && data.cScore > 0;
157
+ });
158
+
159
+ for (const col of headers) {
160
+ const data = columnsData[col];
161
+ if (data.role === 'metadata') {
162
+ columns.push({ name: col, nUnique: data.nUnique, role: 'metadata' });
163
+ continue;
164
+ }
165
+ if (data.role === 'code') {
166
+ columns.push({ name: col, nUnique: data.nUnique, role: 'code' });
167
+ continue;
168
+ }
169
+
170
+ let uScore = data.uScore;
171
+ let cScore = data.cScore;
172
+
173
+ if (col === primaryUnitCol) {
174
+ uScore = data.uScore;
175
+ } else if (primaryUnitCol) {
176
+ // Check if combining col with primaryUnitCol increases the number of unique units
177
+ const combinedVals = rows.map(r => `${r[primaryUnitCol]}|||${r[col]}`).filter(v => !v.includes('null') && !v.includes('undefined'));
178
+ const nCombinedUnique = new Set(combinedVals).size;
179
+
180
+ if (nCombinedUnique > maxUnique) {
181
+ // Splits the primary unit into multiple smaller units. Penalize the score.
182
+ uScore = Math.max(0, uScore - 0.25);
183
+ }
184
+ }
185
+
186
+ // Conversation nesting penalty: B is nested under A if B has more unique values,
187
+ // and combining A + B doesn't increase unique combinations beyond soft-nesting limit.
188
+ if (cScore > 0) {
189
+ let nParents = 0;
190
+ for (const parentCol of convoCandidateCols) {
191
+ if (parentCol === col) continue;
192
+ const parentData = columnsData[parentCol];
193
+
194
+ if (parentData.nUnique < data.nUnique) {
195
+ const combinedVals = rows.map(r => `${r[parentCol]}|||${r[col]}`).filter(v => !v.includes('null') && !v.includes('undefined'));
196
+ const nCombinedUnique = new Set(combinedVals).size;
197
+ const ratio = nCombinedUnique / data.nUnique;
198
+
199
+ if (ratio <= 2.5) {
200
+ nParents++;
201
+ }
202
+ }
203
+ }
204
+ if (nParents > 0) {
205
+ cScore = Math.max(0.1, cScore - nParents * 0.15);
206
+ }
207
+ }
208
+
209
+ if (uScore > 0 || cScore > 0) {
210
+ const role = uScore >= cScore ? 'unit_candidate' : 'conversation_candidate';
211
+ if (uScore > 0) unitCands.push([uScore, col, data.nUnique]);
212
+ if (cScore > 0) convoCands.push([cScore, col, data.nUnique]);
213
+ columns.push({ name: col, nUnique: data.nUnique, role });
214
+ } else {
215
+ columns.push({ name: col, nUnique: data.nUnique, role: 'other' });
216
+ }
217
+ }
218
+
219
+ unitCands.sort((a, b) => b[0] - a[0] || b[2] - a[2]);
220
+ convoCands.sort((a, b) => b[0] - a[0] || a[2] - b[2]);
221
+
222
+ const units = unitCands.slice(0, 5).map(([s, c, u]) =>
223
+ ({ column: c, nUnique: u, score: round2(s) }));
224
+ const conversations = convoCands.slice(0, 5).map(([s, c, u]) =>
225
+ ({ column: c, nUnique: u, score: round2(s) }));
226
+
227
+ // Compute suggested combined selections in the correct nesting order
228
+ const suggestedUnits = [];
229
+ if (primaryUnitCol) {
230
+ // Find other unit columns that don't split the primary unit
231
+ const nestingUnitCols = [];
232
+ for (const [score, col, nUnique] of unitCands) {
233
+ if (col === primaryUnitCol) continue;
234
+ if (score >= 0.5) {
235
+ const combinedVals = rows.map(r => `${r[primaryUnitCol]}|||${r[col]}`).filter(v => !v.includes('null') && !v.includes('undefined'));
236
+ const nCombinedUnique = new Set(combinedVals).size;
237
+ if (nCombinedUnique === maxUnique) {
238
+ nestingUnitCols.push({ col, nUnique });
239
+ }
240
+ }
241
+ }
242
+ // Sort nesting columns by nUnique ascending (broadest first)
243
+ nestingUnitCols.sort((a, b) => a.nUnique - b.nUnique);
244
+ nestingUnitCols.forEach(x => suggestedUnits.push(x.col));
245
+ suggestedUnits.push(primaryUnitCol);
246
+ }
247
+
248
+ const suggestedConversations = [];
249
+ if (convoCands.length > 0) {
250
+ const convo1 = convoCands[0][1];
251
+ const nUnique1 = convoCands[0][2];
252
+ let convo2 = null;
253
+ for (let i = 1; i < convoCands.length; i++) {
254
+ const score = convoCands[i][0];
255
+ const col = convoCands[i][1];
256
+ const nUniqueCol = convoCands[i][2];
257
+ if (score >= 0.5 && nUniqueCol > nUnique1) {
258
+ const combinedVals = rows.map(r => `${r[convo1]}|||${r[col]}`).filter(v => !v.includes('null') && !v.includes('undefined'));
259
+ const nCombinedUnique = new Set(combinedVals).size;
260
+ const ratio = nCombinedUnique / nUniqueCol;
261
+ if (ratio > 1.01) { // Orthogonal/crosses
262
+ convo2 = col;
263
+ break;
264
+ }
265
+ }
266
+ }
267
+ suggestedConversations.push(convo1);
268
+ if (convo2) suggestedConversations.push(convo2);
269
+ }
270
+
271
+ // First convo candidate with a small, discrete value set → a comparison variable.
272
+ const groups = {};
273
+ for (const [, col, nUnique] of convoCands.slice(0, 3)) {
274
+ if (nUnique >= 2 && nUnique <= 8) {
275
+ groups[col] = [...new Set(rows.map(r => String(r[col])).filter(Boolean))].sort();
276
+ break;
277
+ }
278
+ }
279
+
280
+ const nCodes = codeCols.length;
281
+ const hasUnits = unitCands.length > 0 && unitCands[0][0] >= 0.3;
282
+ const hasConvo = convoCands.length > 0 && convoCands[0][0] >= 0.2;
283
+
284
+ let recommended, confidence, reason;
285
+ if (nCodes >= 3 && hasUnits && hasConvo) {
286
+ recommended = true;
287
+ confidence = nCodes >= 5 && unitCands[0][0] >= 0.7 ? 'high' : 'medium';
288
+ reason = `Found ${nCodes} binary code columns, a likely unit column ` +
289
+ `('${unitCands[0][1]}', ${unitCands[0][2]} unique values), and a likely ` +
290
+ `conversation column ('${convoCands[0][1]}', ${convoCands[0][2]} unique values).`;
291
+ } else if (nCodes >= 2 && (hasUnits || hasConvo)) {
292
+ recommended = true;
293
+ confidence = 'low';
294
+ reason = `Found ${nCodes} binary code column(s) and partial structure. ENA may be applicable.`;
295
+ } else if (nCodes >= 2) {
296
+ recommended = true;
297
+ confidence = 'low';
298
+ reason = `Found ${nCodes} binary code columns but no clear unit/conversation columns by name.`;
299
+ } else if (nCodes === 1) {
300
+ recommended = false;
301
+ confidence = 'low';
302
+ reason = `Only 1 binary code column was detected. ENA needs at least 2 (ideally 4+).`;
303
+ } else {
304
+ recommended = false;
305
+ confidence = 'low';
306
+ reason = `No binary (0/1) code columns were detected. ENA requires presence/absence code columns.`;
307
+ }
308
+
309
+ return {
310
+ recommended,
311
+ confidence,
312
+ reason,
313
+ nRows,
314
+ nCols: headers.length,
315
+ codes: codeCols,
316
+ units,
317
+ conversations,
318
+ groups,
319
+ columns,
320
+ suggestedUnits,
321
+ suggestedConversations,
322
+ };
323
+ }
324
+
325
+ export default detectParams;
package/src/index.js CHANGED
@@ -12,6 +12,7 @@
12
12
  *
13
13
  * Top-level fields (= R's set$...):
14
14
  * connectionCounts Float64Array (nUnits × nConnections) — raw accumulation
15
+ * rowConnectionCounts Float64Array (nRows × nConnections) — per-row raw accumulation
15
16
  * lineWeights Float64Array (nUnits × nConnections) — sphere-normed
16
17
  * points Float64Array (nUnits × dims) — projected positions
17
18
  * rotationMatrix Float64Array (nConnections × dims) — rotation vectors
@@ -23,6 +24,7 @@
23
24
  * dims number
24
25
  *
25
26
  * model sub-object (= R's set$model$...):
27
+ * model.rowConnectionCounts Float64Array (nRows × nConnections) — per-row raw accumulation
26
28
  * model.centroids Float64Array (nUnits × dims) — LWS centroids
27
29
  * model.variance number[] variance explained per dim
28
30
  * model.unitLabels string[]
@@ -41,12 +43,16 @@
41
43
  import loadLibQE from '@qe-libs/libqe-wasm';
42
44
  import { parseData } from './data.js';
43
45
  import {
44
- accumulate, sphereNorm, center,
45
- rotateSVD, rotateMeans,
46
- project, nodePositions,
46
+ accumulate, accumulateWithRows, sphereNorm, center,
47
+ rotateSVD, rotateMeans, rotateGeneralized,
48
+ project, nodePositions, spaceDistCorr,
47
49
  } from './pipeline.js';
48
50
  import { accumulateTensor, defaultTensor } from './tensor.js';
49
51
 
52
+ // Heuristic model-parameter detection (units / conversations / codes).
53
+ // Pure JS — re-exported synchronously so callers don't need to load WASM.
54
+ export { detectParams, isBinary, scoreUnit, scoreConvo } from './detect.js';
55
+
50
56
  // ── helpers ───────────────────────────────────────────────────────────────────
51
57
 
52
58
  /**
@@ -103,6 +109,7 @@ class ENAModel {
103
109
  constructor(opts) {
104
110
  // ── top-level fields (= R's set$...) ────────────────────────────────
105
111
  this.connectionCounts = opts.connectionCounts; // raw networks
112
+ this.rowConnectionCounts = opts.rowConnectionCounts;
106
113
  this.lineWeights = opts.lineWeights; // sphere-normed networks
107
114
  this.points = opts.points; // projected unit positions
108
115
  this.rotationMatrix = opts.rotationMatrix; // n_connections × dims
@@ -114,6 +121,7 @@ class ENAModel {
114
121
 
115
122
  // ── model sub-object (= R's set$model$...) ───────────────────────────
116
123
  this.model = {
124
+ rowConnectionCounts: opts.rowConnectionCounts,
117
125
  centroids: opts.centroids, // LWS positions
118
126
  variance: opts.variance, // variance explained
119
127
  unitLabels: opts.unitLabels,
@@ -182,7 +190,8 @@ class ENAModel {
182
190
  // ── shared pipeline (post-accumulation) ──────────────────────────────────────
183
191
 
184
192
  function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
185
- metaData, rotMethod, groupA, groupB, dims) {
193
+ metaData, rotMethod, groupA, groupB, dims, gParams,
194
+ rowConnectionCounts = null) {
186
195
  const connectionNames = qe.connection_names(codes);
187
196
 
188
197
  // Sphere norm → lineWeights (= R's set$line.weights)
@@ -195,7 +204,12 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
195
204
 
196
205
  // Rotate
197
206
  let rot;
198
- if (rotMethod === 'mean') {
207
+ if (rotMethod === 'generalized') {
208
+ if (!gParams) throw new Error(
209
+ 'opts.gParams is required for generalized (GMR) rotation'
210
+ );
211
+ rot = rotateGeneralized(qe, pointsForProjection, nUnits, nConnections, gParams);
212
+ } else if (rotMethod === 'mean') {
199
213
  if (!groupA || !groupB) throw new Error(
200
214
  'opts.groupA and opts.groupB are required for means rotation'
201
215
  );
@@ -234,6 +248,7 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
234
248
  return new ENAModel({
235
249
  // top-level
236
250
  connectionCounts: rawNetworks,
251
+ rowConnectionCounts,
237
252
  lineWeights,
238
253
  points,
239
254
  rotationMatrix,
@@ -267,7 +282,7 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
267
282
  export default async function loadENA() {
268
283
  const qe = await loadLibQE();
269
284
 
270
- return {
285
+ const api = {
271
286
  /**
272
287
  * Run the full ENA pipeline.
273
288
  *
@@ -280,9 +295,10 @@ export default async function loadENA() {
280
295
  * @param {boolean} [opts.binary=true] - Binarise co-occurrences (simple path only)
281
296
  * @param {boolean} [opts.ordered=false] - Directed networks (tensor path only)
282
297
  * @param {object} [opts.tensor] - Context tensor definition
283
- * @param {string} [opts.rotation='svd'] - 'svd' or 'mean'
298
+ * @param {string} [opts.rotation='svd'] - 'svd', 'mean', or 'generalized'
284
299
  * @param {number[]} [opts.groupA] - Unit indices for means rotation group A
285
300
  * @param {number[]} [opts.groupB] - Unit indices for means rotation group B
301
+ * @param {object} [opts.gParams] - Pre-built GMR parameters for 'generalized' rotation
286
302
  * @param {number} [opts.dims=2] - Number of dimensions to return
287
303
  *
288
304
  * @returns {ENAModel}
@@ -299,7 +315,9 @@ export default async function loadENA() {
299
315
  rotation: rotMethod = 'svd',
300
316
  groupA,
301
317
  groupB,
318
+ gParams,
302
319
  dims = 2,
320
+ codeMask,
303
321
  } = opts;
304
322
 
305
323
  if (!codes?.length) throw new Error('opts.codes is required');
@@ -310,7 +328,7 @@ export default async function loadENA() {
310
328
  unitOf, convoGroups, metaData } =
311
329
  parseData(rows, codes, units, conversations);
312
330
 
313
- let rawNetworks, nConnections;
331
+ let rawNetworks, rowConnectionCounts = null, nConnections;
314
332
 
315
333
  if (tensorDef) {
316
334
  rawNetworks = accumulateTensor(
@@ -319,15 +337,47 @@ export default async function loadENA() {
319
337
  );
320
338
  nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
321
339
  } else {
322
- rawNetworks = accumulate(
340
+ const accumulated = accumulateWithRows(
323
341
  qe, codeMatrix, nRows, nCodes, nUnits,
324
342
  unitOf, convoGroups, windowSize, binary
325
343
  );
344
+ rawNetworks = accumulated.networks;
345
+ rowConnectionCounts = accumulated.rowConnectionCounts;
326
346
  nConnections = qe.choose_two(nCodes);
327
347
  }
328
348
 
349
+ // Apply code masking by zeroing out the masked connection columns across all units
350
+ if (codeMask && codeMask.length === codes.length) {
351
+ console.log('[rena-wasm] Applying code mask to raw networks...');
352
+ if (ordered) {
353
+ for (let j = 0; j < codes.length; j++) {
354
+ for (let i = 0; i < codes.length; i++) {
355
+ if (codeMask[j] && codeMask[j][i] === 0) {
356
+ const k = i * codes.length + j;
357
+ for (let u = 0; u < nUnits; u++) {
358
+ rawNetworks[u * nConnections + k] = 0;
359
+ }
360
+ }
361
+ }
362
+ }
363
+ } else {
364
+ let k = 0;
365
+ for (let col = 1; col < codes.length; col++) {
366
+ for (let row = 0; row < col; row++) {
367
+ if ((codeMask[row] && codeMask[row][col] === 0) || (codeMask[col] && codeMask[col][row] === 0)) {
368
+ for (let u = 0; u < nUnits; u++) {
369
+ rawNetworks[u * nConnections + k] = 0;
370
+ }
371
+ }
372
+ k++;
373
+ }
374
+ }
375
+ }
376
+ }
377
+
329
378
  return runPipeline(qe, rawNetworks, nUnits, nConnections, codes,
330
- unitLabels, metaData, rotMethod, groupA, groupB, dims);
379
+ unitLabels, metaData, rotMethod, groupA, groupB,
380
+ dims, gParams, rowConnectionCounts);
331
381
  },
332
382
 
333
383
  /**
@@ -338,11 +388,13 @@ export default async function loadENA() {
338
388
  * @param {object} opts - codes, units, conversations, window, binary, ordered, tensor
339
389
  * @returns {{
340
390
  * connectionCounts: Float64Array,
391
+ * rowConnectionCounts: Float64Array | null,
341
392
  * unitLabels: string[],
342
393
  * connectionNames: string[],
343
394
  * metaData: Object[],
344
395
  * nUnits: number,
345
396
  * nConnections: number,
397
+ * _call: object, // args used to build this accumulation (for tuneWindowSize)
346
398
  * }}
347
399
  */
348
400
  accumulate(rows, opts = {}) {
@@ -352,13 +404,14 @@ export default async function loadENA() {
352
404
  binary = true,
353
405
  ordered = false,
354
406
  tensor: tensorDef,
407
+ codeMask,
355
408
  } = opts;
356
409
 
357
410
  const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
358
411
  unitOf, convoGroups, metaData } =
359
412
  parseData(rows, codes, units, conversations);
360
413
 
361
- let networks, nConnections;
414
+ let networks, rowConnectionCounts = null, nConnections;
362
415
 
363
416
  if (tensorDef) {
364
417
  networks = accumulateTensor(
@@ -367,16 +420,131 @@ export default async function loadENA() {
367
420
  );
368
421
  nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
369
422
  } else {
370
- networks = accumulate(
423
+ const accumulated = accumulateWithRows(
371
424
  qe, codeMatrix, nRows, nCodes, nUnits,
372
425
  unitOf, convoGroups, windowSize, binary
373
426
  );
427
+ networks = accumulated.networks;
428
+ rowConnectionCounts = accumulated.rowConnectionCounts;
374
429
  nConnections = qe.choose_two(nCodes);
375
430
  }
376
431
 
432
+ // Apply code masking by zeroing out the masked connection columns across all units
433
+ if (codeMask && codeMask.length === codes.length) {
434
+ console.log('[rena-wasm] Applying code mask to accumulated networks...');
435
+ if (ordered) {
436
+ for (let j = 0; j < codes.length; j++) {
437
+ for (let i = 0; i < codes.length; i++) {
438
+ if (codeMask[j] && codeMask[j][i] === 0) {
439
+ const k = i * codes.length + j;
440
+ for (let u = 0; u < nUnits; u++) {
441
+ networks[u * nConnections + k] = 0;
442
+ }
443
+ }
444
+ }
445
+ }
446
+ } else {
447
+ let k = 0;
448
+ for (let col = 1; col < codes.length; col++) {
449
+ for (let row = 0; row < col; row++) {
450
+ if ((codeMask[row] && codeMask[row][col] === 0) || (codeMask[col] && codeMask[col][row] === 0)) {
451
+ for (let u = 0; u < nUnits; u++) {
452
+ networks[u * nConnections + k] = 0;
453
+ }
454
+ }
455
+ k++;
456
+ }
457
+ }
458
+ }
459
+ }
460
+
377
461
  const connectionNames = qe.connection_names(codes);
378
- return { connectionCounts: networks, unitLabels, connectionNames,
379
- metaData, nUnits, nConnections };
462
+ return {
463
+ connectionCounts: networks, rowConnectionCounts, unitLabels, connectionNames,
464
+ metaData, nUnits, nConnections,
465
+ // Retained so tuneWindowSize() can rebuild at other window sizes
466
+ // (= R's ENAAccumulation$`_function.call`).
467
+ _call: { rows, codes, units, conversations,
468
+ window: windowSize, binary, ordered, tensor: tensorDef },
469
+ };
470
+ },
471
+
472
+ /**
473
+ * Tune the stanza window size (= R's ena.tune.window.size).
474
+ *
475
+ * Rebuilds the accumulation and fits a default SVD model for every
476
+ * window from `minSize` to `maxSize`, correlates the unit-distance
477
+ * geometry of adjacent window sizes via spaceDistCorr, and selects the
478
+ * smallest window whose adjacent correlation reaches
479
+ * `cutoff * max(correlation)`. Mirrors R by returning a fresh
480
+ * accumulation rebuilt at the selected window size.
481
+ *
482
+ * @param {object} accum - an accumulation returned by accumulate()
483
+ * @param {object} [opts]
484
+ * @param {number} [opts.minSize=1]
485
+ * @param {number} [opts.maxSize=20]
486
+ * @param {number} [opts.cutoff=0.95]
487
+ * @returns {object} accumulation rebuilt at the selected window
488
+ * (selected size available as result._call.window)
489
+ */
490
+ tuneWindowSize(accum, opts = {}) {
491
+ const { minSize = 1, maxSize = 20, cutoff = 0.95 } = opts;
492
+ const call = accum && accum._call;
493
+ if (!call) throw new Error(
494
+ 'accum has no stored _call; build it with accumulate() to enable tuning.'
495
+ );
496
+ if (call.tensor) throw new Error(
497
+ 'window-size tuning is only supported for the simple (window) accumulation path.'
498
+ );
499
+
500
+ const windowRange = [];
501
+ for (let w = minSize; w <= maxSize; w++) windowRange.push(w);
502
+ if (windowRange.length < 2) throw new Error(
503
+ 'maxSize must be greater than minSize to compare windows.'
504
+ );
505
+
506
+ const { rows, codes, units, conversations, binary } = call;
507
+
508
+ // Parse once; only the window size changes between iterations.
509
+ const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
510
+ unitOf, convoGroups, metaData } =
511
+ parseData(rows, codes, units, conversations);
512
+ const nConnections = qe.choose_two(nCodes);
513
+
514
+ // 1. Rebuild + fit at each window, collecting unit points.
515
+ const dims = 2;
516
+ const allPoints = [];
517
+ for (const w of windowRange) {
518
+ const raw = accumulate(
519
+ qe, codeMatrix, nRows, nCodes, nUnits,
520
+ unitOf, convoGroups, w, binary
521
+ );
522
+ const model = runPipeline(
523
+ qe, raw, nUnits, nConnections, codes,
524
+ unitLabels, metaData, 'svd', undefined, undefined, dims
525
+ );
526
+ allPoints.push(model.points);
527
+ }
528
+
529
+ // 2. Adjacent-window distance-space correlations.
530
+ const nSteps = windowRange.length - 1;
531
+ const corr = new Array(nSteps);
532
+ for (let i = 0; i < nSteps; i++) {
533
+ corr[i] = spaceDistCorr(allPoints[i], allPoints[i + 1], nUnits, dims);
534
+ }
535
+
536
+ // 3. Smallest window crossing cutoff * max correlation.
537
+ const finite = corr.filter(v => !Number.isNaN(v));
538
+ const maxCorr = finite.length ? Math.max(...finite) : NaN;
539
+ const threshold = cutoff * maxCorr;
540
+ let bestIdx = corr.findIndex(v => v >= threshold);
541
+ if (bestIdx < 0) bestIdx = 0;
542
+ const bestWindow = windowRange[bestIdx];
543
+
544
+ // 4. Rebuild the accumulation at the selected window size.
545
+ return api.accumulate(rows, {
546
+ codes, units, conversations, window: bestWindow, binary,
547
+ });
380
548
  },
381
549
 
382
550
  /**
@@ -430,4 +598,6 @@ export default async function loadENA() {
430
598
  */
431
599
  defaultTensor,
432
600
  };
601
+
602
+ return api;
433
603
  }
package/src/pipeline.js CHANGED
@@ -35,6 +35,87 @@ export function sliceCols(data, nRows, nCols, nDims) {
35
35
  return out;
36
36
  }
37
37
 
38
+ /**
39
+ * Pearson correlation of two paired numeric vectors.
40
+ * Returns NaN if either vector has zero variance.
41
+ */
42
+ function pearson(x, y) {
43
+ const n = x.length;
44
+ if (n === 0) return NaN;
45
+ let mx = 0, my = 0;
46
+ for (let i = 0; i < n; i++) { mx += x[i]; my += y[i]; }
47
+ mx /= n; my /= n;
48
+ let sxy = 0, sxx = 0, syy = 0;
49
+ for (let i = 0; i < n; i++) {
50
+ const dx = x[i] - mx, dy = y[i] - my;
51
+ sxy += dx * dy; sxx += dx * dx; syy += dy * dy;
52
+ }
53
+ const denom = Math.sqrt(sxx * syy);
54
+ return denom === 0 ? NaN : sxy / denom;
55
+ }
56
+
57
+ /**
58
+ * Pearson correlation between the pairwise distances of two ENA spaces.
59
+ *
60
+ * Computes the Euclidean distance between every pair of points within `A` and
61
+ * within `B` (same pairing for both), then correlates the two distance vectors.
62
+ * Because pairwise distances are invariant to rotation/reflection of a space,
63
+ * this measures configuration similarity up to an orthogonal transform — the
64
+ * right tool for comparing ENA solutions across window sizes whose SVD axes may
65
+ * flip sign. Mirrors R's ena_space_dist_corr: exact for small spaces, sampled
66
+ * (with replacement, self-pairs dropped) once unique pairs exceed the limit.
67
+ *
68
+ * @param {Float64Array} A m × d, row-major
69
+ * @param {Float64Array} B m × d, row-major
70
+ * @param {number} m number of points (rows)
71
+ * @param {number} d dimensions (cols)
72
+ * @param {number} [maxSampleSize=100000]
73
+ * @param {() => number} [rand=Math.random] RNG for the sampled path
74
+ * @returns {number} Pearson correlation of the paired distance vectors
75
+ */
76
+ export function spaceDistCorr(A, B, m, d, maxSampleSize = 100000, rand = Math.random) {
77
+ if (!m || m === 0) throw new Error('The spaces must have a non-zero number of rows.');
78
+
79
+ const dist = (M, p, q) => {
80
+ let s = 0;
81
+ for (let c = 0; c < d; c++) {
82
+ const diff = M[p * d + c] - M[q * d + c];
83
+ s += diff * diff;
84
+ }
85
+ return Math.sqrt(s);
86
+ };
87
+
88
+ const totalPairs = (m * (m - 1)) / 2;
89
+
90
+ let distA, distB;
91
+ if (totalPairs <= maxSampleSize) {
92
+ // Exact: all unique i<j pairs.
93
+ distA = new Float64Array(totalPairs);
94
+ distB = new Float64Array(totalPairs);
95
+ let k = 0;
96
+ for (let i = 0; i < m; i++)
97
+ for (let j = i + 1; j < m; j++) {
98
+ distA[k] = dist(A, i, j);
99
+ distB[k] = dist(B, i, j);
100
+ k++;
101
+ }
102
+ } else {
103
+ // Sample pairs with replacement, drop self-pairs.
104
+ const a = [], b = [];
105
+ for (let s = 0; s < maxSampleSize; s++) {
106
+ const i = Math.floor(rand() * m);
107
+ const j = Math.floor(rand() * m);
108
+ if (i === j) continue;
109
+ a.push(dist(A, i, j));
110
+ b.push(dist(B, i, j));
111
+ }
112
+ distA = Float64Array.from(a);
113
+ distB = Float64Array.from(b);
114
+ }
115
+
116
+ return pearson(distA, distB);
117
+ }
118
+
38
119
  // ── accumulation ─────────────────────────────────────────────────────────────
39
120
 
40
121
  /**
@@ -93,6 +174,51 @@ export function accumulate(qe, codeMatrix, nRows, nCodes, nUnits,
93
174
  return networks;
94
175
  }
95
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
+
96
222
  // ── normalization (sphere norm) ───────────────────────────────────────────────
97
223
 
98
224
  /**
@@ -181,6 +307,89 @@ export function rotateMeans(qe, centered, nUnits, nConnections, groupA, groupB)
181
307
  };
182
308
  }
183
309
 
310
+ /**
311
+ * Generalized Means Rotation (GMR).
312
+ *
313
+ * Mirrors R's ena.rotate.by.generalized() / libqe::generalized_means_rotation().
314
+ * Handles Lasso-adjusted OLS covariate control, between-group scatter for
315
+ * categorical targets, optional Y axis, and SVD completion.
316
+ *
317
+ * All matrix inputs are row-major Float64Arrays; all index arrays are Int32Arrays.
318
+ *
319
+ * @param {object} qe
320
+ * @param {Float64Array} centered nUnits × nConnections, row-major
321
+ * @param {number} nUnits
322
+ * @param {number} nConnections
323
+ * @param {object} p Pre-built GMR parameters
324
+ * @param {Float64Array} p.xModelMatrix nUnits × xmCols row-major model matrix
325
+ * (treatment coding, reference = level[0])
326
+ * @param {number} p.xmRows must equal nUnits
327
+ * @param {number} p.xmCols number of dummy columns (nGroups - 1)
328
+ * @param {Float64Array} p.xTarget nUnits — 0-based integer codes for target
329
+ * @param {Int32Array} p.x1Cols 0-based column indices in xModelMatrix for the target
330
+ * @param {boolean} p.xCategorical
331
+ * @param {number} p.xNGroups number of distinct levels
332
+ * @param {Int32Array} p.xSubset 0-based unit-row indices used for GMR fit
333
+ * @param {boolean} [p.hasY=false] whether a Y-axis GMR target is provided
334
+ * @param {Float64Array} [p.yModelMatrix] (ignored when hasY=false)
335
+ * @param {number} [p.ymRows]
336
+ * @param {number} [p.ymCols]
337
+ * @param {Float64Array} [p.yTarget]
338
+ * @param {Int32Array} [p.y1Cols]
339
+ * @param {boolean} [p.yCategorical=false]
340
+ * @param {number} [p.yNGroups=0]
341
+ * @param {number} [p.nLambda=50]
342
+ * @param {number} [p.kFolds=5]
343
+ * @param {number} [p.lassoEps=0.01]
344
+ * @returns {{ rotation, rotRows, rotCols, eigenvalues, columnNames }}
345
+ */
346
+ export function rotateGeneralized(qe, centered, nUnits, nConnections, p) {
347
+ const hasY = !!p.hasY;
348
+ const nLambda = (p.nLambda ?? 50) | 0;
349
+ const kFolds = (p.kFolds ?? 5) | 0;
350
+ const lassoEps = p.lassoEps ?? 0.01;
351
+
352
+ // Stub Y params when hasY=false — C++ ignores them but still needs valid arrays.
353
+ const yMM = hasY ? p.yModelMatrix : new Float64Array(nUnits);
354
+ const ymR = hasY ? (p.ymRows | 0) : nUnits;
355
+ const ymC = hasY ? (p.ymCols | 0) : 1;
356
+ const yTgt = hasY ? p.yTarget : new Float64Array(nUnits);
357
+ const y1C = hasY ? p.y1Cols : new Int32Array([0]);
358
+ const yCat = hasY ? !!p.yCategorical : false;
359
+ const yNGrp = hasY ? ((p.yNGroups || 0) | 0) : 0;
360
+
361
+ let r;
362
+ try {
363
+ r = qe.generalized_means_rotation(
364
+ centered, nUnits, nConnections,
365
+ p.xModelMatrix, p.xmRows | 0, p.xmCols | 0,
366
+ p.xTarget,
367
+ p.x1Cols,
368
+ !!p.xCategorical, (p.xNGroups | 0),
369
+ p.xSubset,
370
+ hasY,
371
+ yMM, ymR, ymC,
372
+ yTgt,
373
+ y1C,
374
+ yCat, yNGrp,
375
+ nLambda, kFolds, lassoEps
376
+ );
377
+ } catch (err) {
378
+ throw new Error(
379
+ 'Rotation by Regression failed: the target variable or group selection ' +
380
+ 'has zero variance or insufficient rank across units.'
381
+ );
382
+ }
383
+
384
+ return {
385
+ rotation: r.rotation.data,
386
+ rotRows: r.rotation.rows,
387
+ rotCols: r.rotation.cols,
388
+ eigenvalues: r.eigenvalues,
389
+ columnNames: r.column_names,
390
+ };
391
+ }
392
+
184
393
  // ── projection & node positions ───────────────────────────────────────────────
185
394
 
186
395
  /**