creating-intelligence 0.1.0a7__py3-none-win_amd64.whl

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.
@@ -0,0 +1,1945 @@
1
+ """
2
+ Copyright (c) 2026 Peter Overmann
3
+
4
+ SPDX-License-Identifier: MIT
5
+
6
+ This file is part of the "Creating Intelligence" project. It is licensed
7
+ under the MIT License. You may obtain a copy of the License in the LICENSE
8
+ file in the root directory of this repository.
9
+
10
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
11
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
12
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
13
+ """
14
+
15
+
16
+ """
17
+
18
+ This package employs a standardized plugin mechanism for circuit components,
19
+ encoders (preprocessing), decoders (postprocessing), circuit embedding mechanisms,
20
+ and auto-associative update rules.
21
+
22
+ Plugins are defined via factory functions that take a dictionary as input
23
+ and dispatch a dictionary, using closures encapsulate stateful evaluation functions.
24
+
25
+ The function names match the corresponding value strings in the JSON dataflow description,
26
+ for example {"component": "auto", "plugin": "replacement", ...}
27
+
28
+ Users can patch custom plugins into the circuits namespace via:
29
+
30
+ import creating_intelligence
31
+ from myplugins import mycomponent
32
+ circuits.mycomponent = mycomponent
33
+
34
+
35
+ """
36
+
37
+ # Formatting: autopep8 --ignore=E251 -a -a -i circuits.py
38
+
39
+ import itertools
40
+ import math
41
+ import sys
42
+ import os
43
+ import json
44
+ from collections import Counter
45
+ import numpy as np
46
+ import scipy.sparse as sp
47
+ import networkx as nx
48
+ from creating_intelligence import Memory
49
+
50
+
51
+ # Global RNG
52
+ rng = np.random.default_rng()
53
+
54
+
55
+ def multiset(u):
56
+ """
57
+ Summates excitatory and inhibitory signals.
58
+ Outputs a net multiset. No element will exist as both positive and negative.
59
+ """
60
+ if not isinstance(u, list):
61
+ u = [u]
62
+
63
+ # Flatten nested structures if necessary
64
+ flat_u = []
65
+
66
+ def _flatten(items):
67
+ for item in items:
68
+ if isinstance(item, list):
69
+ _flatten(item)
70
+ else:
71
+ flat_u.append(item)
72
+ _flatten(u)
73
+
74
+ tally = Counter()
75
+ pos = [x for x in flat_u if x > 0]
76
+ neg = [-x for x in flat_u if x < 0]
77
+
78
+ tally.update(pos)
79
+ tally.subtract(neg)
80
+
81
+ result = []
82
+ for k, v in tally.items():
83
+ if v > 0:
84
+ result.extend([k] * v)
85
+ elif v < 0:
86
+ result.extend([-k] * abs(v))
87
+
88
+ return sorted(result)
89
+
90
+
91
+ def resolve_normal(x):
92
+ """Applies graded inhibition, drops negatives, squashes excitatory survivors."""
93
+ ae = [i for i in x if i > 0]
94
+ ai = [-i for i in x if i < 0]
95
+
96
+ if not ai: # Fast path for pure boolean
97
+ return sorted(list(set(ae)))
98
+
99
+ counts_e = Counter(ae)
100
+ counts_i = Counter(ai)
101
+
102
+ survivors = [k for k, v in counts_e.items() if v > counts_i.get(k, 0)]
103
+ return sorted(survivors)
104
+
105
+
106
+ def resolve_graded(x):
107
+ """Applies graded inhibition, drops negatives, keeps excitatory survivors."""
108
+ ae = [i for i in x if i > 0]
109
+ ai = [-i for i in x if i < 0]
110
+
111
+ if not ai: # Fast path for pure multisets
112
+ return sorted(ae)
113
+
114
+ counts_e = Counter(ae)
115
+ counts_i = Counter(ai)
116
+
117
+ result = []
118
+ for k, v in counts_e.items():
119
+ surviving_count = max(0, v - counts_i.get(k, 0))
120
+ result.extend([k] * surviving_count)
121
+
122
+ return sorted(result)
123
+
124
+
125
+ def multiset_block_join(blocks, dims):
126
+ """Transparently joins multisets. Preserves duplicates and negative signs."""
127
+ offsets = list(itertools.accumulate([0] + dims[:-1]))
128
+
129
+ result = []
130
+ for sublist, offset in zip(blocks, offsets):
131
+ for val in sublist:
132
+ sign = 1 if val > 0 else -1 if val < 0 else 0
133
+ shifted = sign * (abs(val) + offset)
134
+ result.append(shifted)
135
+
136
+ return sorted(result)
137
+
138
+
139
+ def multiset_block_split(A, dims):
140
+ """Transparently splits a joined multiset back into its original blocks."""
141
+ limits = list(itertools.accumulate([0] + dims))
142
+ bounds = list(zip(limits[:-1], limits[1:]))
143
+
144
+ split_blocks = []
145
+ for b_start, b_end in bounds:
146
+ chunk = []
147
+ for val in A:
148
+ if b_start < abs(val) <= b_end:
149
+ sign = 1 if val > 0 else -1 if val < 0 else 0
150
+ restored = sign * (abs(val) - b_start)
151
+ chunk.append(restored)
152
+ split_blocks.append(chunk)
153
+
154
+ return split_blocks
155
+
156
+
157
+ def multiset_block_join_normal(blocks, dims):
158
+ """
159
+ Join blocks with dimensions into a single list.
160
+ Applies graded inhibition and drops inhibitory elements. Preserves multisets.
161
+ """
162
+ offsets = list(itertools.accumulate([0] + dims[:-1]))
163
+
164
+ ae = []
165
+ ai = []
166
+ for sublist, offset in zip(blocks, offsets):
167
+ for val in sublist:
168
+ shifted_abs = abs(val) + offset
169
+ if val > 0:
170
+ ae.append(shifted_abs)
171
+ elif val < 0:
172
+ ai.append(shifted_abs)
173
+
174
+ counts_e = Counter(ae)
175
+ counts_i = Counter(ai)
176
+
177
+ net_excitatory = []
178
+ for k, v in counts_e.items():
179
+ surviving_count = max(0, v - counts_i.get(k, 0))
180
+ net_excitatory.extend([k] * surviving_count)
181
+
182
+ return sorted(net_excitatory)
183
+
184
+
185
+ def resolve_block_join_normal(blocks, dims):
186
+ """
187
+ Applies graded subtraction, drops negatives, and squashes the survivors.
188
+ Outputs a flat, Boolean excitatory set.
189
+ """
190
+ offsets = list(itertools.accumulate([0] + dims[:-1]))
191
+
192
+ ae = []
193
+ ai = []
194
+ for sublist, offset in zip(blocks, offsets):
195
+ for val in sublist:
196
+ shifted_abs = abs(val) + offset
197
+ if val > 0:
198
+ ae.append(shifted_abs)
199
+ elif val < 0:
200
+ ai.append(shifted_abs)
201
+
202
+ counts_e = Counter(ae)
203
+ counts_i = Counter(ai)
204
+
205
+ survivors = [k for k, v in counts_e.items() if v > counts_i.get(k, 0)]
206
+ return sorted(survivors)
207
+
208
+
209
+ def memory_capacity(A_params, B_params):
210
+ NA, PA = A_params
211
+ NB, PB = B_params
212
+ if NA == NB and PA == PB:
213
+ return round((math.log(2.0) * NB * (NA - 1) * (NA - 2)) /
214
+ (PB * (PA - 1) * (PA - 2)))
215
+ else:
216
+ return round((math.log(2.0) * NB * NA * (NA - 1)) /
217
+ (PB * PA * (PA - 1)))
218
+
219
+
220
+ def matching_threshold(A_params, B_params):
221
+ NA, PA = A_params
222
+ NB, PB = B_params
223
+
224
+ if PA <= 2 or PB <= 2:
225
+ return 0
226
+
227
+ cap = memory_capacity(A_params, B_params)
228
+ T = 1
229
+
230
+ while T < PA and (
231
+ cap**2 *
232
+ math.comb(
233
+ PA,
234
+ T) *
235
+ math.comb(
236
+ NA -
237
+ PA,
238
+ PA -
239
+ T) /
240
+ math.comb(
241
+ NA,
242
+ PA)) >= 1:
243
+ T += 1
244
+
245
+ return T
246
+
247
+
248
+ # Encoders and decoders
249
+
250
+ # Shared state structure keyed by (N, P) tuple
251
+ # By design, circuits with identical hyperparameters (N, P) share
252
+ # encoder/decoder instances.
253
+
254
+ circuit_codec_lexicon = {}
255
+
256
+
257
+ def codec(config):
258
+ """General-purpose SHR encoder/decoder for symbolic expressions."""
259
+
260
+ # Extract hyperparameters from config
261
+ hyperparameters = config.get("hyperparameters", [0, 0])
262
+ n, p = hyperparameters[0], hyperparameters[1]
263
+ lex_key = (n, p)
264
+
265
+ # Initialize global hash table if missing for these dimensions
266
+ if lex_key not in circuit_codec_lexicon:
267
+ circuit_codec_lexicon[lex_key] = {None: []}
268
+
269
+ lex = circuit_codec_lexicon[lex_key]
270
+
271
+ # Use auto-associative pattern matching threshold
272
+ T = matching_threshold((n, p), (n, p))
273
+
274
+ def f(expr):
275
+ # Encoding logic
276
+ if config.get("component") == "input":
277
+ if isinstance(expr, list):
278
+ # Bundle multiple tokens
279
+ bundled = []
280
+ for e in expr:
281
+ res = f(e)
282
+ if res:
283
+ bundled.extend(res)
284
+ return sorted(list(set(bundled)))
285
+
286
+ if expr not in lex:
287
+ # 1-based indexing for native arrays
288
+ sampled = rng.choice(range(1, n + 1), size=p, replace=False)
289
+ lex[expr] = sorted(sampled.tolist())
290
+
291
+ return lex[expr]
292
+
293
+ # Decoding logic
294
+ if not expr:
295
+ return None
296
+
297
+ overlaps = {k: len(set(v).intersection(expr))
298
+ for k, v in lex.items() if k is not None}
299
+
300
+ result = [k for k, overlap in overlaps.items() if overlap >= T]
301
+
302
+ if len(result) == 0:
303
+ return None
304
+ elif len(result) == 1:
305
+ return result[0]
306
+ else:
307
+ return sorted(result)
308
+
309
+ def clear():
310
+ lex.clear()
311
+ lex[None] = []
312
+
313
+ return {
314
+ "label": "SYM",
315
+ "function": f,
316
+ "clear": clear
317
+ }
318
+
319
+
320
+ # Shared state structures keyed by (n, p) tuple
321
+ circuit_category_lexicon = {}
322
+
323
+
324
+ circuit_category_lexicon = {}
325
+
326
+ def category(config):
327
+ hyperparameters = config.get("hyperparameters", [0, 0])
328
+ n, p = hyperparameters[0], hyperparameters[1]
329
+
330
+ K = config.get("categories", n // p if p > 0 else 0)
331
+ if K * p > n:
332
+ raise ValueError(
333
+ f"Encoder requires dimension {K * p} or greater.")
334
+
335
+ partitionsize = n // K if K > 0 else 0
336
+
337
+ lex_key = (n, p, K)
338
+
339
+ if lex_key not in circuit_category_lexicon:
340
+ circuit_category_lexicon[lex_key] = {None: []}
341
+
342
+ lex = circuit_category_lexicon[lex_key]
343
+ T = matching_threshold((n, p), (n, p))
344
+
345
+ def f(expr):
346
+ if config.get("component") == "input":
347
+ if not isinstance(expr, int) or expr < 0 or expr >= K:
348
+ return []
349
+
350
+ if expr not in lex:
351
+ # 1-based indexing for native arrays
352
+ start = partitionsize * expr + 1
353
+ end = start + partitionsize
354
+ lex[expr] = list(range(start, end))
355
+
356
+ pool = lex[expr]
357
+ sample_size = min(p, len(pool))
358
+ return sorted(
359
+ rng.choice(
360
+ pool,
361
+ size=sample_size,
362
+ replace=False).tolist())
363
+
364
+ if not expr:
365
+ return None
366
+
367
+ overlaps = {k: len(set(v).intersection(expr))
368
+ for k, v in lex.items() if k is not None}
369
+
370
+ result = [k for k, overlap in overlaps.items() if overlap >= T]
371
+
372
+ if len(result) == 0:
373
+ return None
374
+ elif len(result) == 1:
375
+ return result[0]
376
+ else:
377
+ return sorted(result)
378
+
379
+ def clear():
380
+ lex.clear()
381
+ lex[None] = []
382
+
383
+ return {
384
+ "label": "CAT",
385
+ "function": f,
386
+ "clear": clear
387
+ }
388
+
389
+
390
+ circuit_binning_lexicon = {}
391
+
392
+
393
+ def binning(config):
394
+ hyperparameters = config.get("hyperparameters", [0, 0])
395
+ n, p = hyperparameters[0], hyperparameters[1]
396
+ lex_key = (n, p)
397
+
398
+ if lex_key not in circuit_binning_lexicon:
399
+ circuit_binning_lexicon[lex_key] = {None: []}
400
+
401
+ lex = circuit_binning_lexicon[lex_key]
402
+ T = matching_threshold((n, p), (n, p))
403
+
404
+ if config.get("component") == "input":
405
+ raise ValueError("binning cannot be used as an encoder.")
406
+
407
+ def f(expr):
408
+ if not expr:
409
+ return 0
410
+
411
+ overlaps = {k: len(set(v).intersection(expr))
412
+ for k, v in lex.items() if k is not None}
413
+
414
+ bins = [k for k, overlap in overlaps.items() if overlap >= T]
415
+
416
+ if len(bins) == 0:
417
+ new_bin = len(lex)
418
+ lex[new_bin] = expr
419
+ return new_bin
420
+ elif len(bins) == 1:
421
+ return bins[0]
422
+ else:
423
+ return sorted(bins)
424
+
425
+ def clear():
426
+ lex.clear()
427
+ lex[None] = []
428
+
429
+ return {
430
+ "label": "BIN",
431
+ "function": f,
432
+ "clear": clear
433
+ }
434
+
435
+
436
+ circuit_vectorencoder_matrices = {}
437
+
438
+
439
+ def vectorencoder(config):
440
+ hyperparameters = config.get("hyperparameters", [0, 0])
441
+ n, p = hyperparameters[0], hyperparameters[1]
442
+ key = (n, p)
443
+
444
+ if key not in circuit_vectorencoder_matrices:
445
+ circuit_vectorencoder_matrices[key] = {}
446
+
447
+ R = circuit_vectorencoder_matrices[key]
448
+
449
+ if config.get("component") == "output":
450
+ raise ValueError("vectorencoder cannot be used as a decoder.")
451
+
452
+ def f(lst):
453
+ if not isinstance(lst, list):
454
+ lst = list(lst)
455
+ d = len(lst)
456
+ if p <= 0 or d == 0:
457
+ return []
458
+
459
+ sparsity = config.get("sparsity", float(1.0 / math.sqrt(d)))
460
+
461
+ if d not in R:
462
+ num_ones = round(n * d * sparsity)
463
+ flat_indices = rng.choice(n * d, size=num_ones, replace=False)
464
+ rows = flat_indices // d
465
+ cols = flat_indices % d
466
+ vals = np.ones(num_ones)
467
+
468
+ R[d] = sp.csr_matrix((vals, (rows, cols)), shape=(n, d))
469
+
470
+ matrix = R[d]
471
+ vec = matrix.dot(np.array(lst))
472
+
473
+ min_val, max_val = np.min(vec), np.max(vec)
474
+ span = max_val - min_val
475
+ scale = max(abs(min_val), abs(max_val))
476
+ tolerance = 1e-10
477
+
478
+ if scale == 0 or span <= tolerance * scale:
479
+ return []
480
+
481
+ k = min(p, len(vec))
482
+ top_k_indices = np.argsort(vec)[-k:]
483
+ return sorted((top_k_indices + 1).tolist())
484
+
485
+ def clear():
486
+ R.clear()
487
+
488
+ return {
489
+ "label": "VEC",
490
+ "function": f,
491
+ "clear": clear
492
+ }
493
+
494
+
495
+ circuit_flyhash_matrices = {}
496
+ circuit_flyhash_resting = {}
497
+
498
+
499
+ def flyhash(config):
500
+ hyperparameters = config.get("hyperparameters", [0, 0])
501
+ n, p = hyperparameters[0], hyperparameters[1]
502
+ key = (n, p)
503
+
504
+ if key not in circuit_flyhash_matrices:
505
+ circuit_flyhash_matrices[key] = {}
506
+ circuit_flyhash_resting[key] = {}
507
+
508
+ R = circuit_flyhash_matrices[key]
509
+ restingpotential = circuit_flyhash_resting[key]
510
+
511
+ sparsity = config.get("sparsity", 0.1)
512
+
513
+ if config.get("component") == "output":
514
+ raise ValueError("flyhash cannot be used as a decoder.")
515
+
516
+ def f(lst):
517
+ if not isinstance(lst, list):
518
+ lst = list(lst)
519
+ d = len(lst)
520
+ if p <= 0 or d == 0:
521
+ return []
522
+
523
+ arr = np.array(lst)
524
+ centeredlist = arr - np.mean(arr)
525
+ tolerance = 1e-10
526
+
527
+ if d not in R:
528
+ c = max(1, round(d * sparsity))
529
+
530
+ rows = np.repeat(np.arange(n), c)
531
+ cols = np.concatenate(
532
+ [rng.choice(d, size=c, replace=False) for _ in range(n)])
533
+ vals = np.ones(n * c)
534
+
535
+ R[d] = sp.csr_matrix((vals, (rows, cols)), shape=(n, d))
536
+ restingpotential[d] = rng.uniform(0, tolerance * 0.01, size=n)
537
+
538
+ vec = R[d].dot(centeredlist) + restingpotential[d]
539
+
540
+ min_val, max_val = np.min(vec), np.max(vec)
541
+ span = max_val - min_val
542
+ scale = max(abs(min_val), abs(max_val))
543
+
544
+ if scale == 0 or span <= tolerance * scale:
545
+ return []
546
+
547
+ k = min(p, len(vec))
548
+ top_k_indices = np.argsort(vec)[-k:]
549
+ return sorted((top_k_indices + 1).tolist())
550
+
551
+ def clear():
552
+ R.clear()
553
+ restingpotential.clear()
554
+
555
+ return {
556
+ "label": "FLY",
557
+ "function": f,
558
+ "clear": clear
559
+ }
560
+
561
+
562
+ # Circuit Components
563
+
564
+
565
+ def input(config):
566
+ """
567
+ At the beginning of each cycle, external input is pushed
568
+ into the input's send slots. Input nodes have no "receive" slots.
569
+ The callback function serves as an optional encoder/preprocessing plugin.
570
+ """
571
+ pluginconfig = config.copy()
572
+ if "send_blocks" in config and config["send_blocks"]:
573
+ pluginconfig["hyperparameters"] = config["send_blocks"][0]
574
+
575
+ plugin_factory = config.get("plugin")
576
+
577
+ if callable(plugin_factory):
578
+ plugin = plugin_factory(pluginconfig)
579
+ else:
580
+ # Default Identity function
581
+ plugin = {"function": lambda x: x}
582
+
583
+ return plugin | {"size": 10, "checks": ["input", "oneout"]}
584
+
585
+
586
+ def output(config):
587
+ """
588
+ At the end of each cycle, the output's input edges are read out.
589
+ Output nodes have no "send" slots.
590
+ The callback function serves as an optional decoder/postprocessing plugin.
591
+ """
592
+ pluginconfig = config.copy()
593
+ if "receive_blocks" in config and config["receive_blocks"]:
594
+ pluginconfig["hyperparameters"] = config["receive_blocks"][0]
595
+
596
+ plugin_factory = config.get("plugin")
597
+
598
+ if callable(plugin_factory):
599
+ plugin = plugin_factory(pluginconfig)
600
+ else:
601
+ # Default Identity function
602
+ plugin = {"function": lambda x: x}
603
+
604
+ return plugin | {"size": 10, "checks": ["output", "oneinp"]}
605
+
606
+
607
+
608
+
609
+ def delay(config):
610
+ plugin_factory = config.get("plugin", replacement)
611
+
612
+ if isinstance(plugin_factory, str):
613
+ import sys
614
+ plugin_factory = getattr(
615
+ sys.modules[__name__],
616
+ plugin_factory,
617
+ replacement)
618
+
619
+ plugin = plugin_factory(config)
620
+
621
+ if plugin_factory is replacement:
622
+ plugin.pop("label", None)
623
+
624
+ dims1 = [b[0] for b in config.get("receive_blocks", [])]
625
+ dims2 = [b[0] for b in config.get("send_blocks", [])]
626
+ pop = sum(b[1] for b in config.get("receive_blocks", []))
627
+
628
+ threshold_factor = config.get("threshold", 0.0)
629
+ absthreshold = round(threshold_factor * pop)
630
+
631
+ rate_limit_factor = config.get("rate_limit", float('inf'))
632
+ absratelimit = round(
633
+ rate_limit_factor *
634
+ pop) if rate_limit_factor != float('inf') else float('inf')
635
+
636
+ decay = config.get("decay", 0.0)
637
+ capacity_factor = config.get("capacity", 1.0)
638
+ abscapacity = round(capacity_factor * pop)
639
+ decimation = config.get("decimate", 1.0)
640
+ latch = config.get("latch", False)
641
+
642
+ Xstate = []
643
+
644
+ def f(*blocks):
645
+ nonlocal Xstate
646
+ X = multiset_block_join(list(blocks), dims1)
647
+
648
+ # Enforce minimum population
649
+ if len(X) < absthreshold:
650
+ X = []
651
+
652
+ if len(X) > absratelimit:
653
+ X = sorted(
654
+ rng.choice(
655
+ X,
656
+ size=absratelimit,
657
+ replace=False).tolist())
658
+
659
+ if decay > 0.0:
660
+ keep_size = math.floor((1.0 - decay) * len(Xstate))
661
+ Xstate = sorted(
662
+ rng.choice(
663
+ Xstate,
664
+ size=keep_size,
665
+ replace=False).tolist())
666
+
667
+ if len(Xstate) > abscapacity:
668
+ Xstate = sorted(
669
+ rng.choice(
670
+ Xstate,
671
+ size=abscapacity,
672
+ replace=False).tolist())
673
+
674
+ # Temporal integration via update rules
675
+ if not (latch and len(X) == 0):
676
+ Xstate = plugin["updaterule"](X, Xstate)
677
+
678
+ # Proportional multiset subsampling applied to output
679
+ Xdec = Xstate
680
+ if decimation < 1.0:
681
+ sample_size = math.floor(decimation * len(Xdec))
682
+ Xdec = sorted(
683
+ rng.choice(
684
+ Xdec,
685
+ size=sample_size,
686
+ replace=False).tolist())
687
+
688
+ return tuple(multiset_block_split(Xdec, dims2))
689
+
690
+ result = dict(plugin)
691
+
692
+ result.update({
693
+ "function": f,
694
+ "checks": ["arginp", "argout", "totaldim"],
695
+ "fill": 13
696
+ })
697
+ return result
698
+
699
+
700
+ # Update rules for auto-associative memory components ("auto")
701
+ # and temporal integration ("delay").
702
+
703
+ def replacement(config):
704
+ return {"updaterule": lambda y, x: y, "label": "▼", "size": 18}
705
+
706
+
707
+ def residual(config):
708
+ return {"updaterule": lambda y, x: resolve_graded(
709
+ multiset([x, [-i for i in y]])), "label": "▲", "size": 18}
710
+
711
+
712
+ def complement(config):
713
+ return {"updaterule": lambda y, x: resolve_graded(
714
+ multiset([y, [-i for i in x]])), "label": "▽", "size": 20}
715
+
716
+
717
+ def difference(config):
718
+ return {"updaterule": lambda y, x: sorted(
719
+ [abs(i) for i in multiset([y, [-i for i in x]])]), "label": "△", "size": 20}
720
+
721
+
722
+ def augmentation(config):
723
+ return {"updaterule": lambda y, x: multiset(
724
+ [y, x]), "label": "∪", "size": 14}
725
+
726
+
727
+ def coincidence(config):
728
+ def multiset_intersection(y, x):
729
+ counts_y = Counter(y)
730
+ counts_x = Counter(x)
731
+ res = []
732
+ for k, v in counts_y.items():
733
+ res.extend([k] * min(v, counts_x.get(k, 0)))
734
+ return sorted(res)
735
+ return {"updaterule": multiset_intersection, "label": "∩", "size": 14}
736
+
737
+
738
+ def permutation(config):
739
+ dims = sum(b[0] for b in config.get("receive_blocks", []))
740
+ dim = dims if dims else 0
741
+ perm = rng.choice(
742
+ range(1, dim + 1),
743
+ size = dim,
744
+ replace=False).tolist() if dim > 0 else []
745
+
746
+ def updaterule(y, x):
747
+ mapped_x = sorted([(1 if i > 0 else -1 if i < 0 else 0)
748
+ * perm[abs(i) - 1] for i in x if i != 0])
749
+ return multiset([y, mapped_x])
750
+
751
+ return {"updaterule": updaterule, "label": "π", "size": 16}
752
+
753
+
754
+ """
755
+ Auto-associative memory component.
756
+
757
+ Note: There is a wide range of possible learning, subsampling, retrieval
758
+ and update rules for auto-associative memory. This prototype captures the
759
+ generic cases -- modify as needed.
760
+ """
761
+
762
+
763
+ def auto(config):
764
+ plugin_factory = config.get("plugin", replacement)
765
+
766
+ if isinstance(plugin_factory, str):
767
+ # Resolve string name to function dynamically
768
+ import sys
769
+ plugin_factory = getattr(
770
+ sys.modules[__name__],
771
+ plugin_factory,
772
+ replacement)
773
+
774
+ plugin = plugin_factory(config)
775
+
776
+ receive_blocks = config.get("receive_blocks", [])
777
+ dims = [b[0] for b in receive_blocks]
778
+
779
+ # params = Plus @@ config["receive_blocks"] (Sums Ns and Ps respectively)
780
+ params = [sum(b[0] for b in receive_blocks), sum(b[1]
781
+ for b in receive_blocks)]
782
+ pop = params[1] if len(params) > 1 else 0
783
+
784
+ rate_limit_factor = config.get("rate_limit", float('inf'))
785
+ absratelimit = round(
786
+ rate_limit_factor *
787
+ pop) if rate_limit_factor != float('inf') else float('inf')
788
+
789
+ decimation = config.get("decimate", 1.0)
790
+
791
+ # Learning thresholds
792
+ min_val = pop
793
+ max_val = pop
794
+ learn_val = config.get("learn", False)
795
+
796
+ if isinstance(learn_val, (int, float)) and not isinstance(learn_val, bool):
797
+ min_val = max_val = round(learn_val * pop)
798
+ elif isinstance(learn_val, (list, tuple)) and len(learn_val) == 2:
799
+ min_val = round(learn_val[0] * pop)
800
+ max_val = round(learn_val[1] * pop)
801
+
802
+ # Memory with identical input and output parameters
803
+ m_config = dict(config)
804
+ m_config.update({
805
+ "A_parameters": params,
806
+ "B_parameters": params
807
+ })
808
+
809
+ # Memory backend
810
+ M = Memory(m_config)
811
+
812
+ def f(*blocks):
813
+ # Join partitions and apply inhibition
814
+ A = resolve_block_join_normal(list(blocks), dims)
815
+ X = A
816
+
817
+ # Limit the rate of incoming positives
818
+ if len(X) > absratelimit:
819
+ X = sorted(
820
+ rng.choice(
821
+ X,
822
+ size=absratelimit,
823
+ replace=False).tolist())
824
+
825
+ # Proportional decimation
826
+ if decimation < 1.0:
827
+ sample_size = math.floor(decimation * len(X))
828
+ Xdec = sorted(
829
+ rng.choice(
830
+ X,
831
+ size=sample_size,
832
+ replace=False).tolist())
833
+ else:
834
+ Xdec = X
835
+
836
+ # Always learn if "learn" is True
837
+ if learn_val is True:
838
+ M["store"](A)
839
+
840
+ # Memory retrieval with decimated and rate-limited input state
841
+ Y = M["retrieve"](Xdec)
842
+
843
+ # Learn input A if retrieval fails and it falls within thresholds
844
+ if not Y and min_val <= len(A) <= max_val:
845
+ if learn_val is not True:
846
+ M["store"](A)
847
+ Y = A
848
+
849
+ X_out = plugin["updaterule"](Y, X)
850
+ X_out = sorted(list(set(X_out)))
851
+
852
+ return tuple(multiset_block_split(X_out, dims))
853
+
854
+ # Merge plugin attributes, component defaults, and Memory closures (e.g.,
855
+ # "clear")
856
+ result = dict(plugin)
857
+ result.update({
858
+ "function": f,
859
+ "checks": ["arginp", "argout", "ident"],
860
+ "shape": "square", "fill": 8
861
+ })
862
+ result.update(M)
863
+
864
+ return result
865
+
866
+
867
+ """
868
+ Temporal-associative memory component.
869
+ Learns higher-order sequences on the fly and predicts the next token.
870
+ A hybrid between auto-associative and hetero-associative architectures.
871
+ Uses the same temporal integration parametrization as "delay".
872
+ """
873
+
874
+
875
+ def temporal(config):
876
+ plugin_factory = config.get("plugin", replacement)
877
+
878
+ if isinstance(plugin_factory, str):
879
+ import sys
880
+ plugin_factory = getattr(
881
+ sys.modules[__name__],
882
+ plugin_factory,
883
+ replacement)
884
+
885
+ plugin = plugin_factory(config)
886
+
887
+ receive_blocks = config.get("receive_blocks", [])
888
+ dims = [b[0] for b in receive_blocks]
889
+
890
+ params = [sum(b[0] for b in receive_blocks), sum(b[1]
891
+ for b in receive_blocks)]
892
+ pop = params[1] if len(params) > 1 else 0
893
+
894
+ threshold_factor = config.get("threshold", 0.0)
895
+ absthreshold = round(threshold_factor * pop)
896
+
897
+ rate_limit_factor = config.get("rate_limit", float('inf'))
898
+ absratelimit = round(
899
+ rate_limit_factor *
900
+ pop) if rate_limit_factor != float('inf') else float('inf')
901
+
902
+ decay = config.get("decay", 0.0)
903
+ capacity_factor = config.get("capacity", 1.0)
904
+ abscapacity = round(capacity_factor * pop)
905
+ decimation = config.get("decimate", 1.0)
906
+ latch = config.get("latch", False)
907
+
908
+ # Memory with identical input and output parameters
909
+ m_config = dict(config)
910
+ m_config.update({
911
+ "A_parameters": params,
912
+ "B_parameters": params
913
+ })
914
+
915
+ M = Memory(m_config)
916
+ Xstate = []
917
+ prediction = []
918
+
919
+ def f(*blocks):
920
+ nonlocal Xstate, prediction
921
+ X = multiset_block_join(list(blocks), dims)
922
+
923
+ # Enforce minimum population
924
+ if len(X) < absthreshold:
925
+ X = []
926
+
927
+ # Rate limiting equally applies to positive and negative elements
928
+ if len(X) > absratelimit:
929
+ X = sorted(
930
+ rng.choice(
931
+ X,
932
+ size=absratelimit,
933
+ replace=False).tolist())
934
+
935
+ # Pre-integration stochastic decay
936
+ if decay > 0.0:
937
+ keep_size = math.floor((1.0 - decay) * len(Xstate))
938
+ Xstate = sorted(
939
+ rng.choice(
940
+ Xstate,
941
+ size=keep_size,
942
+ replace=False).tolist())
943
+
944
+ # Always learn.
945
+ M["store"](resolve_normal(Xstate), resolve_normal(X))
946
+
947
+ # Multiset subsampling applied to previous state
948
+ if len(Xstate) > abscapacity:
949
+ Xstate = sorted(
950
+ rng.choice(
951
+ Xstate,
952
+ size=abscapacity,
953
+ replace=False).tolist())
954
+
955
+ # Temporal integration via update rules
956
+ if not (latch and len(X) == 0):
957
+ Xstate = plugin["updaterule"](X, Xstate)
958
+
959
+ # Proportional multiset subsampling applied to the integrated state
960
+ Xdec = Xstate
961
+ if decimation < 1.0:
962
+ sample_size = math.floor(decimation * len(Xdec))
963
+ Xdec = sorted(
964
+ rng.choice(
965
+ Xdec,
966
+ size=sample_size,
967
+ replace=False).tolist())
968
+
969
+ # Predict next token
970
+ prediction = M["retrieve"](resolve_normal(Xdec))
971
+
972
+ return tuple(multiset_block_split(prediction, dims))
973
+
974
+ result = dict(plugin)
975
+ result.update({
976
+ "function": f,
977
+ "checks": ["arginp", "argout", "ident"],
978
+ "shape": "square", "fill": 14
979
+ })
980
+ result.update(M)
981
+
982
+ return result
983
+
984
+
985
+ def associator(config):
986
+ """
987
+ Vanilla hetero-associative node for supervised learning A -> B.
988
+ B is the first input argument.
989
+ A is given by the rest of the input arguments.
990
+ Can be block-coded. Always learns if B != [].
991
+ """
992
+ receive_blocks = config.get("receive_blocks", [])
993
+ Adims = [b[0] for b in receive_blocks[1:]]
994
+
995
+ params_A = [sum(b[0] for b in receive_blocks[1:]), sum(b[1]
996
+ for b in receive_blocks[1:])]
997
+ params_B = receive_blocks[0]
998
+
999
+ m_config = dict(config)
1000
+ m_config.update({
1001
+ "A_parameters": params_A,
1002
+ "B_parameters": params_B
1003
+ })
1004
+
1005
+ M = Memory(m_config)
1006
+
1007
+ def f(B, *blocks):
1008
+ X = resolve_block_join_normal(list(blocks), Adims)
1009
+ Y = resolve_normal(B)
1010
+
1011
+ if not Y:
1012
+ return (M["retrieve"](X),)
1013
+
1014
+ M["store"](X, Y)
1015
+ return ([],)
1016
+
1017
+ result = {
1018
+ "function": f,
1019
+ "checks": ["dimfirst", "oneout"],
1020
+ "shape": "square", "fill": 11, "size": 18, "label": "▶●"
1021
+ }
1022
+ result.update(M)
1023
+ return result
1024
+
1025
+
1026
+ def heteroencoder(config):
1027
+ """
1028
+ Heteroencoder A -> B. A may be partitioned. Has no input for B.
1029
+ """
1030
+ receive_blocks = config.get("receive_blocks", [])
1031
+ dims = [b[0] for b in receive_blocks]
1032
+
1033
+ params_A = [sum(b[0] for b in receive_blocks), sum(b[1]
1034
+ for b in receive_blocks)]
1035
+ params_B = config.get("send_blocks", [[0, 0]])[0]
1036
+
1037
+ m_config = dict(config)
1038
+ m_config.update({
1039
+ "A_parameters": params_A,
1040
+ "B_parameters": params_B
1041
+ })
1042
+
1043
+ M = Memory(m_config)
1044
+
1045
+ def f(*blocks):
1046
+ X = resolve_block_join_normal(list(blocks), dims)
1047
+
1048
+ # Empty input: no output, nothing to learn
1049
+ if not X:
1050
+ return ([],)
1051
+
1052
+ Y = M["retrieve"](X)
1053
+
1054
+ if not Y:
1055
+ n, p = params_B
1056
+ Y = sorted(
1057
+ rng.choice(
1058
+ range(1, n + 1),
1059
+ size=p, replace=False).tolist())
1060
+
1061
+ M["store"](X, Y)
1062
+ return (Y,)
1063
+
1064
+ result = {
1065
+ "function": f,
1066
+ "checks": ["arginp", "oneout"],
1067
+ "shape": "square",
1068
+ "fill": 11,
1069
+ "size": 18,
1070
+ "label": "◀▶"
1071
+ }
1072
+ result.update(M)
1073
+ return result
1074
+
1075
+
1076
+ def predictor(config):
1077
+ """
1078
+ Generates a prediction based on the current input (slot #1)
1079
+ and context (slots #2,...).
1080
+ Automatically learns the correct prediction in the following cycle.
1081
+
1082
+ The context option sets how the context blocks are taken:
1083
+ "replace" (default) takes every input, so an empty block is empty;
1084
+ "hold" keeps each block at its last non-empty value, for receivers
1085
+ of event-driven senders that emit only on change.
1086
+ """
1087
+ receive_blocks = config.get("receive_blocks", [])
1088
+ itemconfig = receive_blocks[0]
1089
+ contextconfig = receive_blocks[1:]
1090
+
1091
+ # Extract decimation parameter, defaulting to 1.0
1092
+ decimation = config.get("decimate", 1.0)
1093
+
1094
+ # Context: "replace" (take every input) or "hold" (keep last non-empty)
1095
+ context_mode = config.get("context", "replace")
1096
+ if context_mode not in ("replace", "hold"):
1097
+ raise CircuitError(
1098
+ "predictor: context must be \"replace\" or \"hold\".")
1099
+ hold = context_mode == "hold"
1100
+
1101
+ params_A = [sum(b[0] for b in contextconfig), sum(b[1]
1102
+ for b in contextconfig)]
1103
+ params_B = itemconfig
1104
+
1105
+ m_config = dict(config)
1106
+ m_config.update({
1107
+ "A_parameters": params_A,
1108
+ "B_parameters": params_B
1109
+ })
1110
+
1111
+ M = Memory(m_config)
1112
+ X = []
1113
+ prediction = []
1114
+ held = [[] for _ in contextconfig]
1115
+
1116
+ def f(item, *blocks):
1117
+ nonlocal X, prediction, held
1118
+ Y = resolve_normal(item)
1119
+
1120
+ M["store"](X, Y) # always learn
1121
+
1122
+ # Hold: an empty block keeps its last non-empty value
1123
+ if hold:
1124
+ held = [b if b else h for b, h in zip(blocks, held)]
1125
+ blocks = held
1126
+
1127
+ X = resolve_block_join_normal(
1128
+ list(blocks), [b[0] for b in contextconfig])
1129
+
1130
+ # Apply stochastic subsampling to X before retrieval
1131
+ if decimation < 1.0:
1132
+ sample_size = math.floor(decimation * len(X))
1133
+ Xdec = sorted(
1134
+ rng.choice(
1135
+ X,
1136
+ size=sample_size,
1137
+ replace=False).tolist())
1138
+ else:
1139
+ Xdec = X
1140
+
1141
+ prediction = M["retrieve"](Xdec)
1142
+
1143
+ return (prediction,)
1144
+
1145
+ def clear():
1146
+ nonlocal X, prediction, held
1147
+ M["clear"]()
1148
+ X, prediction = [], []
1149
+ held = [[] for _ in contextconfig]
1150
+
1151
+ result = {
1152
+ "function": f,
1153
+ "checks": ["dimfirst", "oneout"],
1154
+ "shape": "square", "fill": 11, "size": 18, "label": "▶▶"
1155
+ }
1156
+ result.update(M)
1157
+ result["clear"] = clear
1158
+ return result
1159
+
1160
+
1161
+
1162
+ def noise(config):
1163
+ n, p = config.get("send_blocks", [[0, 0]])[0]
1164
+
1165
+ def f(*blocks):
1166
+ return (
1167
+ sorted(
1168
+ rng.choice(range(1, n + 1),
1169
+ size=p,
1170
+ replace=False).tolist()),
1171
+ )
1172
+
1173
+ return {
1174
+ "function": f,
1175
+ "checks": ["input", "oneout"],
1176
+ "label": "~", "fill": 13, "size": 18
1177
+ }
1178
+
1179
+
1180
+ def file(config):
1181
+ file_path = config.get("name")
1182
+ if not file_path:
1183
+ raise CircuitError(f"Missing 'name' property in {config}")
1184
+
1185
+ if not file_path.endswith(".json"):
1186
+ file_path += ".json"
1187
+
1188
+ # Search current working directory first, then sys.path
1189
+ search_paths = [""] + sys.path
1190
+
1191
+ full_path = None
1192
+ for base in search_paths:
1193
+ target = os.path.join(base, file_path) if base else file_path
1194
+ if os.path.exists(target):
1195
+ full_path = target
1196
+ break
1197
+
1198
+ if not full_path:
1199
+ raise CircuitError(
1200
+ f"File '{file_path}' not found in current directory or sys.path.")
1201
+
1202
+ with open(full_path, "r") as f:
1203
+ circ_expr = json.load(f)
1204
+
1205
+ # Rescale hyperparameters of embedded circuit
1206
+ scale = config.get("scale")
1207
+ default_hp = circ_expr.get("hyperparameters", {}).get("default")
1208
+
1209
+ if (isinstance(scale, (list, tuple)) and len(scale) == 2 and
1210
+ isinstance(default_hp, (list, tuple)) and len(default_hp) == 2):
1211
+
1212
+ # Calculate separate scaling factors
1213
+ rescale_factor_n = scale[0] / default_hp[0]
1214
+ rescale_factor_p = scale[1] / default_hp[1]
1215
+
1216
+ # Apply scaling to all hyperparameters in the embedded circuit
1217
+ for k, v in circ_expr["hyperparameters"].items():
1218
+ if isinstance(v, (list, tuple)) and len(v) == 2:
1219
+ circ_expr["hyperparameters"][k] = [
1220
+ round(v[0] * rescale_factor_n),
1221
+ round(v[1] * rescale_factor_p)
1222
+ ]
1223
+
1224
+ # Compile the imported circuit
1225
+ sub = Circuit(circ_expr)
1226
+
1227
+ # Extract up to 5 characters from the filename for the label
1228
+ base_name = os.path.splitext(os.path.basename(file_path))[0]
1229
+ sub["label"] = base_name[:5]
1230
+
1231
+ return sub
1232
+
1233
+
1234
+ # Registry for compiled embedded circuits
1235
+ circuit_registry = {}
1236
+
1237
+
1238
+ def shared(config):
1239
+ """Plug-in for embedding a precompiled circuit."""
1240
+ name = config.get("name")
1241
+ if not name:
1242
+ raise ValueError(
1243
+ f"Missing 'name' property in shared circuit: {config}")
1244
+
1245
+ sub = circuit_registry.get(name)
1246
+ if not sub:
1247
+ raise ValueError(f"Unknown circuit '{name}'.")
1248
+
1249
+ result = dict(sub)
1250
+ result["label"] = name
1251
+ result.pop("clear", None)
1252
+
1253
+ return result
1254
+
1255
+
1256
+ def circuit(config):
1257
+ """
1258
+ Embedded circuit component.
1259
+ Multiple circuit components can reference the same embedded instance.
1260
+ """
1261
+ plugin_factory = config.get("plugin", shared)
1262
+
1263
+ if isinstance(plugin_factory, str):
1264
+ import sys
1265
+ plugin_factory = getattr(sys.modules[__name__], plugin_factory, shared)
1266
+
1267
+ plugin = plugin_factory(config)
1268
+ if not plugin:
1269
+ return {}
1270
+
1271
+ params = [plugin.get("receive_blocks", []), plugin.get("send_blocks", [])]
1272
+
1273
+ if [config.get("receive_blocks", []), config.get(
1274
+ "send_blocks", [])] != params:
1275
+ raise ValueError(
1276
+ f"Hyperparameters of embedded circuit do not match: {params}")
1277
+
1278
+ def f(*blocks):
1279
+ return plugin["function"](*blocks)
1280
+
1281
+ result = dict(plugin)
1282
+ result.update({
1283
+ "function": f,
1284
+ "checks": ["arginp", "argout"],
1285
+ "shape": "square", "size": 9
1286
+ })
1287
+
1288
+ return result
1289
+
1290
+
1291
+ # Circuit Error Handling
1292
+
1293
+ class CircuitError(Exception):
1294
+ """Custom exception for Circuit-related errors."""
1295
+ pass
1296
+
1297
+
1298
+ circuit_messages = {
1299
+ "ident": "{0}: Inputs must match outputs.",
1300
+ "identdim": "{0}: Input and output dimensions must be identical.",
1301
+ "totaldim": "{0}: Total input and output dimensions must match.",
1302
+ "dimfirst": "{0}: Output must match first input slot.",
1303
+ "input": "No input slots allowed in {0}.",
1304
+ "oneinp": "Expecting one input slot in {0}.",
1305
+ "arginp": "{0}: Missing input.",
1306
+ "output": "No output slots allowed in {0}.",
1307
+ "oneout": "{0}: Expecting one output slot.",
1308
+ "argout": "{0}: Missing output."
1309
+ }
1310
+
1311
+ circuit_checks = {
1312
+ "ident": lambda r,
1313
+ s: [b[0] for b in r] == [b[0] for b in s],
1314
+ "identdim": lambda r,
1315
+ s: sorted(list(set(b[0] for b in r))) == sorted(list(set(b[0] for b in s))),
1316
+ "totaldim": lambda r,
1317
+ s: sum(b[0] for b in r) == sum(b[0] for b in s),
1318
+ "dimfirst": lambda r,
1319
+ s: len(r) > 0 and len(s) > 0 and r[0] == s[0],
1320
+ "input": lambda r,
1321
+ s: len(r) == 0,
1322
+ "oneinp": lambda r,
1323
+ s: len(r) == 1,
1324
+ "arginp": lambda r,
1325
+ s: len(r) > 0,
1326
+ "output": lambda r,
1327
+ s: len(s) == 0,
1328
+ "oneout": lambda r,
1329
+ s: len(s) == 1,
1330
+ "argout": lambda r,
1331
+ s: len(s) > 0}
1332
+
1333
+
1334
+ # Circuit Visualization
1335
+
1336
+
1337
+ # 16-color Nord hex palette as defined in the Mathematica source
1338
+ NORD_PALETTE = [
1339
+ "#2E3440", "#3B4252", "#434C5E", "#4C566A",
1340
+ "#D8DEE9", "#E5E9F0", "#ECEFF4", "#8FBCBB",
1341
+ "#88C0D0", "#81A1C1", "#5E81AC", "#BF616A",
1342
+ "#D08770", "#EBCB8B", "#A3BE8C", "#B48EAD"
1343
+ ]
1344
+
1345
+ # Tag mapping for edge labels
1346
+ TAG_MAP = {
1347
+ "multiset": "+", "inhibit": "-", "permute": "π",
1348
+ "noise": "~", "rate_limit": "R", "threshold": "T",
1349
+ "veto": "X", "mandatory": "*", "priority": "!",
1350
+ "dependency": "&", "fallback": "|", "barrier": "=",
1351
+ "kwta_excitatory": "K", "kwta_absolute": "k",
1352
+ "log": "?", "show_slot": "#", "show_dimension": "$",
1353
+ "show_population": "%"
1354
+ }
1355
+
1356
+
1357
+ def Circuit(expr):
1358
+ """
1359
+ Parses a circuit dataflow dictionary or JSON string and returns
1360
+ a compiled circuit interface.
1361
+ Implements data-driven execution flow and state isolation.
1362
+ """
1363
+ if isinstance(expr, str):
1364
+ expr = json.loads(expr)
1365
+
1366
+ nodes = {}
1367
+ pathways = {}
1368
+
1369
+ preprocess = []
1370
+ postprocess = []
1371
+ inputslots = []
1372
+ outputedges = []
1373
+
1374
+ receiveparams = []
1375
+ sendparams = []
1376
+ permutations = {}
1377
+
1378
+ # Isolated runtime state buffers
1379
+ tick = 0
1380
+ nextstate = {}
1381
+ currentstate = {}
1382
+
1383
+ nodeid = 0
1384
+ edgeid = 0
1385
+
1386
+ def slotparams(slot):
1387
+ hyperparameters = expr.get("hyperparameters", {})
1388
+ default = hyperparameters.get("default", [100, 3])
1389
+ # Accommodate both int and string keys from JSON
1390
+ np_val = hyperparameters.get(
1391
+ slot, hyperparameters.get(
1392
+ str(slot), default))
1393
+
1394
+ if not (isinstance(np_val, list) and len(np_val) == 2):
1395
+ return [100, 3]
1396
+ return np_val
1397
+
1398
+ def fillparams(x):
1399
+ if isinstance(x, list):
1400
+ lists = [fillparams(i) for i in x]
1401
+ dims = [l[0] for l in lists]
1402
+ return [dims[0], min(l[1] for l in lists)]
1403
+ elif isinstance(x, int):
1404
+ return slotparams(x)
1405
+ elif isinstance(x, dict):
1406
+ return slotparams(x["slot"])
1407
+ return None
1408
+
1409
+ def compilepathway(receive):
1410
+ nonlocal edgeid
1411
+
1412
+ if isinstance(receive, list):
1413
+ return [compilepathway(r) for r in receive]
1414
+
1415
+ edgeid += 1
1416
+ current_id = edgeid
1417
+
1418
+ e = {
1419
+ "to_node_id": nodeid,
1420
+ "label": "",
1421
+ "tags": []
1422
+ }
1423
+
1424
+ if isinstance(receive, int):
1425
+ e["slot"] = receive
1426
+ elif isinstance(receive, dict):
1427
+ e.update(receive)
1428
+
1429
+ e["hyperparameters"] = slotparams(e.get("slot"))
1430
+
1431
+ if "inhibit" in e["tags"] and "multiset" not in e["tags"]:
1432
+ e["tags"].append("multiset")
1433
+
1434
+ pathways[current_id] = e
1435
+ currentstate[e.get("slot")] = []
1436
+
1437
+ # Initialize 1-based permutation array
1438
+ n = e["hyperparameters"][0]
1439
+ perm = rng.choice(range(1, n + 1), size=n, replace=False).tolist()
1440
+ permutations[current_id] = perm
1441
+
1442
+ return current_id
1443
+
1444
+ def compile_node(node):
1445
+ nonlocal nodeid
1446
+ nodeid += 1
1447
+ current_node_id = nodeid
1448
+
1449
+ rec_paths = [compilepathway(r) for r in node.get("receive", [])]
1450
+
1451
+ # Flatten send array and extract slots
1452
+ raw_sends = node.get("send", [])
1453
+ flat_sends = []
1454
+
1455
+ def _flat(items):
1456
+ for i in items:
1457
+ if isinstance(i, list):
1458
+ _flat(i)
1459
+ else:
1460
+ flat_sends.append(i)
1461
+ _flat(raw_sends)
1462
+
1463
+ send_slots = []
1464
+ for s in flat_sends:
1465
+ if isinstance(s, int):
1466
+ send_slots.append(s)
1467
+ elif isinstance(s, dict) and "slot" in s:
1468
+ send_slots.append(s["slot"])
1469
+
1470
+ # Base configuration dictionary
1471
+ config = dict(node)
1472
+ config.update({
1473
+ "receive_paths": rec_paths,
1474
+ "node_id": current_node_id,
1475
+ "send_slots": send_slots,
1476
+ "scale": expr.get("hyperparameters", {}).get("default"),
1477
+ "receive_blocks": [fillparams(r) for r in node.get("receive", [])],
1478
+ "send_blocks": [fillparams(s) for s in node.get("send", [])]
1479
+ })
1480
+
1481
+ # Resolve plugin dependency dynamically
1482
+ plugin_name = config.get("plugin")
1483
+ if isinstance(plugin_name, str):
1484
+ plugin_func = getattr(sys.modules[__name__], plugin_name, None)
1485
+ if callable(plugin_func):
1486
+ config["plugin"] = plugin_func
1487
+ else:
1488
+ raise ValueError(f"Invalid plugin '{plugin_name}'.")
1489
+
1490
+ comp_name = node.get("component")
1491
+ if not isinstance(comp_name, str):
1492
+ return
1493
+
1494
+ # Dynamic module namespace lookup
1495
+ comp_func = getattr(sys.modules[__name__], comp_name, None)
1496
+ if not callable(comp_func):
1497
+ raise ValueError(f"Invalid circuit component '{comp_name}'.")
1498
+
1499
+ comp_result = comp_func(config)
1500
+
1501
+ # Strict overriding sequence
1502
+ final_config = {
1503
+ "shape": "circle", "label": "", "fill": 6, "color": 0, "size": 12,
1504
+ "checks": [], "function": None
1505
+ }
1506
+ final_config.update(config)
1507
+ final_config.update(comp_result)
1508
+
1509
+ # Static error checking, based on component-supplied "checks"
1510
+ for check in final_config.get("checks", []):
1511
+ if check in circuit_checks:
1512
+ if not circuit_checks[check](
1513
+ final_config["receive_blocks"],
1514
+ final_config["send_blocks"]):
1515
+ error_msg = circuit_messages.get(
1516
+ check, f"Check {check} failed.").format(comp_name)
1517
+ raise CircuitError(error_msg)
1518
+
1519
+ if comp_name == "input":
1520
+ preprocess.append(final_config["function"])
1521
+ inputslots.append(final_config["send_slots"][0])
1522
+ receiveparams.append(final_config["send_blocks"][0])
1523
+ final_config["label"] = final_config["label"].replace(
1524
+ "#", str(len(inputslots)))
1525
+
1526
+ if comp_name == "output":
1527
+ postprocess.append(final_config["function"])
1528
+ outputedges.append(final_config["receive_paths"][0])
1529
+ sendparams.append(final_config["receive_blocks"][0])
1530
+ final_config["label"] = final_config["label"].replace(
1531
+ "#", str(len(outputedges)))
1532
+
1533
+ nodes[current_node_id] = final_config
1534
+
1535
+ # Trigger compilation
1536
+ for node in expr.get("dataflow", []):
1537
+ compile_node(node)
1538
+
1539
+ # Compile link table for from_node_id values
1540
+ links = {}
1541
+ for node in nodes.values():
1542
+ for slot in node.get("send_slots", []):
1543
+ links[slot] = node["node_id"]
1544
+
1545
+ for edge in pathways.values():
1546
+ edge["from_node_id"] = links.get(edge.get("slot"), 0)
1547
+
1548
+ def patheval(id_val):
1549
+ e = pathways[id_val]
1550
+ tags = set(e.get("tags", []))
1551
+ n, p = e["hyperparameters"]
1552
+
1553
+ x = currentstate.get(e.get("slot"), [])
1554
+
1555
+ # Upstream validation of 1-based non-zero data
1556
+ if not isinstance(x, list) or 0 in x:
1557
+ raise ValueError(f"Invalid data in slot {e.get('slot')}: {x}")
1558
+
1559
+ gating = e.get("gating", [1])
1560
+ if gating[(tick - 1) % len(gating)] == 0:
1561
+ return []
1562
+
1563
+ # The ONLY source of inhibition
1564
+ if "inhibit" in tags:
1565
+ x = [-abs(i) for i in x]
1566
+
1567
+ if "multiset" in tags:
1568
+ x = multiset(x)
1569
+ else:
1570
+ x = resolve_normal(x)
1571
+
1572
+ if "permute" in tags:
1573
+ perm = permutations[id_val]
1574
+ x = sorted([(1 if i > 0 else -1) * perm[abs(i) - 1] for i in x])
1575
+
1576
+ if "rate_limit" in tags and len(x) > p:
1577
+ x = sorted(rng.choice(x, size=p, replace=False).tolist())
1578
+
1579
+ if "threshold" in tags and len(x) < p:
1580
+ x = []
1581
+
1582
+ if "noise" in tags:
1583
+ if not x:
1584
+ x = sorted(
1585
+ rng.choice(
1586
+ range(
1587
+ 1,
1588
+ n + 1),
1589
+ size=p,
1590
+ replace=False).tolist())
1591
+ else:
1592
+ x = []
1593
+
1594
+ if "log" in tags:
1595
+ print(f"Slot {e.get('slot')}: {x}")
1596
+
1597
+ return x
1598
+
1599
+ def pathmerge(ids):
1600
+ if isinstance(ids, int):
1601
+ ids = [ids]
1602
+
1603
+ x_list = [patheval(i) for i in ids]
1604
+ paths = [pathways[i] for i in ids]
1605
+
1606
+ def has_tag(t):
1607
+ return [t in p.get("tags", []) for p in paths]
1608
+
1609
+ veto_tags = has_tag("veto")
1610
+ if any(x for i, x in enumerate(x_list) if veto_tags[i] and x):
1611
+ return []
1612
+
1613
+ mandatory_tags = has_tag("mandatory")
1614
+ if any(not x for i, x in enumerate(x_list) if mandatory_tags[i]):
1615
+ return []
1616
+
1617
+ priority_tags = has_tag("priority")
1618
+ if any(x for i, x in enumerate(x_list) if priority_tags[i] and x):
1619
+ x_list = [x if priority_tags[i] else []
1620
+ for i, x in enumerate(x_list)]
1621
+
1622
+ dependency_tags = has_tag("dependency")
1623
+ if any(not x for i, x in enumerate(x_list) if not dependency_tags[i]):
1624
+ x_list = [[] if dependency_tags[i]
1625
+ else x for i, x in enumerate(x_list)]
1626
+
1627
+ fallback_tags = has_tag("fallback")
1628
+ if any(x for i, x in enumerate(x_list) if not fallback_tags[i] and x):
1629
+ x_list = [[] if fallback_tags[i]
1630
+ else x for i, x in enumerate(x_list)]
1631
+
1632
+ barrier_tags = has_tag("barrier")
1633
+ if any(barrier_tags) and any(not x for i,
1634
+ x in enumerate(x_list) if barrier_tags[i]):
1635
+ x_list = [[] if barrier_tags[i]
1636
+ else x for i, x in enumerate(x_list)]
1637
+
1638
+ merged = multiset(x_list)
1639
+
1640
+ kwta_exc_tags = has_tag("kwta_excitatory")
1641
+ kwta_abs_tags = has_tag("kwta_absolute")
1642
+
1643
+ # Optional kWTA. Ranks elements by multiplicity across all paths
1644
+ # (within-path counts of multiset paths included). Passes every
1645
+ # element at or above the k-th highest count, ties included. With
1646
+ # k or fewer distinct elements, all pass. Outputs a set.
1647
+ if any(kwta_exc_tags) or any(kwta_abs_tags):
1648
+ k = min(p["hyperparameters"][1] for p in paths)
1649
+ U = [i for i in merged if i > 0] if any(kwta_exc_tags) else merged
1650
+
1651
+ tally = Counter(U)
1652
+ if len(tally) <= k:
1653
+ merged = sorted(tally)
1654
+ else:
1655
+ kth = sorted(tally.values())[-k]
1656
+ merged = sorted(
1657
+ [val for val, count in tally.items() if count >= kth])
1658
+
1659
+ return merged
1660
+
1661
+ def componenteval(config):
1662
+ if config.get("component") in ("input", "output"):
1663
+ return
1664
+
1665
+ inputs = [pathmerge(rp) for rp in config.get("receive_paths", [])]
1666
+
1667
+ func = config.get("function")
1668
+ result = func(*inputs) if func else tuple([]
1669
+ for _ in config.get("send_slots", []))
1670
+
1671
+ if not isinstance(result, tuple):
1672
+ result = (result,)
1673
+
1674
+ for slot, res in zip(config.get("send_slots", []), result):
1675
+ nextstate[slot] = res
1676
+
1677
+ def evaluate(x):
1678
+ nonlocal tick, currentstate, nextstate
1679
+ tick += 1
1680
+
1681
+ for slot, val in zip(inputslots, x):
1682
+ currentstate[slot] = val
1683
+ nextstate[slot] = val
1684
+
1685
+ for config in nodes.values():
1686
+ componenteval(config)
1687
+
1688
+ # Snapshot state, preparing for extraction and next cycle
1689
+ currentstate = dict(nextstate)
1690
+
1691
+ return [pathmerge(edge) for edge in outputedges]
1692
+
1693
+ def multiplex(x):
1694
+ if len(x) != len(inputslots):
1695
+ raise ValueError(
1696
+ f"Function arguments do not match input nodes: {inputslots}")
1697
+
1698
+ # Latch: skip the cycle (clock included) if all inputs are empty
1699
+ if expr.get("options", {}).get("latch", False) is True and \
1700
+ not any(x):
1701
+ return [[] for _ in outputedges]
1702
+
1703
+ multiplex = expr.get("options", {}).get("multiplex", [1])
1704
+ if not all(m in (0, 1) for m in multiplex):
1705
+ multiplex = [1]
1706
+
1707
+ y = []
1708
+ for gate in multiplex:
1709
+ if gate == 1:
1710
+ y = evaluate(x)
1711
+ else:
1712
+ y = evaluate([[] for _ in x])
1713
+ return y
1714
+
1715
+ def f(*blocks):
1716
+ x = list(blocks)
1717
+ if len(x) != len(inputslots):
1718
+ raise ValueError(
1719
+ f"Function arguments do not match input nodes: {inputslots}")
1720
+
1721
+ x_enc = [prep(val) for prep, val in zip(preprocess, x)]
1722
+ y_raw = multiplex(x_enc)
1723
+ y = [post(val) for post, val in zip(postprocess, y_raw)]
1724
+
1725
+ return tuple(y)
1726
+
1727
+ def schematics(filename):
1728
+ """Generates a static PNG graph visualization of the circuit."""
1729
+ from matplotlib.figure import Figure # imported here: only needed for drawing
1730
+
1731
+ G = nx.MultiDiGraph()
1732
+
1733
+ # Build nodes
1734
+ for nid, node_data in nodes.items():
1735
+ fill_color = NORD_PALETTE[node_data.get("fill", 6)]
1736
+ text_color = NORD_PALETTE[node_data.get("color", 0)]
1737
+ label = node_data.get("label", "")
1738
+
1739
+ # Map node shapes and apply shape-specific area multipliers
1740
+ if node_data.get("shape") == "square":
1741
+ shape = "s"
1742
+ size_multiplier = 100
1743
+ else:
1744
+ shape = "o" # Default Circle
1745
+ size_multiplier = 180 # Scale circles up relative to squares
1746
+
1747
+ G.add_node(
1748
+ nid,
1749
+ label=label,
1750
+ fill_color=fill_color,
1751
+ text_color=text_color,
1752
+ shape=shape,
1753
+ size=node_data.get("size", 12) * size_multiplier
1754
+ )
1755
+ # Build edges
1756
+ for eid, edge_data in pathways.items():
1757
+ u = edge_data.get("from_node_id")
1758
+ v = edge_data.get("to_node_id")
1759
+
1760
+ if not u or not v:
1761
+ continue
1762
+
1763
+ tags = edge_data.get("tags", [])
1764
+
1765
+ # Construct edge label logic
1766
+ tag_symbols = "".join([TAG_MAP.get(t, "") for t in tags])
1767
+ user_label = edge_data.get("label", "")
1768
+
1769
+ gating = edge_data.get("gating")
1770
+ gating_str = "@" + "".join(str(g)
1771
+ for g in gating) if gating else ""
1772
+
1773
+ raw_label = f"{user_label}{tag_symbols}{gating_str}"
1774
+
1775
+ # Substitute dynamic variables
1776
+ hyperparams = edge_data.get("hyperparameters", [0, 0])
1777
+ replacements = {
1778
+ "#": str(edge_data.get("slot", "")),
1779
+ "$": str(hyperparams[0]),
1780
+ "%": str(hyperparams[1])
1781
+ }
1782
+
1783
+ final_label = raw_label
1784
+ for old, new in replacements.items():
1785
+ final_label = final_label.replace(old, new)
1786
+
1787
+ # Clean up default labels from UI display
1788
+ display_label = final_label.replace(
1789
+ "-",
1790
+ "").replace(
1791
+ "+",
1792
+ "").replace(
1793
+ "*",
1794
+ "✱")
1795
+
1796
+ # Edge styling
1797
+ edge_color = NORD_PALETTE[0]
1798
+ edge_width = 1.0
1799
+
1800
+ if "-" in tag_symbols:
1801
+ edge_color = NORD_PALETTE[11] # Thick red for inhibition
1802
+ edge_width = 3.5
1803
+ elif "+" in tag_symbols:
1804
+ edge_color = NORD_PALETTE[8] # Thick blue for excitation
1805
+ edge_width = 3.5
1806
+
1807
+ G.add_edge(
1808
+ u, v,
1809
+ label=display_label,
1810
+ color=edge_color,
1811
+ width=edge_width
1812
+ )
1813
+
1814
+
1815
+ # Create a clean graph for layout calculation
1816
+ G_layout = nx.MultiDiGraph()
1817
+ G_layout.add_nodes_from(G.nodes())
1818
+ G_layout.add_edges_from(G.edges(keys=True))
1819
+
1820
+ # Assign attributes (respected by AGraph, often dropped by PyDot)
1821
+ G_layout.graph['rankdir'] = 'LR'
1822
+ G_layout.graph['ranksep'] = '0.85'
1823
+ G_layout.graph['nodesep'] = '0.85'
1824
+
1825
+ # Left-to-right hierarchical layout using Graphviz (dot)
1826
+ try:
1827
+ from networkx.drawing.nx_agraph import graphviz_layout
1828
+ pos = graphviz_layout(G_layout, prog='dot', args='-Grankdir=LR')
1829
+ except Exception:
1830
+ try:
1831
+ from networkx.drawing.nx_pydot import pydot_layout
1832
+ # PyDot wrapper drops the rankdir graph attribute.
1833
+ # Calculate standard Top-to-Bottom and rotate mathematically to
1834
+ # enforce Left-to-Right.
1835
+ pos_tb = pydot_layout(G_layout, prog='dot')
1836
+ pos = {n: (-y, x) for n, (x, y) in pos_tb.items()}
1837
+ except Exception:
1838
+ print(
1839
+ "Warning: Install 'pygraphviz' or 'pydot' (and OS-level Graphviz) for left-to-right layout.")
1840
+ pos = nx.spring_layout(G_layout, seed=42)
1841
+
1842
+
1843
+ # Render graph on a slightly larger canvas
1844
+ fig = Figure(figsize=(11, 7))
1845
+ ax = fig.add_subplot()
1846
+
1847
+ # Draw edges individually to calculate curve routing for bidirectional
1848
+ # and parallel overlaps
1849
+ edges = G.edges(data=True, keys=True)
1850
+ for u, v, key, data in edges:
1851
+ rad = 0.0
1852
+ if G.has_edge(v, u) or G.number_of_edges(u, v) > 1:
1853
+ # Alternate the curvature radius for parallel edges: 0.2, -0.2,
1854
+ # 0.4, -0.4...
1855
+ magnitude = 0.2 + 0.2 * (key // 2)
1856
+ direction = 1 if key % 2 == 0 else -1
1857
+ rad = magnitude * direction
1858
+
1859
+ nx.draw_networkx_edges(
1860
+ G, pos,
1861
+ edgelist=[(u, v)],
1862
+ edge_color=data['color'],
1863
+ width=data['width'],
1864
+ arrows=True,
1865
+ arrowstyle='-|>',
1866
+ arrowsize=18,
1867
+ node_size=G.nodes[v]['size'],
1868
+ # Instructs Matplotlib to calculate intersection for this
1869
+ # specific shape
1870
+ node_shape=G.nodes[v]['shape'],
1871
+ connectionstyle=f'arc3,rad={rad}',
1872
+ ax=ax
1873
+ )
1874
+
1875
+ # Aggregate labels for parallel edges into multiline strings centered
1876
+ # between the curves
1877
+ edge_labels = {}
1878
+ for u, v, key, data in edges:
1879
+ lbl = data.get('label', '')
1880
+ if lbl:
1881
+ current = edge_labels.get((u, v), "")
1882
+ # Prevent duplicating identical labels on perfectly mirrored
1883
+ # parallel edges
1884
+ if lbl not in current:
1885
+ edge_labels[(u,
1886
+ v)] = f"{current}\n{lbl}" if current else lbl
1887
+
1888
+ nx.draw_networkx_edge_labels(
1889
+ G, pos, edge_labels=edge_labels,
1890
+ font_family="Inter Variable",
1891
+ font_color=NORD_PALETTE[0],
1892
+ bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="none"),
1893
+ label_pos=0.5,
1894
+ ax=ax
1895
+ )
1896
+
1897
+ # Draw nodes
1898
+ for node, data in G.nodes(data=True):
1899
+ nx.draw_networkx_nodes(
1900
+ G, pos, nodelist=[node],
1901
+ node_color=data['fill_color'],
1902
+ node_shape=data['shape'],
1903
+ node_size=data['size'],
1904
+ edgecolors=NORD_PALETTE[1],
1905
+ linewidths=1.5,
1906
+ ax=ax
1907
+ )
1908
+
1909
+ # Draw node labels
1910
+ node_labels = {node: data['label']
1911
+ for node, data in G.nodes(data=True)}
1912
+ nx.draw_networkx_labels(
1913
+ G, pos, labels=node_labels,
1914
+ font_family="Inter Variable",
1915
+ font_color=NORD_PALETTE[0],
1916
+ font_weight="bold",
1917
+ ax=ax
1918
+ )
1919
+
1920
+ ax.axis('off')
1921
+ fig.tight_layout()
1922
+ fig.savefig(filename, dpi=300, bbox_inches='tight')
1923
+
1924
+
1925
+ def clear():
1926
+ for node in nodes.values():
1927
+ c = node.get("clear")
1928
+ if callable(c):
1929
+ c()
1930
+
1931
+ dispatch = {
1932
+ "function": f,
1933
+ "schematics": schematics,
1934
+ "clear": clear,
1935
+ "receive_blocks": receiveparams,
1936
+ "send_blocks": sendparams,
1937
+ "nodes": lambda: nodes
1938
+ }
1939
+
1940
+ name = expr.get("options", {}).get("name")
1941
+ if name is not None:
1942
+ dispatch["name"] = name
1943
+ circuit_registry[name] = dispatch
1944
+
1945
+ return dispatch