rnba 0.1.0__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.
rnba/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """RNBA package
2
+
3
+ Simple package with a greet() helper used in tests and CLI.
4
+ """
5
+
6
+ __version__ = "0.1.0"
7
+
8
+
9
+ def greet(name: str = "RNBA") -> str:
10
+ """Return a greeting for `name`."""
11
+ return f"Hello, {name}!"
12
+
13
+
14
+ from .architecture import load_architecture, apply_instruction_schedule, apply_bindings
15
+
16
+ # Try importing visualization module if graphviz is available
17
+ try:
18
+ from .visualize import visualize_network, visualize_circuit_template
19
+ __all__ = ['greet', 'load_architecture', 'apply_instruction_schedule', 'apply_bindings',
20
+ 'visualize_network', 'visualize_circuit_template']
21
+ except ImportError:
22
+ # graphviz not installed, visualization not available
23
+ __all__ = ['greet', 'load_architecture', 'apply_instruction_schedule', 'apply_bindings']
rnba/_bin/rnba-sim.exe ADDED
Binary file
rnba/_sim.py ADDED
@@ -0,0 +1,57 @@
1
+ """Locate and run the bundled ``rnba-sim`` C++ simulator executable.
2
+
3
+ Wheel installs ship the compiled binary in ``rnba/_bin/``. Development
4
+ checkouts fall back to the CMake build tree (``cpp-sim/build/``) and
5
+ finally to ``PATH``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ from importlib.resources import files
15
+ from pathlib import Path
16
+ from typing import Optional, Sequence
17
+
18
+ _EXE_NAME = "rnba-sim.exe" if os.name == "nt" else "rnba-sim"
19
+
20
+
21
+ def sim_executable() -> str:
22
+ """Return the path to the ``rnba-sim`` executable.
23
+
24
+ Raises FileNotFoundError if no executable can be located.
25
+ """
26
+ bundled = Path(str(files("rnba"))) / "_bin" / _EXE_NAME
27
+ if bundled.is_file():
28
+ return str(bundled)
29
+
30
+ dev_build = Path(__file__).resolve().parent.parent / "cpp-sim" / "build" / _EXE_NAME
31
+ if dev_build.is_file():
32
+ return str(dev_build)
33
+
34
+ on_path = shutil.which(_EXE_NAME)
35
+ if on_path:
36
+ return on_path
37
+
38
+ raise FileNotFoundError(
39
+ "rnba-sim executable not found. Install the rnba wheel (which bundles "
40
+ "it), or build it from a source checkout with: "
41
+ "cmake -B cpp-sim/build cpp-sim && cmake --build cpp-sim/build"
42
+ )
43
+
44
+
45
+ def run_sim(args: Sequence[str], **subprocess_kwargs) -> subprocess.CompletedProcess:
46
+ """Run ``rnba-sim`` with ``args`` and return the completed process."""
47
+ return subprocess.run([sim_executable(), *args], **subprocess_kwargs)
48
+
49
+
50
+ def main(argv: Optional[Sequence[str]] = None) -> int:
51
+ """Console entry point forwarding to the bundled executable."""
52
+ args = list(sys.argv[1:] if argv is None else argv)
53
+ return subprocess.call([sim_executable(), *args])
54
+
55
+
56
+ if __name__ == "__main__":
57
+ raise SystemExit(main())
rnba/architecture.py ADDED
@@ -0,0 +1,491 @@
1
+ """Load an RNBA architecture text file into a Network.
2
+
3
+ Parses the row-based architecture format produced by the NBA build tools
4
+ and returns a fully wired Network where:
5
+
6
+ - Normal populations become Node objects.
7
+ - External populations become Input objects with dormant activation
8
+ (``lambda t: 0.0``). Use :func:`apply_instruction_schedule` to set
9
+ activation schedules, and :func:`apply_bindings` to register WM
10
+ clamps.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections import defaultdict
16
+ from pathlib import Path
17
+ from typing import Dict, List, Tuple, Union
18
+
19
+ from rnba.network import Network
20
+ from rnba.nodes import Node
21
+ from rnba.inputs import Input
22
+ from rnba.constants import (
23
+ EXCITATORY_CON_TYPES as _EXCITATORY_CON_TYPES,
24
+ SIGMOID_PARAMS,
25
+ TAU_E,
26
+ TAU_I,
27
+ W_ALL,
28
+ WEIGHT_SCALE as _WEIGHT_SCALE,
29
+ WC_COUPLING_PARAMS,
30
+ )
31
+
32
+
33
+ def load_architecture(
34
+ filepath: Union[str, Path],
35
+ weight: float = W_ALL,
36
+ tau_E: float = TAU_E,
37
+ tau_I: float = TAU_I,
38
+ ) -> Network:
39
+ """Parse an architecture file and build a ready-to-schedule Network.
40
+
41
+ Parameters
42
+ ----------
43
+ filepath : str or Path
44
+ Path to an ``RNBA_arch_SGall_SNB*.txt`` file.
45
+ weight : float, optional
46
+ Base connection weight (default 0.2, the ``w_all`` used in the
47
+ original simulation script).
48
+ tau_E : float, optional
49
+ Time constant for excitatory populations (default 2.5 ms,
50
+ matching ``1/0.4`` from ``pop_module.c``).
51
+ tau_I : float, optional
52
+ Time constant for inhibitory populations (default 1.25 ms,
53
+ matching ``1/0.8`` from ``pop_module.c``).
54
+
55
+ Returns
56
+ -------
57
+ Network
58
+ A Network with all internal wiring complete. External inputs
59
+ have ``activation = lambda t: 0.0`` — set their activation
60
+ functions before calling ``simulate()``.
61
+
62
+ Raises
63
+ ------
64
+ FileNotFoundError
65
+ If *filepath* does not exist.
66
+ ValueError
67
+ If the file cannot be parsed or has inconsistent row counts.
68
+ """
69
+ filepath = Path(filepath)
70
+ if not filepath.exists():
71
+ raise FileNotFoundError(f"Architecture file not found: {filepath}")
72
+
73
+ # ------------------------------------------------------------------
74
+ # 1. Parse
75
+ # ------------------------------------------------------------------
76
+ rows = [] # one dict per population row
77
+ expected_len = None
78
+
79
+ with open(filepath) as fh:
80
+ for raw_line in fh:
81
+ line = raw_line.strip()
82
+ if not line:
83
+ continue
84
+
85
+ # Header line: "lenlist= 16298"
86
+ if line.startswith("lenlist="):
87
+ expected_len = int(line.split("=")[1])
88
+ continue
89
+
90
+ # Data rows start with a digit (the p_indx column)
91
+ if not line[0].isdigit():
92
+ continue
93
+
94
+ parts = line.split()
95
+ if len(parts) < 8:
96
+ continue
97
+
98
+ rows.append({
99
+ "index": int(parts[0]),
100
+ "con_typ": parts[1],
101
+ "pop_typ": parts[2],
102
+ "pop_id": parts[3],
103
+ "circ_id": parts[4],
104
+ "h_row": parts[5],
105
+ "d_row": parts[6],
106
+ "mat_id": parts[7],
107
+ "connection_input": [int(tok) for tok in parts[8:] if tok.isdigit()],
108
+ })
109
+
110
+ if expected_len is not None and len(rows) != expected_len:
111
+ raise ValueError(
112
+ f"Expected {expected_len} rows (from lenlist header) but parsed {len(rows)}"
113
+ )
114
+
115
+ # ------------------------------------------------------------------
116
+ # 2. Create population objects
117
+ # ------------------------------------------------------------------
118
+ # Index-ordered lists so row indices stay valid for connection wiring.
119
+ #
120
+ # Normal pop_ids are NOT unique (e.g. "GC_start_G1" appears 2745×
121
+ # across different circuit instances). External pop_ids ARE unique.
122
+ # We therefore use the row index as the Node name for normals, and
123
+ # the pop_id as the Input name for externals.
124
+ pop_objects = [] # Node | Input, ordered by architecture index
125
+ pop_is_node = [] # True if Node, False if Input
126
+ metadata: Dict[str, dict] = {}
127
+
128
+ _zero = lambda t: 0.0 # noqa: E731 — shared dormant activation
129
+
130
+ for row in rows:
131
+ idx = row["index"]
132
+ pid = row["pop_id"]
133
+
134
+ if row["pop_typ"] == "normal":
135
+ # Name by row index — guarantees uniqueness.
136
+ # Assign tau based on con_typ: excitatory vs inhibitory.
137
+ is_excit = row["con_typ"] in _EXCITATORY_CON_TYPES
138
+ tau = tau_E if is_excit else tau_I
139
+ obj = Node(str(idx), tau=tau, is_excitatory=is_excit)
140
+ pop_is_node.append(True)
141
+ else:
142
+ # External pop_ids are unique; use them directly so callers
143
+ # can look them up by descriptive name.
144
+ obj = Input(pid, _zero)
145
+ pop_is_node.append(False)
146
+
147
+ pop_objects.append(obj)
148
+ metadata[str(idx)] = {
149
+ "index": idx,
150
+ "pop_id": pid,
151
+ "con_typ": row["con_typ"],
152
+ "pop_typ": row["pop_typ"],
153
+ "circ_id": row["circ_id"],
154
+ "h_row": row["h_row"],
155
+ "d_row": row["d_row"],
156
+ "mat_id": row["mat_id"],
157
+ }
158
+
159
+ # ------------------------------------------------------------------
160
+ # 3. Register in Network (build phase)
161
+ # ------------------------------------------------------------------
162
+ net = Network(sigmoid_params=SIGMOID_PARAMS, wc_coupling=WC_COUPLING_PARAMS)
163
+
164
+ for obj, is_node in zip(pop_objects, pop_is_node):
165
+ if is_node:
166
+ net.add_node(obj)
167
+ else:
168
+ net.add_input(obj)
169
+
170
+ # ------------------------------------------------------------------
171
+ # 4. Wire connections (connect phase — triggers __MapNodes on first call)
172
+ # ------------------------------------------------------------------
173
+ for row in rows:
174
+ if row["pop_typ"] != "normal":
175
+ continue # external pops are sources only, never targets
176
+
177
+ target_name = str(row["index"])
178
+
179
+ for src_idx in row["connection_input"]:
180
+ src_row = rows[src_idx]
181
+ src_con_typ = src_row["con_typ"]
182
+
183
+ scale = _WEIGHT_SCALE.get(src_con_typ)
184
+ if scale is None:
185
+ raise ValueError(
186
+ f"Unknown connection type '{src_con_typ}' on population "
187
+ f"{src_idx} ({src_row['pop_id']})"
188
+ )
189
+ w = weight * scale
190
+
191
+ if src_row["pop_typ"] == "normal":
192
+ net.ConnectNodeNode(str(src_idx), target_name, w)
193
+ else:
194
+ net.ConnectInputNode(src_row["pop_id"], target_name, w)
195
+
196
+ # Attach metadata for downstream use (visualisation, debugging).
197
+ net.population_metadata = metadata
198
+
199
+ return net
200
+
201
+
202
+ # ======================================================================
203
+ # Instruction-file command → external pop_id mapping
204
+ # ======================================================================
205
+
206
+ _LMA_SHORT = ['Adj', 'Adv', 'C', 'Co', 'Det', 'N', 'P', 'Pr', 'V']
207
+ _LMA_FULL = [
208
+ 'Adjective', 'Adverb', 'Clause', 'Coordinator', 'Determiner',
209
+ 'Noun', 'Preposition', 'Pronoun', 'Verb',
210
+ ]
211
+ _SG_NAMES = [
212
+ 'SG1Verb', 'SG2Subject', 'SG3Object', 'SG4Det', 'SG5Compl',
213
+ 'SG6Mod', 'SG7Adjunct', 'SG8PC', 'SG9Ex_Mod', 'SG10Coord', 'SG11GAP',
214
+ ]
215
+
216
+
217
+ def _build_command_map() -> Dict[str, str]:
218
+ """Return a mapping from instruction-file command → external pop_id."""
219
+ m: Dict[str, str] = {}
220
+ m['Word_inhib'] = 'EX0a_inhib_W_all'
221
+
222
+ for short, full in zip(_LMA_SHORT, _LMA_FULL):
223
+ m[full] = f'EX2_Bgate_W_{short}'
224
+ m[f'{full}_H'] = f'EX3H_Gate_{short}_H'
225
+ m[f'{full}_D'] = f'EX3D_Gate_{short}_D'
226
+ m[f'CMtoW_{full}_H'] = f'EX8H_LMA_out_{short}_H'
227
+ m[f'CMtoW_{full}_D'] = f'EX8D_LMA_out_{short}_D'
228
+ m[f'{full}_inhib'] = f'EX0_inhib_{short}'
229
+
230
+ for sg in _SG_NAMES:
231
+ m[f'binding_{sg}_H'] = f'EX4H_Bgate_LMA_{sg}_H'
232
+ m[f'binding_{sg}_D'] = f'EX4D_Bgate_LMA_{sg}_D'
233
+ m[f'{sg}_H'] = f'EX6H_Gate_{sg}_CM_H'
234
+ m[f'{sg}_D'] = f'EX6D_Gate_{sg}_CM_D'
235
+ m[f'{sg}_W_H'] = f'EX5H_Gate_{sg}_W_H'
236
+ m[f'{sg}_W_D'] = f'EX5D_Gate_{sg}_W_D'
237
+ m[f'inhibcomp_{sg}_H'] = f'EX7H_SG_comp_inhib_{sg}_H'
238
+ m[f'inhibcomp_{sg}_D'] = f'EX7D_SG_comp_inhib_{sg}_D'
239
+
240
+ return m
241
+
242
+
243
+ _COMMAND_MAP = _build_command_map()
244
+
245
+
246
+ # ======================================================================
247
+ # Instruction-file parser
248
+ # ======================================================================
249
+
250
+ def _parse_instruction_file(
251
+ filepath: Path,
252
+ ) -> List[Tuple[int, int, str]]:
253
+ """Parse a sentence/question instruction file.
254
+
255
+ Returns a list of ``(start_time, end_time, external_pop_id)``
256
+ triples, reproducing the logic of
257
+ ``NBA24_QA_sentence_input.Sentence_file``.
258
+ """
259
+ schedule: List[Tuple[int, int, str]] = []
260
+ word_list: List[str] = []
261
+ step_size = 0
262
+
263
+ with open(filepath) as fh:
264
+ for raw_line in fh:
265
+ parts = raw_line.split()
266
+ if not parts or raw_line.startswith('#'):
267
+ continue
268
+
269
+ cmd = parts[0]
270
+
271
+ if cmd == 'Num_of_words':
272
+ n_words = int(parts[1])
273
+ word_list = [f'EX1_input_W{k}' for k in range(n_words)]
274
+ continue
275
+
276
+ if cmd == 'Word':
277
+ start = step_size + int(parts[1])
278
+ step_size = start
279
+ end = step_size + int(parts[2])
280
+ word_idx = int(parts[3])
281
+ schedule.append((start, end, word_list[word_idx]))
282
+ continue
283
+
284
+ if cmd in _COMMAND_MAP:
285
+ start = step_size + int(parts[1])
286
+ end = step_size + int(parts[2])
287
+ schedule.append((start, end, _COMMAND_MAP[cmd]))
288
+
289
+ return schedule
290
+
291
+
292
+ def _make_piecewise_activation(
293
+ windows: List[Tuple[int, int]],
294
+ amplitude: float,
295
+ ):
296
+ """Return an activation function that is *amplitude* during any of
297
+ the given ``[start, end]`` windows and 0.0 otherwise."""
298
+ sorted_windows = sorted(windows)
299
+
300
+ def activation(t):
301
+ for start, end in sorted_windows:
302
+ if start <= t <= end:
303
+ return amplitude
304
+ return 0.0
305
+
306
+ return activation
307
+
308
+
309
+ # ======================================================================
310
+ # Public API: apply instruction schedule
311
+ # ======================================================================
312
+
313
+ def apply_instruction_schedule(
314
+ net: Network,
315
+ instruction_file: Union[str, Path],
316
+ amplitude: float = 100.0,
317
+ h: float = 0.1,
318
+ ) -> None:
319
+ """Set activation functions on external Inputs from an instruction file.
320
+
321
+ Parameters
322
+ ----------
323
+ net : Network
324
+ A network previously created by :func:`load_architecture`.
325
+ instruction_file : str or Path
326
+ Path to a sentence/question instruction file (e.g.
327
+ ``QA_3NVN_O_success.txt``).
328
+ amplitude : float, optional
329
+ Activation level during scheduled ON windows (default 100.0).
330
+ h : float, optional
331
+ Integration step size in ms (default 0.1, from ``pop_module.c``).
332
+ Instruction-file times are in timestep indices; multiplying by
333
+ *h* converts them to milliseconds for ``odeint``.
334
+ """
335
+ instruction_file = Path(instruction_file)
336
+ if not instruction_file.exists():
337
+ raise FileNotFoundError(
338
+ f"Instruction file not found: {instruction_file}"
339
+ )
340
+
341
+ schedule = _parse_instruction_file(instruction_file)
342
+
343
+ # Group windows by pop_id (a single pop may appear in multiple triples).
344
+ # Scale timestep indices → ms by multiplying by h.
345
+ windows: Dict[str, List[Tuple[float, float]]] = defaultdict(list)
346
+ for start, end, pop_id in schedule:
347
+ windows[pop_id].append((start * h, end * h))
348
+
349
+ for pop_id, wins in windows.items():
350
+ inp = net.get_input(pop_id)
351
+ inp.activation_func = _make_piecewise_activation(wins, amplitude)
352
+
353
+
354
+ # ======================================================================
355
+ # SGall instruction-file parser (fixed_time format)
356
+ # ======================================================================
357
+
358
+ def _parse_sgall_instruction_file(
359
+ filepath: Path,
360
+ ) -> List[Tuple[int, int, str]]:
361
+ """Parse an SGall-format sentence instruction file.
362
+
363
+ SGall files use a ``fixed_time`` directive and two-argument ``Word``
364
+ commands (``Word <offset> <word_idx>``), in contrast to QA files
365
+ which have explicit start/end times.
366
+
367
+ Returns a list of ``(start_time, end_time, external_pop_id)`` triples
368
+ in the same form as :func:`_parse_instruction_file`.
369
+ """
370
+ schedule: List[Tuple[int, int, str]] = []
371
+ word_list: List[str] = []
372
+ step_size = 0
373
+ fixed_time = 0
374
+
375
+ with open(filepath) as fh:
376
+ for raw_line in fh:
377
+ parts = raw_line.split()
378
+ if not parts or raw_line.startswith('#'):
379
+ continue
380
+
381
+ cmd = parts[0]
382
+
383
+ if cmd == 'fixed_time':
384
+ fixed_time = int(parts[1])
385
+ elif cmd == 'Num_of_words':
386
+ n_words = int(parts[1])
387
+ word_list = [f'EX1_input_W{k}' for k in range(n_words)]
388
+ elif cmd == 'Word':
389
+ start = step_size + int(parts[1])
390
+ step_size = start
391
+ end = start + fixed_time
392
+ word_idx = int(parts[2])
393
+ schedule.append((start, end, word_list[word_idx]))
394
+ elif cmd in _COMMAND_MAP:
395
+ start = step_size + int(parts[1])
396
+ end = start + fixed_time
397
+ schedule.append((start, end, _COMMAND_MAP[cmd]))
398
+
399
+ return schedule
400
+
401
+
402
+ def apply_sgall_instruction_schedule(
403
+ net: Network,
404
+ instruction_file: Union[str, Path],
405
+ amplitude: float = 100.0,
406
+ h: float = 0.1,
407
+ ) -> None:
408
+ """Set activation functions on external Inputs from an SGall sentence file.
409
+
410
+ Equivalent to :func:`apply_instruction_schedule` but handles the
411
+ SGall fixed-time instruction format (``fixed_time`` directive,
412
+ two-argument ``Word`` command).
413
+
414
+ Parameters
415
+ ----------
416
+ net : Network
417
+ A network previously created by :func:`load_architecture`.
418
+ instruction_file : str or Path
419
+ Path to an SGall sentence instruction file (e.g.
420
+ ``Sentence_2CV.txt``).
421
+ amplitude : float, optional
422
+ Activation level during scheduled ON windows (default 100.0).
423
+ h : float, optional
424
+ Integration step size in ms (default 0.1). Timestep indices in
425
+ the file are multiplied by *h* to convert to milliseconds.
426
+ """
427
+ instruction_file = Path(instruction_file)
428
+ if not instruction_file.exists():
429
+ raise FileNotFoundError(
430
+ f"Instruction file not found: {instruction_file}"
431
+ )
432
+
433
+ schedule = _parse_sgall_instruction_file(instruction_file)
434
+
435
+ windows: Dict[str, List[Tuple[float, float]]] = defaultdict(list)
436
+ for start, end, pop_id in schedule:
437
+ windows[pop_id].append((start * h, end * h))
438
+
439
+ for pop_id, wins in windows.items():
440
+ inp = net.get_input(pop_id)
441
+ inp.activation_func = _make_piecewise_activation(wins, amplitude)
442
+
443
+
444
+ # ======================================================================
445
+ # Public API: apply bindings
446
+ # ======================================================================
447
+
448
+ def _parse_bindings_file(filepath: Path) -> List[int]:
449
+ """Parse a WM bindings file, returning the list of p_index values."""
450
+ indices: List[int] = []
451
+ with open(filepath) as fh:
452
+ for raw_line in fh:
453
+ parts = raw_line.split()
454
+ if parts and parts[0].isdigit():
455
+ indices.append(int(parts[0]))
456
+ return indices
457
+
458
+
459
+ def apply_bindings(
460
+ net: Network,
461
+ bindings_file: Union[str, Path],
462
+ t_start: float,
463
+ t_end: float,
464
+ value: float,
465
+ ) -> None:
466
+ """Register clamps on WM binding populations from a bindings file.
467
+
468
+ Parameters
469
+ ----------
470
+ net : Network
471
+ A network previously created by :func:`load_architecture`.
472
+ bindings_file : str or Path
473
+ Path to a WM bindings file (e.g.
474
+ ``RNBA_SGall_SNB15_Bindings_Sentence_3NVN.txt``).
475
+ t_start : float
476
+ Start time of the clamp window.
477
+ t_end : float
478
+ End time of the clamp window.
479
+ value : float
480
+ Activation value to hold during the clamp window.
481
+ """
482
+ bindings_file = Path(bindings_file)
483
+ if not bindings_file.exists():
484
+ raise FileNotFoundError(
485
+ f"Bindings file not found: {bindings_file}"
486
+ )
487
+
488
+ indices = _parse_bindings_file(bindings_file)
489
+ for idx in indices:
490
+ # Node names in load_architecture are the stringified row index.
491
+ net.add_clamp(str(idx), t_start, t_end, value)