@cnstra/core 2.6.0 → 3.2.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/dist/index.js CHANGED
@@ -1,39 +1,17 @@
1
- // src/CNSStimulationContextStore.ts
2
- var CNSStimulationContextStore = class {
3
- constructor(ctx) {
4
- this.ctx = ctx;
5
- }
6
- get(key) {
7
- return this.ctx?.get(key);
8
- }
9
- set(key, value) {
10
- (this.ctx ?? (this.ctx = /* @__PURE__ */ new Map())).set(key, value);
11
- }
12
- getAll() {
13
- return this.ctx ? new Map(this.ctx) : /* @__PURE__ */ new Map();
14
- }
15
- setAll(values) {
16
- const ctx = this.ctx ?? (this.ctx = /* @__PURE__ */ new Map());
17
- ctx.clear();
18
- for (const [key, value] of values) ctx.set(key, value);
19
- }
20
- delete(key) {
21
- this.ctx?.delete(key);
22
- }
23
- };
24
-
25
1
  // src/CNSStimulation.ts
26
2
  var TASK_DENDRITE = /* @__PURE__ */ Symbol("cnstra.taskDendrite");
3
+ var TASK_ACTIVE_POSITION = /* @__PURE__ */ Symbol("cnstra.taskActivePosition");
4
+ var TASK_PENDING_POSITION = /* @__PURE__ */ Symbol("cnstra.taskPendingPosition");
27
5
  var CNSDendriteContext = class {
28
- constructor(stimulation, neuron2, abortSignal, cns) {
6
+ constructor(stimulation, abortSignal, cns, task) {
29
7
  this.stimulation = stimulation;
30
- this.neuron = neuron2;
31
8
  this.abortSignal = abortSignal;
32
9
  this.cns = cns;
10
+ this.task = task;
33
11
  }
34
- // Prototype getter, not a constructor field: the per-activation allocation is
35
- // unchanged (still 4 fields) and you only pay a single `cns.global` read on the
36
- // dendrites that actually reach for it. Value getter, so `{ global }` destructuring is safe.
12
+ // Prototype getter, not a constructor field: you only pay a single `cns.global`
13
+ // read on the dendrites that actually reach for it. Value getter, so
14
+ // `{ global }` destructuring is safe.
37
15
  get global() {
38
16
  return this.cns.global;
39
17
  }
@@ -44,20 +22,16 @@ var CNSDendriteContext = class {
44
22
  get stimulationContext() {
45
23
  return this.stimulation.options?.stimulationContext;
46
24
  }
47
- get() {
48
- return this.stimulation.getContext().get(this.neuron);
49
- }
50
- set(value) {
51
- this.stimulation.getContext().set(this.neuron, value);
52
- }
53
- delete() {
54
- this.stimulation.getContext().delete(this.neuron);
25
+ // This activation's own store - written by a response listener before the
26
+ // activation ran. Prototype getter like the two above: nothing is allocated
27
+ // for a handler that never reaches for it.
28
+ get activationContext() {
29
+ return this.task?.activationContext;
55
30
  }
56
31
  };
57
32
  var CNSStimulationResponseImpl = class {
58
- constructor(stimulation, store, inputSignal, outputSignal, queueLength, pendingActivations, activeActivations, hops, error, neuron2) {
33
+ constructor(stimulation, inputSignal, outputSignal, queueLength, pendingActivations, activeActivations, hops, error, neuron, startedAt, duration, scheduledActivationTasks = EMPTY_TASKS, activationContext = void 0) {
59
34
  this.stimulation = stimulation;
60
- this.store = store;
61
35
  this.inputSignal = inputSignal;
62
36
  this.outputSignal = outputSignal;
63
37
  this.queueLength = queueLength;
@@ -65,12 +39,15 @@ var CNSStimulationResponseImpl = class {
65
39
  this.activeActivations = activeActivations;
66
40
  this.hops = hops;
67
41
  this.error = error;
68
- this.neuron = neuron2;
69
- }
70
- get contextValue() {
71
- return this.store ? this.store.getAll() : /* @__PURE__ */ new Map();
42
+ this.neuron = neuron;
43
+ this.startedAt = startedAt;
44
+ this.duration = duration;
45
+ this.scheduledActivationTasks = scheduledActivationTasks;
46
+ this.activationContext = activationContext;
72
47
  }
73
48
  };
49
+ var EMPTY_TASKS = [];
50
+ var EMPTY_SUBSCRIBERS = [];
74
51
  var CNSStimulation = class {
75
52
  constructor(cns, instanceNeuronQueue, options, onResponse, onDrain) {
76
53
  this.cns = cns;
@@ -97,14 +74,16 @@ var CNSStimulation = class {
97
74
  this.isCompleted = false;
98
75
  this.completionRejected = false;
99
76
  this.onDrain = onDrain;
100
- this.ctxStore = options?.ctx;
101
77
  this.instanceNeuronQueue = instanceNeuronQueue;
102
78
  this.onResponse = onResponse;
79
+ this.concurrencyLimit = this.options?.concurrency ?? Infinity;
80
+ this.abortSignal = this.options?.abortSignal;
81
+ this.subscribersByCollateralIndex = cns.network.getSubscriberTable(
82
+ this.options?.modality,
83
+ this.options?.afferentPath
84
+ );
103
85
  if (this.options?.maxNeuronHops) {
104
- this.neuronVisitMap = /* @__PURE__ */ new Map();
105
- }
106
- if (this.autoCleanupContextsEnabled) {
107
- this.activeSccCounts = /* @__PURE__ */ new Map();
86
+ this.neuronVisitCounts = [];
108
87
  }
109
88
  }
110
89
  pushFailedTask(failure) {
@@ -119,14 +98,9 @@ var CNSStimulation = class {
119
98
  this.completionError = error;
120
99
  this.rejectCompleted?.(error);
121
100
  }
122
- /** Lazily-created neuron context store (see {@link ctxStore}). */
123
- get ctx() {
124
- return this.ctxStore ?? (this.ctxStore = new CNSStimulationContextStore());
125
- }
126
101
  // ----- Inlined activation queue / pump -----
127
102
  get canStartOperation() {
128
- const limit = this.options?.concurrency ?? Infinity;
129
- return this.activeOperations < limit && !this.options?.abortSignal?.aborted;
103
+ return this.activeOperations < this.concurrencyLimit && !this.abortSignal?.aborted;
130
104
  }
131
105
  qResize() {
132
106
  const oldCapacity = this.qCapacity;
@@ -183,48 +157,56 @@ var CNSStimulation = class {
183
157
  return;
184
158
  }
185
159
  this.pumping = true;
186
- while (this.canStartOperation && this.qSize > 0) {
187
- const task = this.qDequeue();
188
- this.activeOperations++;
189
- const ret = this.executeActivationTask(task);
190
- if (ret && typeof ret.then === "function") {
191
- (this.activeTasks ?? (this.activeTasks = /* @__PURE__ */ new Set())).add(task);
192
- ret.then(
193
- (cb) => {
194
- this.activeOperations--;
195
- this.activeTasks.delete(task);
196
- this.beginTurn();
197
- try {
198
- if (typeof cb === "function") cb();
199
- if (this.qSize > 0 && this.canStartOperation) {
200
- if (this.pumping) this.needsPump = true;
201
- else this.pump();
160
+ try {
161
+ while (this.canStartOperation && this.qSize > 0) {
162
+ const task = this.qDequeue();
163
+ this.activeOperations++;
164
+ const ret = this.executeActivationTask(task);
165
+ if (ret && typeof ret.then === "function") {
166
+ this.addPositionedTask(this.activeTasks ?? (this.activeTasks = []), task, TASK_ACTIVE_POSITION);
167
+ ret.then(
168
+ (cb) => {
169
+ this.activeOperations--;
170
+ this.removeActiveTask(task);
171
+ this.beginTurn();
172
+ try {
173
+ if (typeof cb === "function") cb();
174
+ if (this.qSize > 0 && this.canStartOperation) {
175
+ if (this.pumping) this.needsPump = true;
176
+ else this.pump();
177
+ }
178
+ } catch (error) {
179
+ this.endRunWithError(error);
180
+ } finally {
181
+ this.endTurn();
202
182
  }
203
- } finally {
204
- this.endTurn();
205
- }
206
- },
207
- (err) => {
208
- this.activeOperations--;
209
- this.activeTasks.delete(task);
210
- this.beginTurn();
211
- try {
212
- if (this.qSize > 0 && this.canStartOperation) {
213
- if (this.pumping) this.needsPump = true;
214
- else this.pump();
183
+ },
184
+ (err) => {
185
+ this.activeOperations--;
186
+ this.removeActiveTask(task);
187
+ this.beginTurn();
188
+ try {
189
+ if (this.qSize > 0 && this.canStartOperation) {
190
+ if (this.pumping) this.needsPump = true;
191
+ else this.pump();
192
+ }
193
+ } finally {
194
+ this.endTurn();
215
195
  }
216
- } finally {
217
- this.endTurn();
196
+ throw err;
218
197
  }
219
- throw err;
220
- }
221
- );
222
- continue;
198
+ );
199
+ continue;
200
+ }
201
+ this.activeOperations--;
202
+ if (typeof ret === "function") ret();
223
203
  }
224
- this.activeOperations--;
225
- if (typeof ret === "function") ret();
204
+ } catch (error) {
205
+ this.endRunWithError(error);
206
+ throw error;
207
+ } finally {
208
+ this.pumping = false;
226
209
  }
227
- this.pumping = false;
228
210
  if (this.needsPump) {
229
211
  this.needsPump = false;
230
212
  this.pump();
@@ -266,14 +248,30 @@ var CNSStimulation = class {
266
248
  }
267
249
  }
268
250
  /**
269
- * Activations that exist (or are reserved) but whose dendrite body has not
270
- * been invoked yet: sitting in the ring buffer, created and held in
251
+ * Activations that exist (or are reserved) but whose dendrite response has
252
+ * not been invoked yet: sitting in the ring buffer, created and held in
271
253
  * pendingTasks, or reserved by an array fan-out mid-dispatch. Subscribers
272
254
  * parked behind an async onResponse land here too - their body has not run.
273
255
  */
274
256
  get pendingActivations() {
275
257
  return this.qSize + this.scheduledCount + this.reservedActivations;
276
258
  }
259
+ /**
260
+ * End the run because the machinery itself threw. Dendrite bodies never get
261
+ * here - runStarter catches those and records them as failed tasks; what
262
+ * lands here is a throw from dispatch, today the hop-limit guard in
263
+ * createSubscriberQueueItem. Nothing settles the counters after such a
264
+ * throw, so completion has to be forced: without it the stimulation neither
265
+ * resolves nor rejects, and a worker holds a job that never finishes and
266
+ * never fails.
267
+ */
268
+ endRunWithError(error) {
269
+ if (this.isCompleted) return;
270
+ this.isCompleted = true;
271
+ this.finalizeReject(
272
+ error instanceof Error ? error : new Error(String(error))
273
+ );
274
+ }
277
275
  tryResolveCompleted() {
278
276
  if (this.isCompleted) return;
279
277
  const noActive = this.activeOperations === 0;
@@ -297,6 +295,7 @@ var CNSStimulation = class {
297
295
  }
298
296
  }
299
297
  this.isCompleted = true;
298
+ if (!noPending) this.emitAbortedResponse();
300
299
  this.finalizeReject(new Error("Stimulation aborted"));
301
300
  return;
302
301
  }
@@ -329,10 +328,34 @@ var CNSStimulation = class {
329
328
  */
330
329
  getAllActivationTasks() {
331
330
  const queuedTasks = this.qGetQueuedTasks();
332
- const activeTasks = this.activeTasks ? Array.from(this.activeTasks) : [];
333
- const pendingTasks = this.pendingTasks ? Array.from(this.pendingTasks) : [];
331
+ const activeTasks = this.activeTasks ?? EMPTY_TASKS;
332
+ const pendingTasks = this.pendingTasks ?? EMPTY_TASKS;
334
333
  return [...queuedTasks, ...activeTasks, ...pendingTasks];
335
334
  }
335
+ getSubscribers(collateral) {
336
+ return this.subscribersByCollateralIndex[collateral.index] ?? EMPTY_SUBSCRIBERS;
337
+ }
338
+ addPositionedTask(tasks, task, positionKey) {
339
+ task[positionKey] = tasks.length;
340
+ tasks.push(task);
341
+ }
342
+ removePositionedTask(tasks, task, positionKey) {
343
+ if (tasks === void 0) return;
344
+ const position = task[positionKey];
345
+ if (position === void 0 || tasks[position] !== task) return;
346
+ const lastTask = tasks.pop();
347
+ if (lastTask !== task) {
348
+ tasks[position] = lastTask;
349
+ lastTask[positionKey] = position;
350
+ }
351
+ task[positionKey] = void 0;
352
+ }
353
+ removeActiveTask(task) {
354
+ this.removePositionedTask(this.activeTasks, task, TASK_ACTIVE_POSITION);
355
+ }
356
+ removePendingTask(task) {
357
+ this.removePositionedTask(this.pendingTasks, task, TASK_PENDING_POSITION);
358
+ }
336
359
  /**
337
360
  * Returns all tasks that failed or were aborted
338
361
  */
@@ -345,18 +368,25 @@ var CNSStimulation = class {
345
368
  * failed/aborted tasks. This is the set to persist for resume: re-activating
346
369
  * it re-runs exactly the not-yet-done work. Safe to call at any point during
347
370
  * a run (for progress) or after it fails.
371
+ *
372
+ * Each task appears exactly once. The two sources overlap: aborting marks
373
+ * every still-queued task as failed without dequeuing it, so a task sits in
374
+ * the ring buffer AND in failedTasks. Concatenating them blindly handed the
375
+ * broker a checkpoint with the whole queued frontier listed twice, and the
376
+ * resumed run re-executed every one of those tasks a second time - duplicate
377
+ * charges, emails and POSTs on every graceful shutdown.
348
378
  */
349
379
  getOutstandingTasks() {
350
- return [
351
- ...this.getAllActivationTasks(),
352
- ...this.getFailedTasks().map((f) => f.task)
353
- ];
354
- }
355
- /**
356
- * Get the context store for this stimulation
357
- */
358
- getContext() {
359
- return this.ctx;
380
+ const seen = /* @__PURE__ */ new Set();
381
+ const outstanding = [];
382
+ const add = (task) => {
383
+ if (seen.has(task)) return;
384
+ seen.add(task);
385
+ outstanding.push(task);
386
+ };
387
+ for (const task of this.getAllActivationTasks()) add(task);
388
+ for (const failure of this.getFailedTasks()) add(failure.task);
389
+ return outstanding;
360
390
  }
361
391
  /**
362
392
  * Enqueue activation tasks directly into the stimulation queue
@@ -375,79 +405,17 @@ var CNSStimulation = class {
375
405
  get concurrencyEnabled() {
376
406
  return this.options?.concurrency !== void 0 && this.options?.concurrency > 0;
377
407
  }
378
- get autoCleanupContextsEnabled() {
379
- return this.cns?.options?.autoCleanupContexts ?? false;
380
- }
381
- /**
382
- * Mark a neuron as active (being processed)
383
- */
384
- markNeuronActive(neuron2) {
385
- if (!this.autoCleanupContextsEnabled) return;
386
- this.incrementSccCount(neuron2);
387
- }
388
- /**
389
- * Mark a neuron as inactive (finished processing)
390
- */
391
- markNeuronInactive(neuron2) {
392
- if (!this.autoCleanupContextsEnabled) return;
393
- this.decrementSccCount(neuron2);
394
- }
395
- /**
396
- * Increment the active count for the SCC containing this neuron
397
- */
398
- incrementSccCount(neuron2) {
399
- const counts = this.activeSccCounts;
400
- if (!counts) return;
401
- const sccIndex = this.cns.network.getSccIndexByNeuron(neuron2);
402
- if (sccIndex === void 0) return;
403
- const currentCount = counts.get(sccIndex) || 0;
404
- counts.set(sccIndex, currentCount + 1);
405
- }
406
- /**
407
- * Decrement the active count for the SCC containing this neuron
408
- */
409
- decrementSccCount(neuron2) {
410
- const counts = this.activeSccCounts;
411
- if (!counts) return;
412
- const sccIndex = this.cns.network.getSccIndexByNeuron(neuron2);
413
- if (sccIndex === void 0) return;
414
- const currentCount = counts.get(sccIndex) || 0;
415
- const nextCount = Math.max(0, currentCount - 1);
416
- counts.set(sccIndex, nextCount);
417
- if (currentCount === 0) {
418
- console.warn(
419
- `[CNSStimulation] Attempting to decrement SCC count below 0 for neuron`
420
- );
421
- }
422
- }
423
- /**
424
- * Check if a neuron can be guaranteed not to be visited again
425
- */
426
- canNeuronBeGuaranteedDone(neuron2) {
427
- if (!this.autoCleanupContextsEnabled || !this.activeSccCounts)
428
- return false;
429
- return this.cns.network.canNeuronBeGuaranteedDone(
430
- neuron2,
431
- this.activeSccCounts
432
- );
433
- }
434
- cleanupCtxIfNeeded(neuron2) {
435
- if (this.autoCleanupContextsEnabled && this.canNeuronBeGuaranteedDone(neuron2)) {
436
- this.ctxStore?.delete(neuron2);
437
- }
438
- }
439
408
  createSubscriberQueueItem(subscriber, inputSignal) {
440
- if (this.options?.maxNeuronHops) {
441
- if ((this.neuronVisitMap?.get(subscriber.neuron) ?? 0) >= this.options?.maxNeuronHops) {
409
+ const neuronVisitCounts = this.neuronVisitCounts;
410
+ if (neuronVisitCounts !== void 0) {
411
+ const neuronIndex = subscriber.neuron.index;
412
+ const neuronVisitCount = neuronVisitCounts[neuronIndex] ?? 0;
413
+ if (neuronVisitCount >= this.options.maxNeuronHops) {
442
414
  throw new Error(
443
415
  `Max neuron hops reached when trying to enqueue subscriber`
444
416
  );
445
- } else {
446
- this.neuronVisitMap?.set(
447
- subscriber.neuron,
448
- (this.neuronVisitMap?.get(subscriber.neuron) ?? 0) + 1
449
- );
450
417
  }
418
+ neuronVisitCounts[neuronIndex] = neuronVisitCount + 1;
451
419
  }
452
420
  const neuronActivationTask = {
453
421
  neuron: subscriber.neuron,
@@ -455,12 +423,14 @@ var CNSStimulation = class {
455
423
  input: inputSignal
456
424
  };
457
425
  neuronActivationTask[TASK_DENDRITE] = subscriber;
426
+ neuronActivationTask[TASK_ACTIVE_POSITION] = void 0;
427
+ neuronActivationTask[TASK_PENDING_POSITION] = void 0;
458
428
  return neuronActivationTask;
459
429
  }
460
430
  executeActivationTask(neuronActivationTask) {
461
431
  let subscriber = neuronActivationTask[TASK_DENDRITE];
462
432
  if (!subscriber) {
463
- const subscribers = this.cns.network.getSubscribers(
433
+ const subscribers = this.getSubscribers(
464
434
  neuronActivationTask.dendriteCollateral
465
435
  );
466
436
  for (let i = 0; i < subscribers.length; i++) {
@@ -482,48 +452,49 @@ var CNSStimulation = class {
482
452
  void 0,
483
453
  neuronActivationTask.input,
484
454
  void 0,
485
- error
455
+ error,
456
+ void 0,
457
+ neuronActivationTask.activationContext
486
458
  );
487
459
  };
488
460
  }
489
- const neuron2 = subscriber.neuron;
461
+ const neuron = subscriber.neuron;
490
462
  const dendrite = subscriber.dendrite;
491
463
  const inputSignal = neuronActivationTask.input;
492
- const concurrency = neuron2.concurrency;
464
+ const concurrency = neuron.concurrency;
493
465
  if (concurrency === void 0 || concurrency <= 0) {
494
466
  return this.runStarter(
495
- neuron2,
467
+ neuron,
496
468
  dendrite,
497
469
  inputSignal,
498
470
  neuronActivationTask
499
471
  );
500
472
  }
501
473
  return this.instanceNeuronQueue.run(
502
- neuron2,
474
+ neuron,
503
475
  () => this.runStarter(
504
- neuron2,
476
+ neuron,
505
477
  dendrite,
506
478
  inputSignal,
507
479
  neuronActivationTask
508
480
  )
509
481
  );
510
482
  }
511
- runStarter(neuron2, dendrite, inputSignal, neuronActivationTask) {
512
- this.markNeuronActive(neuron2);
483
+ runStarter(neuron, dendrite, inputSignal, neuronActivationTask) {
513
484
  let response;
485
+ const startedAt = this.onResponse === void 0 ? void 0 : Date.now();
514
486
  try {
515
487
  response = dendrite.response(
516
488
  inputSignal?.payload,
517
- neuron2.axon,
489
+ neuron.axon,
518
490
  new CNSDendriteContext(
519
491
  this,
520
- neuron2,
521
492
  this.options?.abortSignal,
522
- this.cns
493
+ this.cns,
494
+ neuronActivationTask
523
495
  )
524
496
  );
525
497
  } catch (error) {
526
- this.markNeuronInactive(neuron2);
527
498
  const isAborted = this.options?.abortSignal?.aborted ?? false;
528
499
  this.pushFailedTask({
529
500
  task: neuronActivationTask,
@@ -532,14 +503,16 @@ var CNSStimulation = class {
532
503
  });
533
504
  return () => {
534
505
  this.processResponseOrResponses(
535
- neuron2,
506
+ neuron,
536
507
  inputSignal,
537
508
  void 0,
538
- error
509
+ error,
510
+ startedAt,
511
+ neuronActivationTask.activationContext
539
512
  );
540
513
  };
541
514
  }
542
- const maxDuration = neuron2.maxDuration;
515
+ const maxDuration = neuron.maxDuration;
543
516
  if (response instanceof Promise || maxDuration) {
544
517
  const asPromise = response instanceof Promise ? response : Promise.resolve(response);
545
518
  const timedPromise = maxDuration && maxDuration > 0 ? new Promise((resolve, reject) => {
@@ -563,17 +536,18 @@ var CNSStimulation = class {
563
536
  return timedPromise.then(
564
537
  (signal) => {
565
538
  return () => {
566
- this.markNeuronInactive(neuron2);
567
539
  return this.processResponseOrResponses(
568
- neuron2,
540
+ neuron,
569
541
  inputSignal,
570
- signal
542
+ signal,
543
+ void 0,
544
+ startedAt,
545
+ neuronActivationTask.activationContext
571
546
  );
572
547
  };
573
548
  },
574
549
  (error) => {
575
550
  return () => {
576
- this.markNeuronInactive(neuron2);
577
551
  const isAborted = this.options?.abortSignal?.aborted ?? false;
578
552
  this.pushFailedTask({
579
553
  task: neuronActivationTask,
@@ -581,29 +555,42 @@ var CNSStimulation = class {
581
555
  aborted: isAborted
582
556
  });
583
557
  return this.processResponseOrResponses(
584
- neuron2,
558
+ neuron,
585
559
  inputSignal,
586
560
  void 0,
587
- error
561
+ error,
562
+ startedAt,
563
+ neuronActivationTask.activationContext
588
564
  );
589
565
  };
590
566
  }
591
567
  );
592
568
  } else {
593
569
  return () => {
594
- this.markNeuronInactive(neuron2);
595
570
  return this.processResponseOrResponses(
596
- neuron2,
571
+ neuron,
597
572
  inputSignal,
598
- response
573
+ response,
574
+ void 0,
575
+ startedAt,
576
+ neuronActivationTask.activationContext
599
577
  );
600
578
  };
601
579
  }
602
580
  }
603
- processResponseOrResponses(emitter, inputSignal, outputSignalOrSignals, error) {
581
+ processResponseOrResponses(emitter, inputSignal, outputSignalOrSignals, error, startedAt, activationContext) {
582
+ const duration = startedAt === void 0 ? void 0 : Date.now() - startedAt;
604
583
  if (Array.isArray(outputSignalOrSignals)) {
605
584
  if (outputSignalOrSignals.length === 0) {
606
- this.processResponse(emitter, inputSignal, void 0, error);
585
+ this.processResponse(
586
+ emitter,
587
+ inputSignal,
588
+ void 0,
589
+ error,
590
+ startedAt,
591
+ duration,
592
+ activationContext
593
+ );
607
594
  return;
608
595
  }
609
596
  let perSignal;
@@ -612,32 +599,82 @@ var CNSStimulation = class {
612
599
  let reserved = 0;
613
600
  for (let i = 0; i < outputSignalOrSignals.length; i++) {
614
601
  const c = outputSignalOrSignals[i]?.collateral;
615
- const n = c ? this.cns.network.getSubscribers(c).length : 0;
602
+ const n = c ? this.getSubscribers(c).length : 0;
616
603
  perSignal[i] = n;
617
604
  reserved += n;
618
605
  }
619
606
  this.reservedActivations += reserved;
620
607
  }
621
- for (let i = 0; i < outputSignalOrSignals.length; i++) {
622
- if (perSignal) this.reservedActivations -= perSignal[i];
623
- this.processResponse(
624
- emitter,
625
- inputSignal,
626
- outputSignalOrSignals[i],
627
- error
628
- );
608
+ let dispatchIndex = 0;
609
+ try {
610
+ for (; dispatchIndex < outputSignalOrSignals.length; dispatchIndex++) {
611
+ if (perSignal)
612
+ this.reservedActivations -= perSignal[dispatchIndex];
613
+ this.processResponse(
614
+ emitter,
615
+ inputSignal,
616
+ outputSignalOrSignals[dispatchIndex],
617
+ error,
618
+ startedAt,
619
+ duration,
620
+ activationContext
621
+ );
622
+ }
623
+ } finally {
624
+ if (perSignal) {
625
+ for (let j = dispatchIndex + 1; j < perSignal.length; j++) {
626
+ this.reservedActivations -= perSignal[j];
627
+ }
628
+ }
629
629
  }
630
630
  return;
631
631
  }
632
- this.processResponse(emitter, inputSignal, outputSignalOrSignals, error);
632
+ this.processResponse(
633
+ emitter,
634
+ inputSignal,
635
+ outputSignalOrSignals,
636
+ error,
637
+ startedAt,
638
+ duration,
639
+ activationContext
640
+ );
633
641
  }
634
- processResponse(emitter, inputSignal, outputSignal, error) {
635
- const collateral2 = outputSignal?.collateral;
636
- const subscribers = collateral2 ? this.cns.network.getSubscribers(collateral2) : [];
642
+ /**
643
+ * The one response an aborted run emits: no neuron, no signals, an empty
644
+ * queue. It exists so `queueLength === 0` — the completion condition every
645
+ * listener uses — is reached on the abort path too. Listener errors are
646
+ * swallowed: the run is already over and rejecting, and there is nothing
647
+ * left to fail.
648
+ */
649
+ emitAbortedResponse() {
650
+ const onResponse = this.onResponse;
651
+ if (!onResponse) return;
652
+ try {
653
+ void onResponse(
654
+ new CNSStimulationResponseImpl(
655
+ this,
656
+ void 0,
657
+ void 0,
658
+ 0,
659
+ 0,
660
+ 0,
661
+ void 0,
662
+ new Error("Stimulation aborted"),
663
+ void 0,
664
+ void 0,
665
+ void 0
666
+ )
667
+ );
668
+ } catch (e) {
669
+ this.onResponseError = e instanceof Error ? e : new Error(String(e));
670
+ }
671
+ }
672
+ processResponse(emitter, inputSignal, outputSignal, error, startedAt, duration, activationContext) {
673
+ const collateral = outputSignal?.collateral;
674
+ const subscribers = collateral ? this.getSubscribers(collateral) : EMPTY_SUBSCRIBERS;
637
675
  const onResponse = this.onResponse;
638
676
  if (!onResponse) {
639
- if (collateral2 && !error) {
640
- if (emitter) this.cleanupCtxIfNeeded(emitter);
677
+ if (collateral && !error) {
641
678
  const n = subscribers.length;
642
679
  if (n > 0) {
643
680
  const tasks = new Array(n);
@@ -657,17 +694,17 @@ var CNSStimulation = class {
657
694
  this.tryResolveCompleted();
658
695
  return;
659
696
  }
660
- const subscriberActivationTasks = [];
661
- if (collateral2 && !error) {
662
- if (emitter) this.cleanupCtxIfNeeded(emitter);
663
- const pending = this.pendingTasks ?? (this.pendingTasks = /* @__PURE__ */ new Set());
697
+ let subscriberActivationTasks = EMPTY_TASKS;
698
+ if (collateral && !error && subscribers.length > 0) {
699
+ const pending = this.pendingTasks ?? (this.pendingTasks = []);
700
+ subscriberActivationTasks = new Array(subscribers.length);
664
701
  for (let i = 0; i < subscribers.length; i++) {
665
702
  const task = this.createSubscriberQueueItem(
666
703
  subscribers[i],
667
704
  outputSignal
668
705
  );
669
- subscriberActivationTasks.push(task);
670
- pending.add(task);
706
+ subscriberActivationTasks[i] = task;
707
+ this.addPositionedTask(pending, task, TASK_PENDING_POSITION);
671
708
  }
672
709
  }
673
710
  this.scheduledCount += subscriberActivationTasks.length;
@@ -676,22 +713,25 @@ var CNSStimulation = class {
676
713
  maybePromise = onResponse(
677
714
  new CNSStimulationResponseImpl(
678
715
  this,
679
- this.ctxStore,
680
716
  inputSignal,
681
717
  outputSignal,
682
718
  this.pendingActivations + this.activeOperations,
683
719
  this.pendingActivations,
684
720
  this.activeOperations,
685
- this.options?.maxNeuronHops && emitter ? this.neuronVisitMap?.get(emitter) ?? 0 : void 0,
721
+ this.options?.maxNeuronHops && emitter ? this.neuronVisitCounts?.[emitter.index] ?? 0 : void 0,
686
722
  error,
687
- emitter
723
+ emitter,
724
+ startedAt,
725
+ duration,
726
+ subscriberActivationTasks,
727
+ activationContext
688
728
  )
689
729
  );
690
730
  } catch (e) {
691
731
  this.onResponseError = e instanceof Error ? e : new Error(String(e));
692
732
  for (let i = 0; i < subscriberActivationTasks.length; i++) {
693
733
  this.scheduledCount--;
694
- this.pendingTasks?.delete(subscriberActivationTasks[i]);
734
+ this.removePendingTask(subscriberActivationTasks[i]);
695
735
  this.qEnqueue(subscriberActivationTasks[i]);
696
736
  }
697
737
  this.tryResolveCompleted();
@@ -704,7 +744,7 @@ var CNSStimulation = class {
704
744
  try {
705
745
  for (let i = 0; i < subscriberActivationTasks.length; i++) {
706
746
  this.scheduledCount--;
707
- this.pendingTasks?.delete(
747
+ this.removePendingTask(
708
748
  subscriberActivationTasks[i]
709
749
  );
710
750
  this.qEnqueue(subscriberActivationTasks[i]);
@@ -720,7 +760,7 @@ var CNSStimulation = class {
720
760
  try {
721
761
  for (let i = 0; i < subscriberActivationTasks.length; i++) {
722
762
  this.scheduledCount--;
723
- this.pendingTasks?.delete(
763
+ this.removePendingTask(
724
764
  subscriberActivationTasks[i]
725
765
  );
726
766
  this.qEnqueue(subscriberActivationTasks[i]);
@@ -735,7 +775,7 @@ var CNSStimulation = class {
735
775
  }
736
776
  for (let i = 0; i < subscriberActivationTasks.length; i++) {
737
777
  this.scheduledCount--;
738
- this.pendingTasks?.delete(subscriberActivationTasks[i]);
778
+ this.removePendingTask(subscriberActivationTasks[i]);
739
779
  this.qEnqueue(subscriberActivationTasks[i]);
740
780
  }
741
781
  this.tryResolveCompleted();
@@ -762,36 +802,36 @@ var CNSStimulation = class {
762
802
  // src/CNSInstanceNeuronQueue.ts
763
803
  var CNSInstanceNeuronQueue = class {
764
804
  constructor() {
765
- this.gates = /* @__PURE__ */ new Map();
805
+ this.gatesByNeuronIndex = [];
766
806
  }
767
- run(neuron2, fn) {
768
- const limit = neuron2?.concurrency;
807
+ run(neuron, fn) {
808
+ const limit = neuron?.concurrency;
769
809
  if (limit === void 0 || limit <= 0) {
770
810
  return fn();
771
811
  }
772
- let gate = this.gates.get(neuron2);
812
+ let gate = this.gatesByNeuronIndex[neuron.index];
773
813
  if (!gate) {
774
814
  gate = { limit, active: 0, waiters: [] };
775
- this.gates.set(neuron2, gate);
815
+ this.gatesByNeuronIndex[neuron.index] = gate;
776
816
  } else {
777
817
  gate.limit = limit;
778
818
  }
819
+ const ownGate = gate;
779
820
  const wrap = (cb) => {
780
821
  return () => {
781
822
  try {
782
823
  cb();
783
824
  } finally {
784
- const g = this.gates.get(neuron2);
785
- g.active = Math.max(0, g.active - 1);
786
- if (g.waiters.length > 0) {
787
- const next = g.waiters.shift();
825
+ ownGate.active = Math.max(0, ownGate.active - 1);
826
+ if (ownGate.waiters.length > 0) {
827
+ const next = ownGate.waiters.shift();
788
828
  next();
789
829
  }
790
830
  }
791
831
  };
792
832
  };
793
- if (gate.active < gate.limit) {
794
- gate.active++;
833
+ if (ownGate.active < ownGate.limit) {
834
+ ownGate.active++;
795
835
  const ret = fn();
796
836
  if (ret && typeof ret.then === "function") {
797
837
  return ret.then((cb) => wrap(cb));
@@ -800,8 +840,7 @@ var CNSInstanceNeuronQueue = class {
800
840
  }
801
841
  return new Promise((resolve) => {
802
842
  const starter = () => {
803
- const g = this.gates.get(neuron2);
804
- g.active++;
843
+ ownGate.active++;
805
844
  const ret = fn();
806
845
  if (ret && typeof ret.then === "function") {
807
846
  ret.then((cb) => resolve(wrap(cb)));
@@ -809,12 +848,52 @@ var CNSInstanceNeuronQueue = class {
809
848
  resolve(wrap(ret));
810
849
  }
811
850
  };
812
- gate.waiters.push(starter);
851
+ ownGate.waiters.push(starter);
813
852
  });
814
853
  }
815
854
  };
816
855
 
856
+ // src/utils/cnsResolveModalityHandler.ts
857
+ function cnsResolveModalityHandler(modalitySelection, modality, afferentPath) {
858
+ if (modality !== void 0) {
859
+ const modalityConfigs = modalitySelection.modalityConfigs;
860
+ for (let i = 0; i < modalityConfigs.length; i++) {
861
+ const modalityConfig = modalityConfigs[i];
862
+ if (modalityConfig.modality !== modality) continue;
863
+ for (let path = afferentPath; path !== void 0; path = path.parentAfferentPath) {
864
+ const handler = modalityConfig.afferentPaths?.get(path);
865
+ if (handler !== void 0) return handler;
866
+ }
867
+ if (modalityConfig.default !== void 0) return modalityConfig.default;
868
+ break;
869
+ }
870
+ }
871
+ return modalitySelection.defaultHandler;
872
+ }
873
+
874
+ // src/utils/cnsComposeModalityResponse.ts
875
+ function cnsComposeModalityResponse(handler, output) {
876
+ return (payload, axon, ctx) => {
877
+ const maybeResult = handler(payload, axon, ctx);
878
+ if (maybeResult && typeof maybeResult.then === "function") {
879
+ return maybeResult.then((result) => output(result, axon, ctx));
880
+ }
881
+ try {
882
+ return output(maybeResult, axon, ctx);
883
+ } catch (error) {
884
+ if (error instanceof Error && error.message.includes("Cannot read properties of undefined")) {
885
+ const axonKeys = axon && typeof axon === "object" ? Object.keys(axon) : "not an object";
886
+ throw new Error(
887
+ `modalityDendrite: axon.output is undefined. Axon type: ${typeof axon}, Axon keys: ${Array.isArray(axonKeys) ? axonKeys.join(", ") : axonKeys}. Make sure the neuron was created with the correct axon structure. Original error: ${error.message}`
888
+ );
889
+ }
890
+ throw error;
891
+ }
892
+ };
893
+ }
894
+
817
895
  // src/CNSNetwork.ts
896
+ var NO_SUBSCRIBERS = [];
818
897
  var CNSNetwork = class {
819
898
  constructor(neurons) {
820
899
  this.neurons = neurons;
@@ -822,8 +901,9 @@ var CNSNetwork = class {
822
901
  * Strongly Connected Components of the neuron graph.
823
902
  * Each component is a set of neuron objects that can reach each other.
824
903
  *
825
- * Computed lazily on first access: the SCC machinery is only needed for
826
- * `autoCleanupContexts` (off by default), so most networks never pay for it.
904
+ * Computed lazily on first access: the SCC machinery only drives graph
905
+ * introspection (`getSCCSetByNeuron`, `canNeuronBeGuaranteedDone`), so a
906
+ * network that never inspects reachability never pays for it.
827
907
  */
828
908
  this._stronglyConnectedComponents = [];
829
909
  this.sccBuilt = false;
@@ -843,16 +923,19 @@ var CNSNetwork = class {
843
923
  * Fast lookup: collateral -> list of (neuron, dendrite) subscribers.
844
924
  * Built once at construction time.
845
925
  */
846
- this.subIndex = /* @__PURE__ */ new Map();
926
+ this.subscribersByCollateralIndex = [];
927
+ this.allSubscribersByCollateralIndex = [];
928
+ this.modalityCollateralIndices = [];
929
+ this.subscriberTablesByModalityIndex = [];
847
930
  this.parentNeuronByCollateral = /* @__PURE__ */ new Map();
848
931
  this.buildIndexes();
849
932
  }
850
933
  get stronglyConnectedComponents() {
851
- this.ensureSCC();
934
+ this.buildSccIndex();
852
935
  return this._stronglyConnectedComponents;
853
936
  }
854
937
  /** Build the SCC analysis the first time anything needs it. */
855
- ensureSCC() {
938
+ buildSccIndex() {
856
939
  if (this.sccBuilt) return;
857
940
  this.sccBuilt = true;
858
941
  this.buildSCC();
@@ -862,23 +945,21 @@ var CNSNetwork = class {
862
945
  */
863
946
  buildNeuronGraph() {
864
947
  const graph = /* @__PURE__ */ new Map();
865
- for (const neuron2 of this.neurons) {
866
- graph.set(neuron2, /* @__PURE__ */ new Set());
948
+ for (const neuron of this.neurons) {
949
+ graph.set(neuron, /* @__PURE__ */ new Set());
867
950
  }
868
- for (const neuron2 of this.neurons) {
951
+ for (const neuron of this.neurons) {
869
952
  const reachableNeurons = /* @__PURE__ */ new Set();
870
- for (const axonKey in neuron2.axon) {
871
- const collateral2 = neuron2.axon[axonKey];
872
- const subscribers = this.subIndex.get(
873
- collateral2
874
- );
953
+ for (const axonKey in neuron.axon) {
954
+ const collateral = neuron.axon[axonKey];
955
+ const subscribers = this.allSubscribersByCollateralIndex[collateral.index];
875
956
  if (subscribers) {
876
957
  for (const { neuron: targetNeuron } of subscribers) {
877
958
  reachableNeurons.add(targetNeuron);
878
959
  }
879
960
  }
880
961
  }
881
- graph.set(neuron2, reachableNeurons);
962
+ graph.set(neuron, reachableNeurons);
882
963
  }
883
964
  return graph;
884
965
  }
@@ -893,41 +974,41 @@ var CNSNetwork = class {
893
974
  const stack = [];
894
975
  const components = [];
895
976
  let currentIndex = 0;
896
- const strongConnect = (neuron2) => {
897
- index.set(neuron2, currentIndex);
898
- lowlink.set(neuron2, currentIndex);
977
+ const strongConnect = (neuron) => {
978
+ index.set(neuron, currentIndex);
979
+ lowlink.set(neuron, currentIndex);
899
980
  currentIndex++;
900
- stack.push(neuron2);
901
- onStack.add(neuron2);
902
- const neighbors = graph.get(neuron2) || /* @__PURE__ */ new Set();
981
+ stack.push(neuron);
982
+ onStack.add(neuron);
983
+ const neighbors = graph.get(neuron) || /* @__PURE__ */ new Set();
903
984
  for (const neighbor of Array.from(neighbors)) {
904
985
  if (!index.has(neighbor)) {
905
986
  strongConnect(neighbor);
906
987
  lowlink.set(
907
- neuron2,
908
- Math.min(lowlink.get(neuron2), lowlink.get(neighbor))
988
+ neuron,
989
+ Math.min(lowlink.get(neuron), lowlink.get(neighbor))
909
990
  );
910
991
  } else if (onStack.has(neighbor)) {
911
992
  lowlink.set(
912
- neuron2,
913
- Math.min(lowlink.get(neuron2), index.get(neighbor))
993
+ neuron,
994
+ Math.min(lowlink.get(neuron), index.get(neighbor))
914
995
  );
915
996
  }
916
997
  }
917
- if (lowlink.get(neuron2) === index.get(neuron2)) {
998
+ if (lowlink.get(neuron) === index.get(neuron)) {
918
999
  const component = /* @__PURE__ */ new Set();
919
1000
  let w;
920
1001
  do {
921
1002
  w = stack.pop();
922
1003
  onStack.delete(w);
923
1004
  component.add(w);
924
- } while (w !== neuron2);
1005
+ } while (w !== neuron);
925
1006
  components.push(component);
926
1007
  }
927
1008
  };
928
- for (const neuron2 of this.neurons) {
929
- if (!index.has(neuron2)) {
930
- strongConnect(neuron2);
1009
+ for (const neuron of this.neurons) {
1010
+ if (!index.has(neuron)) {
1011
+ strongConnect(neuron);
931
1012
  }
932
1013
  }
933
1014
  this._stronglyConnectedComponents = components;
@@ -949,8 +1030,8 @@ var CNSNetwork = class {
949
1030
  }
950
1031
  for (let i = 0; i < this._stronglyConnectedComponents.length; i++) {
951
1032
  const scc = this._stronglyConnectedComponents[i];
952
- for (const neuron2 of Array.from(scc)) {
953
- const neighbors = neuronGraph.get(neuron2) || /* @__PURE__ */ new Set();
1033
+ for (const neuron of Array.from(scc)) {
1034
+ const neighbors = neuronGraph.get(neuron) || /* @__PURE__ */ new Set();
954
1035
  for (const neighbor of Array.from(neighbors)) {
955
1036
  const neighborSccIndex = this.neuronToSCC.get(neighbor);
956
1037
  if (neighborSccIndex !== void 0 && neighborSccIndex !== i) {
@@ -1003,26 +1084,26 @@ var CNSNetwork = class {
1003
1084
  * Returns true if the neuron is in a strongly connected component with more than one member,
1004
1085
  * or if it's in a single-member SCC that can reach itself.
1005
1086
  */
1006
- getSCCSetByNeuron(neuron2) {
1007
- this.ensureSCC();
1008
- const sccIndex = this.neuronToSCC.get(neuron2);
1087
+ getSCCSetByNeuron(neuron) {
1088
+ this.buildSccIndex();
1089
+ const sccIndex = this.neuronToSCC.get(neuron);
1009
1090
  if (sccIndex === void 0) return;
1010
1091
  return this._stronglyConnectedComponents[sccIndex];
1011
1092
  }
1012
1093
  /**
1013
1094
  * Get the SCC index for a given neuron
1014
1095
  */
1015
- getSccIndexByNeuron(neuron2) {
1016
- this.ensureSCC();
1017
- return this.neuronToSCC.get(neuron2);
1096
+ getSccIndexByNeuron(neuron) {
1097
+ this.buildSccIndex();
1098
+ return this.neuronToSCC.get(neuron);
1018
1099
  }
1019
1100
  /**
1020
1101
  * Check if a neuron can be guaranteed not to be visited again during the current propagation.
1021
1102
  * This is the core logic for safe context cleanup.
1022
1103
  */
1023
- canNeuronBeGuaranteedDone(neuron2, activeSccCounts) {
1024
- this.ensureSCC();
1025
- const sccIndex = this.neuronToSCC.get(neuron2);
1104
+ canNeuronBeGuaranteedDone(neuron, activeSccCounts) {
1105
+ this.buildSccIndex();
1106
+ const sccIndex = this.neuronToSCC.get(neuron);
1026
1107
  if (sccIndex === void 0) return true;
1027
1108
  if (activeSccCounts.get(sccIndex) && activeSccCounts.get(sccIndex) > 0) {
1028
1109
  return false;
@@ -1036,36 +1117,93 @@ var CNSNetwork = class {
1036
1117
  }
1037
1118
  return true;
1038
1119
  }
1120
+ describeNeuron(neuron) {
1121
+ const axonKeys = Object.keys(neuron.axon);
1122
+ return `neuron #${neuron.index} (${axonKeys.length === 0 ? "no axon" : `axon: ${axonKeys.join(", ")}`})`;
1123
+ }
1124
+ describeCollateral(neuron, collateral) {
1125
+ const axonKey = Object.keys(neuron.axon).find((key) => neuron.axon[key] === collateral);
1126
+ return `"${axonKey}" #${collateral.index}`;
1127
+ }
1039
1128
  buildIndexes() {
1040
- this.subIndex.clear();
1129
+ var _a;
1130
+ this.allSubscribersByCollateralIndex = [];
1131
+ this.modalityCollateralIndices = [];
1132
+ this.subscriberTablesByModalityIndex = [];
1041
1133
  this.parentNeuronByCollateral.clear();
1042
- for (const neuron2 of this.neurons) {
1043
- for (const dendrite of neuron2.dendrites) {
1044
- const key = dendrite.collateral;
1045
- const arr = this.subIndex.get(key) ?? [];
1046
- arr.push({ neuron: neuron2, dendrite });
1047
- this.subIndex.set(
1048
- key,
1049
- arr
1050
- );
1134
+ const listedNeurons = /* @__PURE__ */ new Set();
1135
+ for (const neuron of this.neurons) {
1136
+ if (listedNeurons.has(neuron)) {
1137
+ throw new Error(`${this.describeNeuron(neuron)} is listed twice; every dendrite of it would run twice`);
1051
1138
  }
1052
- Object.values(neuron2.axon).forEach((collateral2) => {
1139
+ listedNeurons.add(neuron);
1140
+ for (const dendrite of neuron.dendrites) {
1141
+ const index = dendrite.collateral.index;
1142
+ const subscribers = (_a = this.allSubscribersByCollateralIndex)[index] ?? (_a[index] = []);
1143
+ subscribers.push({ neuron, dendrite });
1144
+ if (dendrite.modalitySelection !== void 0 && this.modalityCollateralIndices.indexOf(index) < 0) {
1145
+ this.modalityCollateralIndices.push(index);
1146
+ }
1147
+ }
1148
+ Object.values(neuron.axon).forEach((collateral) => {
1149
+ const owner = this.parentNeuronByCollateral.get(collateral);
1150
+ if (owner !== void 0 && owner !== neuron) {
1151
+ throw new Error(
1152
+ `collateral ${this.describeCollateral(neuron, collateral)} is in the axons of ${this.describeNeuron(owner)} and ${this.describeNeuron(neuron)}; a collateral is owned by one neuron`
1153
+ );
1154
+ }
1053
1155
  this.parentNeuronByCollateral.set(
1054
- collateral2,
1055
- neuron2
1156
+ collateral,
1157
+ neuron
1056
1158
  );
1057
1159
  });
1058
1160
  }
1161
+ this.subscribersByCollateralIndex = this.buildSubscriberTable(void 0, void 0);
1162
+ }
1163
+ getSubscriberTable(modality, afferentPath) {
1164
+ var _a, _b;
1165
+ if (modality === void 0) return this.subscribersByCollateralIndex;
1166
+ const subscriberTablesByAfferentPathIndex = (_a = this.subscriberTablesByModalityIndex)[_b = modality.index] ?? (_a[_b] = []);
1167
+ const afferentPathSlot = afferentPath === void 0 ? 0 : afferentPath.index + 1;
1168
+ return subscriberTablesByAfferentPathIndex[afferentPathSlot] ?? (subscriberTablesByAfferentPathIndex[afferentPathSlot] = this.buildSubscriberTable(modality, afferentPath));
1169
+ }
1170
+ buildSubscriberTable(modality, afferentPath) {
1171
+ const subscriberTable = this.allSubscribersByCollateralIndex.slice();
1172
+ const modalityCollateralIndices = this.modalityCollateralIndices;
1173
+ for (let i = 0; i < modalityCollateralIndices.length; i++) {
1174
+ const index = modalityCollateralIndices[i];
1175
+ const resolvedSubscribers = [];
1176
+ const subscribers = this.allSubscribersByCollateralIndex[index];
1177
+ for (let j = 0; j < subscribers.length; j++) {
1178
+ const subscriber = subscribers[j];
1179
+ const modalitySelection = subscriber.dendrite.modalitySelection;
1180
+ if (modalitySelection === void 0) {
1181
+ resolvedSubscribers.push(subscriber);
1182
+ continue;
1183
+ }
1184
+ const handler = cnsResolveModalityHandler(modalitySelection, modality, afferentPath);
1185
+ if (handler === void 0) continue;
1186
+ resolvedSubscribers.push({
1187
+ neuron: subscriber.neuron,
1188
+ dendrite: {
1189
+ collateral: subscriber.dendrite.collateral,
1190
+ response: cnsComposeModalityResponse(handler, modalitySelection.output)
1191
+ }
1192
+ });
1193
+ }
1194
+ subscriberTable[index] = resolvedSubscribers;
1195
+ }
1196
+ return subscriberTable;
1059
1197
  }
1060
- getParentNeuronByCollateral(collateral2) {
1061
- return this.parentNeuronByCollateral.get(collateral2);
1198
+ getParentNeuronByCollateral(collateral) {
1199
+ return this.parentNeuronByCollateral.get(collateral);
1062
1200
  }
1063
1201
  // Computed on demand (rarely used): one dendrite per collateral, last wins —
1064
1202
  // preserving the previous index semantics without building it every construct.
1065
1203
  getDendrites() {
1066
1204
  const byCollateral = /* @__PURE__ */ new Map();
1067
- for (const neuron2 of this.neurons) {
1068
- for (const dendrite of neuron2.dendrites) {
1205
+ for (const neuron of this.neurons) {
1206
+ for (const dendrite of neuron.dendrites) {
1069
1207
  byCollateral.set(
1070
1208
  dendrite.collateral,
1071
1209
  dendrite
@@ -1076,15 +1214,15 @@ var CNSNetwork = class {
1076
1214
  }
1077
1215
  getCollaterals() {
1078
1216
  const collaterals = /* @__PURE__ */ new Set();
1079
- for (const neuron2 of this.neurons) {
1080
- for (const collateral2 of Object.values(neuron2.axon)) {
1081
- collaterals.add(collateral2);
1217
+ for (const neuron of this.neurons) {
1218
+ for (const collateral of Object.values(neuron.axon)) {
1219
+ collaterals.add(collateral);
1082
1220
  }
1083
1221
  }
1084
1222
  return Array.from(collaterals);
1085
1223
  }
1086
- getSubscribers(collateral2) {
1087
- return this.subIndex.get(collateral2) ?? [];
1224
+ getSubscribers(collateral) {
1225
+ return this.subscribersByCollateralIndex[collateral.index] ?? NO_SUBSCRIBERS;
1088
1226
  }
1089
1227
  };
1090
1228
 
@@ -1106,36 +1244,47 @@ var CNS = class {
1106
1244
  * Global batch-boundary listeners applied to every stimulation.
1107
1245
  */
1108
1246
  this.globalDrainListeners = [];
1247
+ this.batchDepth = 0;
1248
+ this.batchedDrains = [];
1109
1249
  this.network = new CNSNetwork(this.neurons);
1110
1250
  this.global = global;
1111
1251
  }
1112
- addResponseListener(listener) {
1113
- this.globalResponseListeners.push(listener);
1252
+ /** Push a listener onto a list; return an idempotent unsubscribe. */
1253
+ addListener(list, listener) {
1254
+ list.push(listener);
1114
1255
  let active = true;
1115
1256
  return () => {
1116
1257
  if (!active) return;
1117
1258
  active = false;
1118
- const idx = this.globalResponseListeners.indexOf(listener);
1119
- if (idx >= 0) this.globalResponseListeners.splice(idx, 1);
1259
+ const idx = list.indexOf(listener);
1260
+ if (idx >= 0) list.splice(idx, 1);
1120
1261
  };
1121
1262
  }
1263
+ addResponseListener(listener) {
1264
+ return this.addListener(this.globalResponseListeners, listener);
1265
+ }
1122
1266
  /**
1123
1267
  * Register a listener called once at the end of every synchronous turn of
1124
- * every stimulation - see {@link TCNSStimulationDrain}. This is how an
1268
+ * every stimulation (inside `batch`, when the batch ends) - see {@link TCNSStimulationDrain}. This is how an
1125
1269
  * integration (a state manager, an in-memory database) installs a single
1126
1270
  * commit point for the whole organism instead of passing `onDrain` per call.
1127
1271
  *
1128
1272
  * Returns an unsubscribe function.
1129
1273
  */
1130
1274
  addDrainListener(listener) {
1131
- this.globalDrainListeners.push(listener);
1132
- let active = true;
1133
- return () => {
1134
- if (!active) return;
1135
- active = false;
1136
- const idx = this.globalDrainListeners.indexOf(listener);
1137
- if (idx >= 0) this.globalDrainListeners.splice(idx, 1);
1138
- };
1275
+ return this.addListener(this.globalDrainListeners, listener);
1276
+ }
1277
+ batch(run) {
1278
+ this.batchDepth++;
1279
+ try {
1280
+ return run();
1281
+ } finally {
1282
+ if (--this.batchDepth === 0) {
1283
+ const batchedDrains = this.batchedDrains;
1284
+ this.batchedDrains = [];
1285
+ for (const [announce, drain] of batchedDrains) announce(drain);
1286
+ }
1287
+ }
1139
1288
  }
1140
1289
  /**
1141
1290
  * Fan a drain out to the local and global listeners, or return undefined when
@@ -1146,7 +1295,7 @@ var CNS = class {
1146
1295
  */
1147
1296
  wrapOnDrain(local) {
1148
1297
  if (this.globalDrainListeners.length === 0 && !local) return void 0;
1149
- return (d) => {
1298
+ const announce = (d) => {
1150
1299
  if (local) {
1151
1300
  try {
1152
1301
  local(d);
@@ -1162,39 +1311,38 @@ var CNS = class {
1162
1311
  }
1163
1312
  }
1164
1313
  };
1314
+ return (d) => {
1315
+ if (this.batchDepth > 0) this.batchedDrains.push([announce, d]);
1316
+ else announce(d);
1317
+ };
1165
1318
  }
1166
1319
  wrapOnResponse(local) {
1167
1320
  if (this.globalResponseListeners.length === 0 && !local) {
1168
1321
  return void 0;
1169
1322
  }
1170
1323
  return (r) => {
1171
- let anyPromise = false;
1172
- const promises = [];
1324
+ let promises;
1173
1325
  if (local) {
1174
1326
  try {
1175
1327
  const res = local(r);
1176
1328
  if (res && typeof res.then === "function") {
1177
- anyPromise = true;
1178
- promises.push(res);
1329
+ (promises ?? (promises = [])).push(res);
1179
1330
  }
1180
1331
  } catch (error) {
1181
- anyPromise = true;
1182
- promises.push(Promise.reject(error));
1332
+ (promises ?? (promises = [])).push(Promise.reject(error));
1183
1333
  }
1184
1334
  }
1185
1335
  for (let i = 0; i < this.globalResponseListeners.length; i++) {
1186
1336
  try {
1187
1337
  const res = this.globalResponseListeners[i](r);
1188
1338
  if (res && typeof res.then === "function") {
1189
- anyPromise = true;
1190
- promises.push(res);
1339
+ (promises ?? (promises = [])).push(res);
1191
1340
  }
1192
1341
  } catch (error) {
1193
- anyPromise = true;
1194
- promises.push(Promise.reject(error));
1342
+ (promises ?? (promises = [])).push(Promise.reject(error));
1195
1343
  }
1196
1344
  }
1197
- if (anyPromise) {
1345
+ if (promises) {
1198
1346
  return Promise.allSettled(promises).then((results) => {
1199
1347
  const rejected = results.find(
1200
1348
  (r2) => r2.status === "rejected"
@@ -1204,15 +1352,18 @@ var CNS = class {
1204
1352
  }
1205
1353
  };
1206
1354
  }
1207
- stimulate(signalOrSignals, options) {
1208
- const wrapped = this.wrapOnResponse(options?.onResponse);
1209
- const stimulation = new CNSStimulation(
1355
+ /** Build a stimulation (wired listeners, unseeded). Shared by stimulate/activate. */
1356
+ createStimulation(options) {
1357
+ return new CNSStimulation(
1210
1358
  this,
1211
1359
  this.instanceNeuronQueue,
1212
1360
  options,
1213
- wrapped,
1361
+ this.wrapOnResponse(options?.onResponse),
1214
1362
  this.wrapOnDrain(options?.onDrain)
1215
1363
  );
1364
+ }
1365
+ stimulate(signalOrSignals, options) {
1366
+ const stimulation = this.createStimulation(options);
1216
1367
  stimulation.responseToSignal(signalOrSignals);
1217
1368
  return stimulation;
1218
1369
  }
@@ -1220,21 +1371,26 @@ var CNS = class {
1220
1371
  * Start a stimulation with activation tasks directly
1221
1372
  */
1222
1373
  activate(tasks, options) {
1223
- const wrapped = this.wrapOnResponse(options?.onResponse);
1224
- const stimulation = new CNSStimulation(
1225
- this,
1226
- this.instanceNeuronQueue,
1227
- options,
1228
- wrapped,
1229
- this.wrapOnDrain(options?.onDrain)
1230
- );
1374
+ const stimulation = this.createStimulation(options);
1231
1375
  stimulation.enqueueTasks(tasks);
1232
1376
  return stimulation;
1233
1377
  }
1234
1378
  };
1235
1379
 
1380
+ // src/createCNS.ts
1381
+ var createCNS = (neuronFactories, dependencies, options) => {
1382
+ if (new Set(neuronFactories).size !== neuronFactories.length) {
1383
+ throw new Error("createCNS: a neuron factory is listed twice; every dendrite of its neuron would run twice");
1384
+ }
1385
+ return new CNS(neuronFactories.map((createNeuron) => createNeuron(dependencies)), options);
1386
+ };
1387
+
1236
1388
  // src/CNSCollateral.ts
1389
+ var nextCollateralIndex = 0;
1237
1390
  var CNSCollateral = class {
1391
+ constructor() {
1392
+ this.index = nextCollateralIndex++;
1393
+ }
1238
1394
  createSignal(payload) {
1239
1395
  return { collateral: this, payload };
1240
1396
  }
@@ -1290,8 +1446,11 @@ var CNSStimulationGate = class {
1290
1446
  };
1291
1447
 
1292
1448
  // src/factory/index.ts
1293
- var collateral = () => new CNSCollateral();
1294
- var neuron = (axon) => {
1449
+ var nextNeuronIndex = 0;
1450
+ var nextAfferentPathIndex = 0;
1451
+ var nextModalityIndex = 0;
1452
+ var cnsCollateral = () => new CNSCollateral();
1453
+ var cnsNeuron = (axon) => {
1295
1454
  const dendrites = [];
1296
1455
  const api = {
1297
1456
  setConcurrency: (n) => {
@@ -1315,15 +1474,16 @@ var neuron = (axon) => {
1315
1474
  }
1316
1475
  return api;
1317
1476
  }),
1477
+ index: nextNeuronIndex++,
1318
1478
  axon,
1319
1479
  concurrency: void 0,
1320
1480
  maxDuration: void 0,
1321
1481
  dendrites,
1322
1482
  dendrite(s) {
1323
1483
  if ("collateral" in s && "response" in s && Array.isArray(s.collateral)) {
1324
- for (const collateral2 of s.collateral) {
1484
+ for (const collateral of s.collateral) {
1325
1485
  dendrites.push({
1326
- collateral: collateral2,
1486
+ collateral,
1327
1487
  response: s.response
1328
1488
  });
1329
1489
  }
@@ -1336,7 +1496,7 @@ var neuron = (axon) => {
1336
1496
  },
1337
1497
  modalityDendrite(s) {
1338
1498
  const {
1339
- collateral: collateral2,
1499
+ collateral,
1340
1500
  modality: singleModality,
1341
1501
  modalities: multipleModalities,
1342
1502
  afferentPaths: singleAfferentPaths,
@@ -1353,89 +1513,56 @@ var neuron = (axon) => {
1353
1513
  const response = (payload, axon2, ctx) => {
1354
1514
  const stimulation = ctx.stimulation;
1355
1515
  const stimOptions = stimulation?.options;
1356
- const runGlobalDefault = () => {
1357
- if (!defaultHandler) {
1358
- throw new Error(
1359
- `modalityDendrite: No handler found for modality and no default handler provided`
1360
- );
1361
- }
1362
- const res = defaultHandler(payload, axon2, ctx);
1363
- if (res && typeof res.then === "function") {
1364
- return res.then(
1365
- (result) => output(result, axon2, ctx)
1366
- );
1367
- }
1368
- return output(res, axon2, ctx);
1369
- };
1370
- if (!stimOptions || !stimOptions.modality) {
1371
- return runGlobalDefault();
1372
- }
1373
- const matchingConfig = modalityConfigs.find(
1374
- (config) => config.modality === stimOptions.modality
1516
+ const handler = cnsResolveModalityHandler(
1517
+ modalitySelection,
1518
+ stimOptions?.modality,
1519
+ stimOptions?.afferentPath
1375
1520
  );
1376
- if (!matchingConfig) {
1377
- return runGlobalDefault();
1378
- }
1379
- const stimAfferentPath = stimOptions.afferentPath;
1380
- const handler = stimAfferentPath && matchingConfig.afferentPaths ? matchingConfig.afferentPaths.get(stimAfferentPath) : void 0;
1381
- const effectiveHandler = handler ?? matchingConfig.default ?? defaultHandler;
1382
- if (!effectiveHandler) {
1521
+ if (handler === void 0) {
1383
1522
  throw new Error(
1384
1523
  `modalityDendrite: No handler found for afferent path in modality and no default handler provided`
1385
1524
  );
1386
1525
  }
1387
- const maybeResult = effectiveHandler(payload, axon2, ctx);
1388
- if (maybeResult && typeof maybeResult.then === "function") {
1389
- return maybeResult.then(
1390
- (result) => output(result, axon2, ctx)
1391
- );
1392
- }
1393
- try {
1394
- return output(maybeResult, axon2, ctx);
1395
- } catch (error) {
1396
- if (error instanceof Error && error.message.includes(
1397
- "Cannot read properties of undefined"
1398
- )) {
1399
- const axonKeys = axon2 && typeof axon2 === "object" ? Object.keys(axon2) : "not an object";
1400
- throw new Error(
1401
- `modalityDendrite: axon.output is undefined. Axon type: ${typeof axon2}, Axon keys: ${Array.isArray(axonKeys) ? axonKeys.join(", ") : axonKeys}. Make sure the neuron was created with the correct axon structure. Original error: ${error.message}`
1402
- );
1403
- }
1404
- throw error;
1405
- }
1526
+ return cnsComposeModalityResponse(handler, output)(payload, axon2, ctx);
1527
+ };
1528
+ const modalitySelection = {
1529
+ modalityConfigs,
1530
+ defaultHandler,
1531
+ output
1406
1532
  };
1407
1533
  dendrites.push({
1408
- collateral: collateral2,
1409
- response
1534
+ collateral,
1535
+ response,
1536
+ modalitySelection
1410
1537
  });
1411
1538
  return api;
1412
1539
  }
1413
1540
  };
1414
1541
  return api;
1415
1542
  };
1416
- var neuronFactory = () => {
1543
+ var cnsNeuronFactory = () => {
1417
1544
  const factory = {
1418
- withCtx: () => factory,
1419
1545
  withGlobal: () => factory,
1420
- neuron: (axon) => neuron(axon)
1546
+ neuron: (axon) => cnsNeuron(axon)
1421
1547
  };
1422
1548
  return factory;
1423
1549
  };
1424
- var withCtx = () => neuronFactory();
1425
- var withGlobal = () => neuronFactory();
1426
- var afferentPath = (parentAfferentPath) => {
1550
+ var withCNSGlobal = () => cnsNeuronFactory();
1551
+ var cnsAfferentPath = (parentAfferentPath) => {
1427
1552
  return {
1553
+ index: nextAfferentPathIndex++,
1428
1554
  ...parentAfferentPath !== void 0 && {
1429
1555
  parentAfferentPath
1430
1556
  }
1431
1557
  };
1432
1558
  };
1433
- var modality = (afferentPaths) => {
1559
+ var cnsModality = (afferentPaths) => {
1434
1560
  return {
1561
+ index: nextModalityIndex++,
1435
1562
  afferentPaths
1436
1563
  };
1437
1564
  };
1438
1565
 
1439
- export { CNS, CNSCollateral, CNSStimulationContextStore, CNSStimulationGate, afferentPath, collateral, modality, neuron, neuronFactory, withCtx, withGlobal };
1566
+ export { CNS, CNSCollateral, CNSStimulationGate, cnsAfferentPath, cnsCollateral, cnsModality, cnsNeuron, cnsNeuronFactory, createCNS, withCNSGlobal };
1440
1567
  //# sourceMappingURL=index.js.map
1441
1568
  //# sourceMappingURL=index.js.map