@qe-libs/rena-wasm 0.1.0 → 0.1.1

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/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.1",
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
@@ -42,11 +42,15 @@ import loadLibQE from '@qe-libs/libqe-wasm';
42
42
  import { parseData } from './data.js';
43
43
  import {
44
44
  accumulate, sphereNorm, center,
45
- rotateSVD, rotateMeans,
46
- project, nodePositions,
45
+ rotateSVD, rotateMeans, rotateGeneralized,
46
+ project, nodePositions, spaceDistCorr,
47
47
  } from './pipeline.js';
48
48
  import { accumulateTensor, defaultTensor } from './tensor.js';
49
49
 
50
+ // Heuristic model-parameter detection (units / conversations / codes).
51
+ // Pure JS — re-exported synchronously so callers don't need to load WASM.
52
+ export { detectParams, isBinary, scoreUnit, scoreConvo } from './detect.js';
53
+
50
54
  // ── helpers ───────────────────────────────────────────────────────────────────
51
55
 
52
56
  /**
@@ -182,7 +186,7 @@ class ENAModel {
182
186
  // ── shared pipeline (post-accumulation) ──────────────────────────────────────
183
187
 
184
188
  function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
185
- metaData, rotMethod, groupA, groupB, dims) {
189
+ metaData, rotMethod, groupA, groupB, dims, gParams) {
186
190
  const connectionNames = qe.connection_names(codes);
187
191
 
188
192
  // Sphere norm → lineWeights (= R's set$line.weights)
@@ -195,7 +199,12 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
195
199
 
196
200
  // Rotate
197
201
  let rot;
198
- if (rotMethod === 'mean') {
202
+ if (rotMethod === 'generalized') {
203
+ if (!gParams) throw new Error(
204
+ 'opts.gParams is required for generalized (GMR) rotation'
205
+ );
206
+ rot = rotateGeneralized(qe, pointsForProjection, nUnits, nConnections, gParams);
207
+ } else if (rotMethod === 'mean') {
199
208
  if (!groupA || !groupB) throw new Error(
200
209
  'opts.groupA and opts.groupB are required for means rotation'
201
210
  );
@@ -267,7 +276,7 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
267
276
  export default async function loadENA() {
268
277
  const qe = await loadLibQE();
269
278
 
270
- return {
279
+ const api = {
271
280
  /**
272
281
  * Run the full ENA pipeline.
273
282
  *
@@ -280,9 +289,10 @@ export default async function loadENA() {
280
289
  * @param {boolean} [opts.binary=true] - Binarise co-occurrences (simple path only)
281
290
  * @param {boolean} [opts.ordered=false] - Directed networks (tensor path only)
282
291
  * @param {object} [opts.tensor] - Context tensor definition
283
- * @param {string} [opts.rotation='svd'] - 'svd' or 'mean'
292
+ * @param {string} [opts.rotation='svd'] - 'svd', 'mean', or 'generalized'
284
293
  * @param {number[]} [opts.groupA] - Unit indices for means rotation group A
285
294
  * @param {number[]} [opts.groupB] - Unit indices for means rotation group B
295
+ * @param {object} [opts.gParams] - Pre-built GMR parameters for 'generalized' rotation
286
296
  * @param {number} [opts.dims=2] - Number of dimensions to return
287
297
  *
288
298
  * @returns {ENAModel}
@@ -299,7 +309,9 @@ export default async function loadENA() {
299
309
  rotation: rotMethod = 'svd',
300
310
  groupA,
301
311
  groupB,
312
+ gParams,
302
313
  dims = 2,
314
+ codeMask,
303
315
  } = opts;
304
316
 
305
317
  if (!codes?.length) throw new Error('opts.codes is required');
@@ -326,8 +338,37 @@ export default async function loadENA() {
326
338
  nConnections = qe.choose_two(nCodes);
327
339
  }
328
340
 
341
+ // Apply code masking by zeroing out the masked connection columns across all units
342
+ if (codeMask && codeMask.length === codes.length) {
343
+ console.log('[rena-wasm] Applying code mask to raw networks...');
344
+ if (ordered) {
345
+ for (let j = 0; j < codes.length; j++) {
346
+ for (let i = 0; i < codes.length; i++) {
347
+ if (codeMask[j] && codeMask[j][i] === 0) {
348
+ const k = i * codes.length + j;
349
+ for (let u = 0; u < nUnits; u++) {
350
+ rawNetworks[u * nConnections + k] = 0;
351
+ }
352
+ }
353
+ }
354
+ }
355
+ } else {
356
+ let k = 0;
357
+ for (let col = 1; col < codes.length; col++) {
358
+ for (let row = 0; row < col; row++) {
359
+ if ((codeMask[row] && codeMask[row][col] === 0) || (codeMask[col] && codeMask[col][row] === 0)) {
360
+ for (let u = 0; u < nUnits; u++) {
361
+ rawNetworks[u * nConnections + k] = 0;
362
+ }
363
+ }
364
+ k++;
365
+ }
366
+ }
367
+ }
368
+ }
369
+
329
370
  return runPipeline(qe, rawNetworks, nUnits, nConnections, codes,
330
- unitLabels, metaData, rotMethod, groupA, groupB, dims);
371
+ unitLabels, metaData, rotMethod, groupA, groupB, dims, gParams);
331
372
  },
332
373
 
333
374
  /**
@@ -343,6 +384,7 @@ export default async function loadENA() {
343
384
  * metaData: Object[],
344
385
  * nUnits: number,
345
386
  * nConnections: number,
387
+ * _call: object, // args used to build this accumulation (for tuneWindowSize)
346
388
  * }}
347
389
  */
348
390
  accumulate(rows, opts = {}) {
@@ -352,6 +394,7 @@ export default async function loadENA() {
352
394
  binary = true,
353
395
  ordered = false,
354
396
  tensor: tensorDef,
397
+ codeMask,
355
398
  } = opts;
356
399
 
357
400
  const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
@@ -374,9 +417,122 @@ export default async function loadENA() {
374
417
  nConnections = qe.choose_two(nCodes);
375
418
  }
376
419
 
420
+ // Apply code masking by zeroing out the masked connection columns across all units
421
+ if (codeMask && codeMask.length === codes.length) {
422
+ console.log('[rena-wasm] Applying code mask to accumulated networks...');
423
+ if (ordered) {
424
+ for (let j = 0; j < codes.length; j++) {
425
+ for (let i = 0; i < codes.length; i++) {
426
+ if (codeMask[j] && codeMask[j][i] === 0) {
427
+ const k = i * codes.length + j;
428
+ for (let u = 0; u < nUnits; u++) {
429
+ networks[u * nConnections + k] = 0;
430
+ }
431
+ }
432
+ }
433
+ }
434
+ } else {
435
+ let k = 0;
436
+ for (let col = 1; col < codes.length; col++) {
437
+ for (let row = 0; row < col; row++) {
438
+ if ((codeMask[row] && codeMask[row][col] === 0) || (codeMask[col] && codeMask[col][row] === 0)) {
439
+ for (let u = 0; u < nUnits; u++) {
440
+ networks[u * nConnections + k] = 0;
441
+ }
442
+ }
443
+ k++;
444
+ }
445
+ }
446
+ }
447
+ }
448
+
377
449
  const connectionNames = qe.connection_names(codes);
378
- return { connectionCounts: networks, unitLabels, connectionNames,
379
- metaData, nUnits, nConnections };
450
+ return {
451
+ connectionCounts: networks, unitLabels, connectionNames,
452
+ metaData, nUnits, nConnections,
453
+ // Retained so tuneWindowSize() can rebuild at other window sizes
454
+ // (= R's ENAAccumulation$`_function.call`).
455
+ _call: { rows, codes, units, conversations,
456
+ window: windowSize, binary, ordered, tensor: tensorDef },
457
+ };
458
+ },
459
+
460
+ /**
461
+ * Tune the stanza window size (= R's ena.tune.window.size).
462
+ *
463
+ * Rebuilds the accumulation and fits a default SVD model for every
464
+ * window from `minSize` to `maxSize`, correlates the unit-distance
465
+ * geometry of adjacent window sizes via spaceDistCorr, and selects the
466
+ * smallest window whose adjacent correlation reaches
467
+ * `cutoff * max(correlation)`. Mirrors R by returning a fresh
468
+ * accumulation rebuilt at the selected window size.
469
+ *
470
+ * @param {object} accum - an accumulation returned by accumulate()
471
+ * @param {object} [opts]
472
+ * @param {number} [opts.minSize=1]
473
+ * @param {number} [opts.maxSize=20]
474
+ * @param {number} [opts.cutoff=0.95]
475
+ * @returns {object} accumulation rebuilt at the selected window
476
+ * (selected size available as result._call.window)
477
+ */
478
+ tuneWindowSize(accum, opts = {}) {
479
+ const { minSize = 1, maxSize = 20, cutoff = 0.95 } = opts;
480
+ const call = accum && accum._call;
481
+ if (!call) throw new Error(
482
+ 'accum has no stored _call; build it with accumulate() to enable tuning.'
483
+ );
484
+ if (call.tensor) throw new Error(
485
+ 'window-size tuning is only supported for the simple (window) accumulation path.'
486
+ );
487
+
488
+ const windowRange = [];
489
+ for (let w = minSize; w <= maxSize; w++) windowRange.push(w);
490
+ if (windowRange.length < 2) throw new Error(
491
+ 'maxSize must be greater than minSize to compare windows.'
492
+ );
493
+
494
+ const { rows, codes, units, conversations, binary } = call;
495
+
496
+ // Parse once; only the window size changes between iterations.
497
+ const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
498
+ unitOf, convoGroups, metaData } =
499
+ parseData(rows, codes, units, conversations);
500
+ const nConnections = qe.choose_two(nCodes);
501
+
502
+ // 1. Rebuild + fit at each window, collecting unit points.
503
+ const dims = 2;
504
+ const allPoints = [];
505
+ for (const w of windowRange) {
506
+ const raw = accumulate(
507
+ qe, codeMatrix, nRows, nCodes, nUnits,
508
+ unitOf, convoGroups, w, binary
509
+ );
510
+ const model = runPipeline(
511
+ qe, raw, nUnits, nConnections, codes,
512
+ unitLabels, metaData, 'svd', undefined, undefined, dims
513
+ );
514
+ allPoints.push(model.points);
515
+ }
516
+
517
+ // 2. Adjacent-window distance-space correlations.
518
+ const nSteps = windowRange.length - 1;
519
+ const corr = new Array(nSteps);
520
+ for (let i = 0; i < nSteps; i++) {
521
+ corr[i] = spaceDistCorr(allPoints[i], allPoints[i + 1], nUnits, dims);
522
+ }
523
+
524
+ // 3. Smallest window crossing cutoff * max correlation.
525
+ const finite = corr.filter(v => !Number.isNaN(v));
526
+ const maxCorr = finite.length ? Math.max(...finite) : NaN;
527
+ const threshold = cutoff * maxCorr;
528
+ let bestIdx = corr.findIndex(v => v >= threshold);
529
+ if (bestIdx < 0) bestIdx = 0;
530
+ const bestWindow = windowRange[bestIdx];
531
+
532
+ // 4. Rebuild the accumulation at the selected window size.
533
+ return api.accumulate(rows, {
534
+ codes, units, conversations, window: bestWindow, binary,
535
+ });
380
536
  },
381
537
 
382
538
  /**
@@ -430,4 +586,6 @@ export default async function loadENA() {
430
586
  */
431
587
  defaultTensor,
432
588
  };
589
+
590
+ return api;
433
591
  }
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
  /**
@@ -181,6 +262,89 @@ export function rotateMeans(qe, centered, nUnits, nConnections, groupA, groupB)
181
262
  };
182
263
  }
183
264
 
265
+ /**
266
+ * Generalized Means Rotation (GMR).
267
+ *
268
+ * Mirrors R's ena.rotate.by.generalized() / libqe::generalized_means_rotation().
269
+ * Handles Lasso-adjusted OLS covariate control, between-group scatter for
270
+ * categorical targets, optional Y axis, and SVD completion.
271
+ *
272
+ * All matrix inputs are row-major Float64Arrays; all index arrays are Int32Arrays.
273
+ *
274
+ * @param {object} qe
275
+ * @param {Float64Array} centered nUnits × nConnections, row-major
276
+ * @param {number} nUnits
277
+ * @param {number} nConnections
278
+ * @param {object} p Pre-built GMR parameters
279
+ * @param {Float64Array} p.xModelMatrix nUnits × xmCols row-major model matrix
280
+ * (treatment coding, reference = level[0])
281
+ * @param {number} p.xmRows must equal nUnits
282
+ * @param {number} p.xmCols number of dummy columns (nGroups - 1)
283
+ * @param {Float64Array} p.xTarget nUnits — 0-based integer codes for target
284
+ * @param {Int32Array} p.x1Cols 0-based column indices in xModelMatrix for the target
285
+ * @param {boolean} p.xCategorical
286
+ * @param {number} p.xNGroups number of distinct levels
287
+ * @param {Int32Array} p.xSubset 0-based unit-row indices used for GMR fit
288
+ * @param {boolean} [p.hasY=false] whether a Y-axis GMR target is provided
289
+ * @param {Float64Array} [p.yModelMatrix] (ignored when hasY=false)
290
+ * @param {number} [p.ymRows]
291
+ * @param {number} [p.ymCols]
292
+ * @param {Float64Array} [p.yTarget]
293
+ * @param {Int32Array} [p.y1Cols]
294
+ * @param {boolean} [p.yCategorical=false]
295
+ * @param {number} [p.yNGroups=0]
296
+ * @param {number} [p.nLambda=50]
297
+ * @param {number} [p.kFolds=5]
298
+ * @param {number} [p.lassoEps=0.01]
299
+ * @returns {{ rotation, rotRows, rotCols, eigenvalues, columnNames }}
300
+ */
301
+ export function rotateGeneralized(qe, centered, nUnits, nConnections, p) {
302
+ const hasY = !!p.hasY;
303
+ const nLambda = (p.nLambda ?? 50) | 0;
304
+ const kFolds = (p.kFolds ?? 5) | 0;
305
+ const lassoEps = p.lassoEps ?? 0.01;
306
+
307
+ // Stub Y params when hasY=false — C++ ignores them but still needs valid arrays.
308
+ const yMM = hasY ? p.yModelMatrix : new Float64Array(nUnits);
309
+ const ymR = hasY ? (p.ymRows | 0) : nUnits;
310
+ const ymC = hasY ? (p.ymCols | 0) : 1;
311
+ const yTgt = hasY ? p.yTarget : new Float64Array(nUnits);
312
+ const y1C = hasY ? p.y1Cols : new Int32Array([0]);
313
+ const yCat = hasY ? !!p.yCategorical : false;
314
+ const yNGrp = hasY ? ((p.yNGroups || 0) | 0) : 0;
315
+
316
+ let r;
317
+ try {
318
+ r = qe.generalized_means_rotation(
319
+ centered, nUnits, nConnections,
320
+ p.xModelMatrix, p.xmRows | 0, p.xmCols | 0,
321
+ p.xTarget,
322
+ p.x1Cols,
323
+ !!p.xCategorical, (p.xNGroups | 0),
324
+ p.xSubset,
325
+ hasY,
326
+ yMM, ymR, ymC,
327
+ yTgt,
328
+ y1C,
329
+ yCat, yNGrp,
330
+ nLambda, kFolds, lassoEps
331
+ );
332
+ } catch (err) {
333
+ throw new Error(
334
+ 'Rotation by Regression failed: the target variable or group selection ' +
335
+ 'has zero variance or insufficient rank across units.'
336
+ );
337
+ }
338
+
339
+ return {
340
+ rotation: r.rotation.data,
341
+ rotRows: r.rotation.rows,
342
+ rotCols: r.rotation.cols,
343
+ eigenvalues: r.eigenvalues,
344
+ columnNames: r.column_names,
345
+ };
346
+ }
347
+
184
348
  // ── projection & node positions ───────────────────────────────────────────────
185
349
 
186
350
  /**