@qe-libs/rena-wasm 0.1.6 → 0.1.8

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
@@ -91,6 +91,26 @@ const model = ena.fit(rows, { codes, units, conversations, window: 4, weightMode
91
91
 
92
92
  ---
93
93
 
94
+ ## Modeling a Subset of Units
95
+
96
+ `unitsUsed` (= R's `units.used`) lists the units to model, as unit keys (the
97
+ unit column values joined with `__`, as in `model.unitLabels`). Every row
98
+ stays in the data: rows of other units still count as context in the modeled
99
+ units' windows, but those units get no network and never reach sphere
100
+ normalization, centering or rotation. Omit it to model every unit.
101
+
102
+ ```js
103
+ // Students' connections to what teachers said are kept; teachers aren't units.
104
+ const model = ena.fit(rows, { codes, units: ['Role', 'Name'], conversations,
105
+ window: 4, unitsUsed: studentKeys });
106
+ ```
107
+
108
+ `unitsUsed` works in `fit()`, `accumulate()`, `tuneWindowSize()` and `pria()`.
109
+ This differs from dropping the other units' rows, which also removes them from
110
+ everyone's windows.
111
+
112
+ ---
113
+
94
114
  ## Reduced-Code Search (PRIA)
95
115
 
96
116
  `pria()` finds the largest set of codes (up to `removeNum`, never leaving
@@ -136,7 +156,13 @@ columns in the data.
136
156
  // dims = [nRoleValues, 2] → Teacher uses window=4, Student uses window=2.
137
157
  const model = ena.fit(rows, {
138
158
  codes, units, conversations,
139
- ordered: true, // directed (n² connections); also works without a tensor
159
+ ordered: true, // directed (n² connections); also works without a tensor.
160
+ // Ordered models follow ona::model(): zero-network units are
161
+ // left out of the centring mean but shifted by it, nodes use
162
+ // directed positions, and points/nodes are centred on the origin.
163
+ // connectionNames / rotation.adjacencyKey list all n² directed
164
+ // connections as R names them: column j*n + i is
165
+ // "codes[i] & codes[j]", ground codes[i] → response codes[j].
140
166
  tensor: {
141
167
  dims: [2, 2], // [nRoleValues=2, weight/window=2]
142
168
  dimsSender: [0], // axis 0 is a sender factor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qe-libs/rena-wasm",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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",
package/src/data.js CHANGED
@@ -24,6 +24,10 @@ export function rowKey(row, cols) {
24
24
  * @param {string[]} codes - Code column names
25
25
  * @param {string[]} unitCols - Column(s) that identify a unit (e.g. ['UserName', 'Condition'])
26
26
  * @param {string[]} convoCols - Column(s) that identify a conversation (e.g. ['Condition', 'GroupName'])
27
+ * @param {string[]|null} [unitsUsed=null] - Unit keys (rowKey format) to model
28
+ * (= R's `units.used`). Rows of other units stay in their conversations, so
29
+ * they still count as context in the included units' windows, but they get
30
+ * no unit (unitOf = -1) and so produce no network. null = every unit.
27
31
  *
28
32
  * @returns {{
29
33
  * codeMatrix: Float64Array, // n_rows × n_codes, row-major
@@ -31,14 +35,14 @@ export function rowKey(row, cols) {
31
35
  * nCodes: number,
32
36
  * nUnits: number,
33
37
  * unitLabels: string[], // unit key for each unit index
34
- * unitOf: Int32Array, // unit index for each row
38
+ * unitOf: Int32Array, // unit index for each row (-1 = not a modeled unit)
35
39
  * convoOf: Int32Array, // conversation index for each row
36
40
  * convoGroups: Map<number, number[]>, // convoIdx → [rowIdx, ...]
37
41
  * metaData: Object[], // one metadata object per unit (first-row representative)
38
42
  * metaCols: string[], // names of metadata columns
39
43
  * }}
40
44
  */
41
- export function parseData(rows, codes, unitCols, convoCols) {
45
+ export function parseData(rows, codes, unitCols, convoCols, unitsUsed = null) {
42
46
  const nRows = rows.length;
43
47
  const nCodes = codes.length;
44
48
 
@@ -58,6 +62,8 @@ export function parseData(rows, codes, unitCols, convoCols) {
58
62
  // Per-unit metadata rows — populated on first occurrence of each unit.
59
63
  const metaData = [];
60
64
 
65
+ const used = unitsUsed ? new Set(unitsUsed.map(String)) : null;
66
+
61
67
  for (let i = 0; i < nRows; i++) {
62
68
  const row = rows[i];
63
69
 
@@ -68,7 +74,9 @@ export function parseData(rows, codes, unitCols, convoCols) {
68
74
 
69
75
  // Unit index
70
76
  const uKey = rowKey(row, unitCols);
71
- if (!unitIndex.has(uKey)) {
77
+ if (used && !used.has(uKey)) {
78
+ unitOf[i] = -1;
79
+ } else if (!unitIndex.has(uKey)) {
72
80
  const uIdx = unitIndex.size;
73
81
  unitIndex.set(uKey, uIdx);
74
82
  // First row for this unit → representative metadata
@@ -76,7 +84,7 @@ export function parseData(rows, codes, unitCols, convoCols) {
76
84
  for (const col of metaCols) meta[col] = row[col];
77
85
  metaData[uIdx] = meta;
78
86
  }
79
- unitOf[i] = unitIndex.get(uKey);
87
+ if (unitOf[i] !== -1) unitOf[i] = unitIndex.get(uKey);
80
88
 
81
89
  // Conversation index
82
90
  const cKey = rowKey(row, convoCols);
@@ -90,6 +98,9 @@ export function parseData(rows, codes, unitCols, convoCols) {
90
98
 
91
99
  const unitLabels = Array.from(unitIndex.keys());
92
100
  const nUnits = unitLabels.length;
101
+ if (used && nUnits === 0) {
102
+ throw new Error('opts.unitsUsed matched no units in the data');
103
+ }
93
104
 
94
105
  return { codeMatrix, nRows, nCodes, nUnits, unitLabels, unitOf, convoOf, convoGroups,
95
106
  metaData, metaCols };
package/src/index.js CHANGED
@@ -90,19 +90,45 @@ function computeVariance(points, nUnits, dims) {
90
90
 
91
91
  /**
92
92
  * Build the adjacency key — list of [codeA, codeB] pairs for each connection.
93
- * Matches R's enadata$adjacency.matrix (column-major upper triangle order).
93
+ * Matches R's rotation$adjacency.key.
94
+ *
95
+ * Unordered: the upper triangle in column-major order (choose_two(n) pairs).
96
+ * Ordered: all n² directed connections in column-major order — column
97
+ * j*n + i is [codes[i], codes[j]], ground codes[i] → response codes[j]
98
+ * (as R names it "codes[i] & codes[j]").
94
99
  *
95
100
  * @param {string[]} codes
101
+ * @param {boolean} [ordered=false]
96
102
  * @returns {string[][]} length nConnections, each entry is [codeI, codeJ]
97
103
  */
98
- function buildAdjacencyKey(codes) {
104
+ function buildAdjacencyKey(codes, ordered = false) {
99
105
  const key = [];
106
+ if (ordered) {
107
+ for (let j = 0; j < codes.length; j++)
108
+ for (let i = 0; i < codes.length; i++)
109
+ key.push([codes[i], codes[j]]);
110
+ return key;
111
+ }
100
112
  for (let j = 1; j < codes.length; j++)
101
113
  for (let i = 0; i < j; i++)
102
114
  key.push([codes[i], codes[j]]);
103
115
  return key;
104
116
  }
105
117
 
118
+ /**
119
+ * Connection names, "codeA & codeB" per connection, in network column order
120
+ * (= R's colnames(set$connection.counts)).
121
+ *
122
+ * @param {object} qe
123
+ * @param {string[]} codes
124
+ * @param {boolean} [ordered=false]
125
+ * @returns {string[]}
126
+ */
127
+ function connectionNamesFor(qe, codes, ordered = false) {
128
+ if (!ordered) return qe.connection_names(codes);
129
+ return buildAdjacencyKey(codes, true).map(([a, b]) => `${a} & ${b}`);
130
+ }
131
+
106
132
  // ── ENA result object ─────────────────────────────────────────────────────────
107
133
 
108
134
  class ENAModel {
@@ -250,16 +276,18 @@ function applyCodeMask(networks, codeMask, nCodes, nUnits, nConnections, ordered
250
276
 
251
277
  function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
252
278
  metaData, rotMethod, groupA, groupB, dims, gParams,
253
- rowConnectionCounts = null, directedNodes = false) {
254
- const connectionNames = qe.connection_names(codes);
279
+ rowConnectionCounts = null, ordered = false) {
280
+ const connectionNames = connectionNamesFor(qe, codes, ordered);
255
281
 
256
282
  // Sphere norm → lineWeights (= R's set$line.weights)
257
283
  const lineWeights = sphereNorm(qe, rawNetworks, nUnits, nConnections);
258
284
 
259
285
  // Center → pointsForProjection (= R's model$points.for.projection)
260
286
  // centerVec (= R's rotation$center.vec)
287
+ // Ordered (ONA) models follow ona::model(): zero-network units are left
288
+ // out of the mean but still shifted by it.
261
289
  const { centered: pointsForProjection, centerVec } =
262
- center(qe, lineWeights, nUnits, nConnections);
290
+ center(qe, lineWeights, nUnits, nConnections, ordered);
263
291
 
264
292
  // Rotate
265
293
  let rot;
@@ -298,17 +326,30 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
298
326
  // Matches rENA's lws.positions.sq, which regresses the projected points onto
299
327
  // the SPHERE-normed line weights (enaset$line.weights) — not the centered
300
328
  // networks. Verified node-for-node against R rENA on rs.data.new.csv.
301
- // `directedNodes` (ordered networks, used by pria()) solves with libqe's
302
- // directed_node_positions, as rENA's optimize() does for ordered sets.
329
+ // Ordered (ONA) models use libqe's directed_node_positions, as
330
+ // ona::model() does (via rENA's optimize()).
303
331
  const { nodes, centroids } = nodePositions(
304
- qe, lineWeights, nUnits, nConnections, points, dims, directedNodes
332
+ qe, lineWeights, nUnits, nConnections, points, dims, ordered
305
333
  );
306
334
 
335
+ // ONA: translate points and nodes so the points' mean is at the origin
336
+ // (ona::model's center_to_origin; centroids are left as computed, as in R).
337
+ if (ordered) {
338
+ const nCodes = nodes.length / dims;
339
+ for (let d = 0; d < dims; d++) {
340
+ let mean = 0;
341
+ for (let u = 0; u < nUnits; u++) mean += points[u * dims + d];
342
+ mean /= nUnits;
343
+ for (let u = 0; u < nUnits; u++) points[u * dims + d] -= mean;
344
+ for (let c = 0; c < nCodes; c++) nodes[c * dims + d] -= mean;
345
+ }
346
+ }
347
+
307
348
  // Variance explained (= R's model$variance)
308
349
  const variance = computeVariance(points, nUnits, dims);
309
350
 
310
351
  // Adjacency key (= R's rotation$adjacency.key)
311
- const adjacencyKey = buildAdjacencyKey(codes);
352
+ const adjacencyKey = buildAdjacencyKey(codes, ordered);
312
353
 
313
354
  return new ENAModel({
314
355
  // top-level
@@ -366,6 +407,8 @@ export default async function loadENA() {
366
407
  * @param {number[]} [opts.groupB] - Unit indices for means rotation group B
367
408
  * @param {object} [opts.gParams] - Pre-built GMR parameters for 'generalized' rotation
368
409
  * @param {number} [opts.dims=2] - Number of dimensions to return
410
+ * @param {string[]} [opts.unitsUsed] - Unit keys to model (= R's units.used);
411
+ * other units' rows remain as context
369
412
  *
370
413
  * @returns {ENAModel}
371
414
  */
@@ -385,6 +428,7 @@ export default async function loadENA() {
385
428
  dims = 2,
386
429
  codeMask,
387
430
  weightModel,
431
+ unitsUsed,
388
432
  } = opts;
389
433
 
390
434
  if (!codes?.length) throw new Error('opts.codes is required');
@@ -401,7 +445,7 @@ export default async function loadENA() {
401
445
 
402
446
  const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
403
447
  unitOf, convoGroups, metaData } =
404
- parseData(rows, codes, units, conversations);
448
+ parseData(rows, codes, units, conversations, unitsUsed);
405
449
 
406
450
  // Every model accumulates through the tensor path; a plain moving
407
451
  // window is expressed as defaultTensor(window).
@@ -416,7 +460,7 @@ export default async function loadENA() {
416
460
 
417
461
  return runPipeline(qe, rawNetworks, nUnits, nConnections, codes,
418
462
  unitLabels, metaData, rotMethod, groupA, groupB,
419
- dims, gParams, rowConnectionCounts);
463
+ dims, gParams, rowConnectionCounts, ordered);
420
464
  },
421
465
 
422
466
  /**
@@ -445,11 +489,12 @@ export default async function loadENA() {
445
489
  tensor: tensorDef,
446
490
  codeMask,
447
491
  weightModel,
492
+ unitsUsed,
448
493
  } = opts;
449
494
 
450
495
  const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
451
496
  unitOf, convoGroups, metaData } =
452
- parseData(rows, codes, units, conversations);
497
+ parseData(rows, codes, units, conversations, unitsUsed);
453
498
 
454
499
  const { networks, rowConnectionCounts } = accumulateTensor(
455
500
  qe, rows, codeMatrix, nRows, nCodes, nUnits,
@@ -460,7 +505,7 @@ export default async function loadENA() {
460
505
 
461
506
  applyCodeMask(networks, codeMask, codes.length, nUnits, nConnections, ordered);
462
507
 
463
- const connectionNames = qe.connection_names(codes);
508
+ const connectionNames = connectionNamesFor(qe, codes, ordered);
464
509
  return {
465
510
  connectionCounts: networks, rowConnectionCounts, unitLabels, connectionNames,
466
511
  metaData, nUnits, nConnections,
@@ -468,7 +513,7 @@ export default async function loadENA() {
468
513
  // (= R's ENAAccumulation$`_function.call`).
469
514
  _call: { rows, codes, units, conversations,
470
515
  window: windowSize, binary, ordered, tensor: tensorDef,
471
- weightModel },
516
+ weightModel, unitsUsed },
472
517
  };
473
518
  },
474
519
 
@@ -506,12 +551,12 @@ export default async function loadENA() {
506
551
  'maxSize must be greater than minSize to compare windows.'
507
552
  );
508
553
 
509
- const { rows, codes, units, conversations, binary, ordered, weightModel } = call;
554
+ const { rows, codes, units, conversations, binary, ordered, weightModel, unitsUsed } = call;
510
555
 
511
556
  // Parse once; only the window size changes between iterations.
512
557
  const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
513
558
  unitOf, convoGroups, metaData } =
514
- parseData(rows, codes, units, conversations);
559
+ parseData(rows, codes, units, conversations, unitsUsed);
515
560
  const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
516
561
 
517
562
  // 1. Rebuild + fit at each window, collecting unit points.
@@ -525,7 +570,8 @@ export default async function loadENA() {
525
570
  );
526
571
  const model = runPipeline(
527
572
  qe, raw, nUnits, nConnections, codes,
528
- unitLabels, metaData, 'svd', undefined, undefined, dims
573
+ unitLabels, metaData, 'svd', undefined, undefined, dims,
574
+ undefined, null, ordered
529
575
  );
530
576
  allPoints.push(model.points);
531
577
  }
@@ -548,7 +594,7 @@ export default async function loadENA() {
548
594
  // 4. Rebuild the accumulation at the selected window size.
549
595
  return api.accumulate(rows, {
550
596
  codes, units, conversations, window: bestWindow, binary, ordered,
551
- weightModel,
597
+ weightModel, unitsUsed,
552
598
  });
553
599
  },
554
600
 
@@ -680,7 +726,8 @@ export default async function loadENA() {
680
726
  * @param {Object[]} rows
681
727
  * @param {object} opts - fit() options (codes, units, conversations,
682
728
  * window, binary, ordered, tensor, weightModel,
683
- * codeMask, rotation, groupA, groupB, gParams)
729
+ * codeMask, rotation, groupA, groupB, gParams,
730
+ * unitsUsed)
684
731
  * plus removeNum (default 3) and threshold
685
732
  * (default 0.95). `dims` is ignored: scoring
686
733
  * uses dims 1:2 and the variance uses all dims.
@@ -693,7 +740,7 @@ export default async function loadENA() {
693
740
  window: windowSize = 4, binary = true, ordered = false,
694
741
  tensor: tensorDef, weightModel, codeMask,
695
742
  rotation = 'svd', gParams, groupA, groupB,
696
- removeNum = 3, threshold = 0.95,
743
+ removeNum = 3, threshold = 0.95, unitsUsed,
697
744
  } = opts;
698
745
  if (!codes?.length) throw new Error('opts.codes is required');
699
746
  if (!units?.length) throw new Error('opts.units is required');
@@ -707,7 +754,7 @@ export default async function loadENA() {
707
754
  // Accumulate once, exactly as fit() would for these options.
708
755
  const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
709
756
  unitOf, convoGroups, metaData } =
710
- parseData(rows, codes, units, conversations);
757
+ parseData(rows, codes, units, conversations, unitsUsed);
711
758
  const { networks: raw } = accumulateTensor(
712
759
  qe, rows, codeMatrix, nRows, nCodes, nUnits,
713
760
  unitOf, convoGroups, tensorDef ?? defaultTensor(windowSize),
@@ -745,8 +792,6 @@ export default async function loadENA() {
745
792
  networks[u * nConn + c] = raw[u * nConnections + cols[c]];
746
793
  keptCodes = codes.filter((_, i) => keep(i));
747
794
  }
748
- // Ordered models are scored with directed node positions (R's
749
- // ordered pipeline), independent of how fit() places nodes.
750
795
  return runPipeline(qe, networks, nUnits, nConn, keptCodes,
751
796
  unitLabels, metaData, rotation, groupA, groupB,
752
797
  nConn, gParams, null, ordered);
package/src/pipeline.js CHANGED
@@ -133,11 +133,15 @@ export function sphereNorm(qe, networks, nUnits, nConnections) {
133
133
  * Subtract column means (center the network space).
134
134
  * Excludes all-zero rows from the mean calculation (zero-network exclusion).
135
135
  *
136
+ * @param {boolean} [shiftZeroRows=false] Also subtract the mean from all-zero
137
+ * rows, as rENA's center(exclude_zero_networks = TRUE) does -- the ONA
138
+ * convention (ona::model). By default zero-network rows stay at zero, as in
139
+ * rENA's ena.make.set().
136
140
  * @returns {{ centered: Float64Array, centerVec: Float64Array }}
137
- * centered — mean-subtracted networks (zero-network rows left at zero)
141
+ * centered — mean-subtracted networks
138
142
  * centerVec — the column means used for centering (= R's rotation$center.vec)
139
143
  */
140
- export function center(qe, networks, nUnits, nConnections) {
144
+ export function center(qe, networks, nUnits, nConnections, shiftZeroRows = false) {
141
145
  // Identify non-zero rows
142
146
  const active = [];
143
147
  for (let u = 0; u < nUnits; u++) {
@@ -160,7 +164,7 @@ export function center(qe, networks, nUnits, nConnections) {
160
164
  const activeSet = new Set(active);
161
165
  const centered = new Float64Array(networks.length);
162
166
  for (let u = 0; u < nUnits; u++) {
163
- if (!activeSet.has(u)) continue; // leave zero-network row as zero
167
+ if (!shiftZeroRows && !activeSet.has(u)) continue; // leave zero-network row as zero
164
168
  for (let c = 0; c < nConnections; c++) {
165
169
  centered[u * nConnections + c] = networks[u * nConnections + c] - means[c];
166
170
  }
package/src/tensor.js CHANGED
@@ -197,10 +197,13 @@ export function accumulateTensor(qe, rows, codeMatrix, nRows, nCodes, nUnits,
197
197
  ? buildContextLookupWithTimes(rows, rowIndices, factors, factorLevels, timesCol)
198
198
  : buildContextLookup(rows, rowIndices, factors, factorLevels);
199
199
 
200
- // Group response rows by unit (within this conversation)
200
+ // Group response rows by unit (within this conversation). Rows with
201
+ // no unit (unitOf = -1, excluded via unitsUsed) are never responses,
202
+ // but stay in convoCodes as context for the other units' windows.
201
203
  const unitConvoRows = new Map();
202
204
  for (let r = 0; r < nConvo; r++) {
203
205
  const unit = unitOf[rowIndices[r]];
206
+ if (unit < 0) continue;
204
207
  if (!unitConvoRows.has(unit)) unitConvoRows.set(unit, []);
205
208
  unitConvoRows.get(unit).push(r); // local (conversation-relative) index
206
209
  }