hypercube-esn 0.3.0__tar.gz

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,6 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ build/
4
+ dist/
5
+ *.pyd
6
+ *.so
@@ -0,0 +1,67 @@
1
+ cmake_minimum_required(VERSION 3.20)
2
+ project(HypercubeESNPython LANGUAGES CXX)
3
+
4
+ set(CMAKE_CXX_STANDARD 23)
5
+ set(CMAKE_CXX_STANDARD_REQUIRED ON)
6
+ set(CMAKE_POSITION_INDEPENDENT_CODE ON)
7
+
8
+ # ── pybind11 ──
9
+ find_package(pybind11 CONFIG REQUIRED)
10
+
11
+ # ── Optimization flags (match main project) ──
12
+ # HYPERCUBE_ARCH controls -march. Defaults to "native" for local dev builds.
13
+ # cibuildwheel overrides to "x86-64-v2" (x86_64) or "none" (ARM, MSVC).
14
+ set(HYPERCUBE_ARCH "native" CACHE STRING "Target architecture for -march (native, x86-64-v2, none)")
15
+
16
+ if(MSVC)
17
+ add_compile_options(/O2 /fp:fast)
18
+ else()
19
+ add_compile_options(-O3 -ffast-math)
20
+ if(NOT HYPERCUBE_ARCH STREQUAL "none")
21
+ if(HYPERCUBE_ARCH STREQUAL "native")
22
+ add_compile_options(-march=native -mtune=native)
23
+ else()
24
+ add_compile_options(-march=${HYPERCUBE_ARCH} -mtune=generic)
25
+ endif()
26
+ endif()
27
+ add_compile_options(-Wall -Wextra -Wno-unknown-pragmas)
28
+ endif()
29
+
30
+ # ── Core sources compiled directly into the module (PIC required) ──
31
+ set(CORE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/..")
32
+ set(CORE_SOURCES
33
+ ${CORE_DIR}/Reservoir.cpp
34
+ ${CORE_DIR}/ESN.cpp
35
+ ${CORE_DIR}/Readout.cpp
36
+ )
37
+
38
+ # ── HypercubeCNN dependency ──
39
+ # Vendored read-only snapshot at ../third_party/HypercubeCNN (see VENDORED.md).
40
+ # No sibling checkout, no pre-built .a, no network fetch — offline & version-pinned.
41
+ # third_party/ lies outside this source tree, so a binary dir arg is required.
42
+ add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../third_party/HypercubeCNN
43
+ ${CMAKE_BINARY_DIR}/HypercubeCNN-build)
44
+ set(HCNN_LIBS HypercubeCNNCore)
45
+
46
+ # ── Build the Python extension module ──
47
+ # HypercubeCNNCore's PUBLIC include dir propagates through the link below, so no
48
+ # explicit HCNN include path is needed here.
49
+ pybind11_add_module(_core bindings.cpp ${CORE_SOURCES})
50
+ target_include_directories(_core PRIVATE ${CORE_DIR})
51
+ target_link_libraries(_core PRIVATE ${HCNN_LIBS})
52
+
53
+ # ── Linking ──
54
+ if(MINGW)
55
+ # Statically link MinGW runtime DLLs so the .pyd is self-contained.
56
+ target_link_options(_core PRIVATE -static-libgcc -static-libstdc++)
57
+ target_link_libraries(_core PRIVATE -Wl,-Bstatic pthread winpthread -Wl,-Bdynamic)
58
+ # Posix-model MinGW always needs libwinpthread-1.dll at runtime.
59
+ # Ship it inside the package so the .pyd loads without MinGW on PATH.
60
+ find_file(WINPTHREAD_DLL libwinpthread-1.dll PATHS ENV PATH NO_DEFAULT_PATH)
61
+ if(WINPTHREAD_DLL)
62
+ install(FILES ${WINPTHREAD_DLL} DESTINATION hypercube_esn)
63
+ endif()
64
+ endif()
65
+
66
+ # ── Install into the hypercube_esn package directory ──
67
+ install(TARGETS _core DESTINATION hypercube_esn)
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: hypercube-esn
3
+ Version: 0.3.0
4
+ Summary: Python bindings for HypercubeESN: reservoir computing on Boolean hypercube graphs
5
+ License-Expression: Apache-2.0
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Intended Audience :: Science/Research
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: C++
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Classifier: Operating System :: Microsoft :: Windows
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Operating System :: MacOS
18
+ Project-URL: Homepage, https://github.com/dliptak001/HypercubeESN
19
+ Project-URL: Repository, https://github.com/dliptak001/HypercubeESN
20
+ Project-URL: Documentation, https://github.com/dliptak001/HypercubeESN/blob/main/docs/Python_SDK.md
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: numpy>=1.21
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest>=7.0; extra == "test"
25
+ Description-Content-Type: text/markdown
26
+
27
+ # HypercubeESN
28
+
29
+ [![Build wheels](https://github.com/dliptak001/HypercubeESN/actions/workflows/wheels.yml/badge.svg)](https://github.com/dliptak001/HypercubeESN/actions/workflows/wheels.yml)
30
+
31
+ Python bindings for a reservoir computer whose neurons live on a Boolean hypercube — a
32
+ DIM-dimensional graph where each vertex is addressed by a DIM-bit binary
33
+ index, with all connectivity defined by XOR operations on those indices.
34
+ **Neuron states are continuous real values** (driven through `tanh`
35
+ nonlinearity); only the *addressing scheme* is binary. No adjacency list
36
+ is stored. N = 2^DIM neurons (DIM 5-16, i.e. 32 to 65,536 neurons).
37
+ DIM-invariant hyperparameters: the same spectral radius and input_scaling
38
+ work at every DIM.
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ pip install hypercube-esn
44
+ ```
45
+
46
+ Pre-built wheels for Python 3.10-3.13 on Windows (x64), Linux (x86_64,
47
+ aarch64), and macOS (x86_64, arm64). No compiler required.
48
+
49
+ ## Quick Start
50
+
51
+ ```python
52
+ import numpy as np
53
+ import hypercube_esn as he
54
+
55
+ # One-step-ahead sine prediction
56
+ signal = np.sin(np.linspace(0, 20 * np.pi, 2000)).astype(np.float32)
57
+ esn = he.ESN(dim=7)
58
+ esn.fit(signal, warmup=200)
59
+ print(f"R2 = {esn.r2():.6f}")
60
+ print(f"NRMSE = {esn.nrmse():.6f}")
61
+ ```
62
+
63
+ ## Features
64
+
65
+ - **Simple API** -- `fit()` handles warmup, run, and train in one call
66
+ - **DIM 5-16** -- 32 to 65,536 neurons, DIM-invariant defaults
67
+ - **HCNN readout** -- learned convolutional readout on raw reservoir state
68
+ - **Multi-input** -- multiple input channels via contiguous-block driving
69
+ - **Streaming mode** -- online training for real-time applications
70
+ - **Model persistence** -- pickle, save/load to disk
71
+
72
+ ## Documentation
73
+
74
+ Full API reference: [docs/Python_SDK.md](https://github.com/dliptak001/HypercubeESN/blob/main/docs/Python_SDK.md)
75
+
76
+ Project repository: [github.com/dliptak001/HypercubeESN](https://github.com/dliptak001/HypercubeESN)
@@ -0,0 +1,50 @@
1
+ # HypercubeESN
2
+
3
+ [![Build wheels](https://github.com/dliptak001/HypercubeESN/actions/workflows/wheels.yml/badge.svg)](https://github.com/dliptak001/HypercubeESN/actions/workflows/wheels.yml)
4
+
5
+ Python bindings for a reservoir computer whose neurons live on a Boolean hypercube — a
6
+ DIM-dimensional graph where each vertex is addressed by a DIM-bit binary
7
+ index, with all connectivity defined by XOR operations on those indices.
8
+ **Neuron states are continuous real values** (driven through `tanh`
9
+ nonlinearity); only the *addressing scheme* is binary. No adjacency list
10
+ is stored. N = 2^DIM neurons (DIM 5-16, i.e. 32 to 65,536 neurons).
11
+ DIM-invariant hyperparameters: the same spectral radius and input_scaling
12
+ work at every DIM.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install hypercube-esn
18
+ ```
19
+
20
+ Pre-built wheels for Python 3.10-3.13 on Windows (x64), Linux (x86_64,
21
+ aarch64), and macOS (x86_64, arm64). No compiler required.
22
+
23
+ ## Quick Start
24
+
25
+ ```python
26
+ import numpy as np
27
+ import hypercube_esn as he
28
+
29
+ # One-step-ahead sine prediction
30
+ signal = np.sin(np.linspace(0, 20 * np.pi, 2000)).astype(np.float32)
31
+ esn = he.ESN(dim=7)
32
+ esn.fit(signal, warmup=200)
33
+ print(f"R2 = {esn.r2():.6f}")
34
+ print(f"NRMSE = {esn.nrmse():.6f}")
35
+ ```
36
+
37
+ ## Features
38
+
39
+ - **Simple API** -- `fit()` handles warmup, run, and train in one call
40
+ - **DIM 5-16** -- 32 to 65,536 neurons, DIM-invariant defaults
41
+ - **HCNN readout** -- learned convolutional readout on raw reservoir state
42
+ - **Multi-input** -- multiple input channels via contiguous-block driving
43
+ - **Streaming mode** -- online training for real-time applications
44
+ - **Model persistence** -- pickle, save/load to disk
45
+
46
+ ## Documentation
47
+
48
+ Full API reference: [docs/Python_SDK.md](https://github.com/dliptak001/HypercubeESN/blob/main/docs/Python_SDK.md)
49
+
50
+ Project repository: [github.com/dliptak001/HypercubeESN](https://github.com/dliptak001/HypercubeESN)
@@ -0,0 +1,302 @@
1
+ #include <pybind11/pybind11.h>
2
+ #include <pybind11/numpy.h>
3
+ #include <pybind11/stl.h>
4
+ #include <cstring>
5
+ #include <memory>
6
+ #include "../ESN.h"
7
+
8
+ namespace py = pybind11;
9
+
10
+ // Single de-templated ESN binding. The hypercube dimension is a runtime
11
+ // constructor argument (cfg.reservoir.dim), so one C++ type and one Python
12
+ // class serve every dimension 5-16 — no per-DIM instantiations.
13
+ PYBIND11_MODULE(_core, m)
14
+ {
15
+ m.doc() = "HypercubeESN: reservoir computing on Boolean hypercube graphs";
16
+
17
+ py::class_<ESN>(m, "_ESN")
18
+ // ── Construction ──
19
+ // All reservoir + readout parameters fixed at construction time.
20
+ // The readout config is consumed by train() / init_online() — no
21
+ // per-call config overrides.
22
+ .def(py::init([](size_t dim, uint64_t seed, float spectral_radius, float input_scaling,
23
+ float leak_rate, size_t num_inputs, size_t history_depth,
24
+ float output_fraction,
25
+ int readout_num_outputs, const char* readout_task,
26
+ int readout_num_layers, int readout_conv_channels,
27
+ int readout_epochs, int readout_batch_size,
28
+ float readout_lr_max, float readout_lr_min_frac,
29
+ int readout_lr_decay_epochs, float readout_weight_decay,
30
+ unsigned readout_seed, bool readout_verbose) {
31
+ ESNConfig cfg;
32
+ cfg.reservoir.dim = dim;
33
+ cfg.reservoir.seed = seed;
34
+ cfg.reservoir.spectral_radius = spectral_radius;
35
+ cfg.reservoir.input_scaling = input_scaling;
36
+ cfg.reservoir.leak_rate = leak_rate;
37
+ cfg.reservoir.num_inputs = num_inputs;
38
+ cfg.reservoir.history_depth = history_depth;
39
+ cfg.output_fraction = output_fraction;
40
+ cfg.readout.num_outputs = readout_num_outputs;
41
+ cfg.readout.task = (std::strcmp(readout_task, "classification") == 0)
42
+ ? ReadoutTask::Classification
43
+ : ReadoutTask::Regression;
44
+ cfg.readout.num_layers = readout_num_layers;
45
+ cfg.readout.conv_channels = readout_conv_channels;
46
+ cfg.readout.epochs = readout_epochs;
47
+ cfg.readout.batch_size = readout_batch_size;
48
+ cfg.readout.lr_max = readout_lr_max;
49
+ cfg.readout.lr_min_frac = readout_lr_min_frac;
50
+ cfg.readout.lr_decay_epochs = readout_lr_decay_epochs;
51
+ cfg.readout.weight_decay = readout_weight_decay;
52
+ cfg.readout.seed = readout_seed;
53
+ cfg.readout.verbose = readout_verbose;
54
+ return std::make_unique<ESN>(cfg);
55
+ }),
56
+ py::arg("dim"),
57
+ py::arg("seed") = 73895ULL,
58
+ py::arg("spectral_radius") = 0.99f,
59
+ py::arg("input_scaling") = 0.5f,
60
+ py::arg("leak_rate") = 1.0f,
61
+ py::arg("num_inputs") = 1ULL,
62
+ py::arg("history_depth") = 16ULL,
63
+ py::arg("output_fraction") = 1.0f,
64
+ py::arg("readout_num_outputs") = 1,
65
+ py::arg("readout_task") = "regression",
66
+ py::arg("readout_num_layers") = 0,
67
+ py::arg("readout_conv_channels") = 16,
68
+ py::arg("readout_epochs") = 200,
69
+ py::arg("readout_batch_size") = 32,
70
+ py::arg("readout_lr_max") = 0.005f,
71
+ py::arg("readout_lr_min_frac") = 0.1f,
72
+ py::arg("readout_lr_decay_epochs") = 0,
73
+ py::arg("readout_weight_decay") = 0.0f,
74
+ py::arg("readout_seed") = 42u,
75
+ py::arg("readout_verbose") = false)
76
+
77
+ // ── Reservoir driving ──
78
+ .def("warmup", [](ESN& self, py::array_t<float, py::array::c_style | py::array::forcecast> inputs) {
79
+ auto buf = inputs.request();
80
+ size_t total = static_cast<size_t>(buf.size);
81
+ size_t K = self.NumInputs();
82
+ if (total % K != 0)
83
+ throw std::invalid_argument("Input size must be divisible by num_inputs");
84
+ self.Warmup(static_cast<const float*>(buf.ptr), total / K);
85
+ }, py::arg("inputs"),
86
+ "Drive the reservoir without recording states (wash out initial transient).")
87
+
88
+ .def("run", [](ESN& self, py::array_t<float, py::array::c_style | py::array::forcecast> inputs) {
89
+ auto buf = inputs.request();
90
+ size_t total = static_cast<size_t>(buf.size);
91
+ size_t K = self.NumInputs();
92
+ if (total % K != 0)
93
+ throw std::invalid_argument("Input size must be divisible by num_inputs");
94
+ self.Run(static_cast<const float*>(buf.ptr), total / K);
95
+ }, py::arg("inputs"),
96
+ "Drive the reservoir and record states for training/evaluation.")
97
+
98
+ .def("clear_states", &ESN::ClearStates,
99
+ "Clear collected states and cached features. Keeps trained readout.")
100
+
101
+ .def("reset_reservoir_only", &ESN::ResetReservoirOnly,
102
+ "Zero only the reservoir state; collected states preserved.")
103
+
104
+ // ── Batch training ──
105
+ .def("train", [](ESN& self,
106
+ py::array_t<float, py::array::c_style | py::array::forcecast> targets) {
107
+ auto buf = targets.request();
108
+ size_t n = static_cast<size_t>(buf.size);
109
+ const float* ptr = static_cast<const float*>(buf.ptr);
110
+
111
+ if (n > self.NumCollected())
112
+ throw std::invalid_argument(
113
+ "train_size (" + std::to_string(n) +
114
+ ") exceeds num_collected (" + std::to_string(self.NumCollected()) + ")");
115
+
116
+ self.Train(ptr, n);
117
+ },
118
+ py::arg("targets"),
119
+ "Train the HCNN readout on collected states.\n"
120
+ "Uses the readout config supplied at ESN construction.")
121
+
122
+ // ── Online (streaming) HCNN training ──
123
+ .def("init_online", [](ESN& self,
124
+ py::array_t<float, py::array::c_style | py::array::forcecast> warmup_inputs) {
125
+ auto buf = warmup_inputs.request();
126
+ size_t total = static_cast<size_t>(buf.size);
127
+ size_t K = self.NumInputs();
128
+ if (total % K != 0)
129
+ throw std::invalid_argument("warmup_inputs size must be divisible by num_inputs");
130
+ self.InitOnline(static_cast<const float*>(buf.ptr), total / K);
131
+ },
132
+ py::arg("warmup_inputs"),
133
+ "Initialize HCNN for online (streaming) training.\n\n"
134
+ "Runs warmup_inputs through reservoir to reach a representative state,\n"
135
+ "then builds CNN architecture. Uses the readout config supplied at construction.\n"
136
+ "Call before train_live_step/train_live_batch.")
137
+
138
+ .def("train_live_step", [](ESN& self, float target_class, float lr, float weight_decay) {
139
+ self.TrainLiveStep(target_class, lr, weight_decay);
140
+ },
141
+ py::arg("target_class"), py::arg("lr"), py::arg("weight_decay") = 0.0f,
142
+ "Single-step online classification training on the live reservoir state.")
143
+
144
+ .def("train_live_batch", [](ESN& self,
145
+ py::array_t<float, py::array::c_style | py::array::forcecast> states,
146
+ py::array_t<int, py::array::c_style | py::array::forcecast> targets,
147
+ float lr, float weight_decay) {
148
+ auto sbuf = states.request();
149
+ auto tbuf = targets.request();
150
+ size_t count = static_cast<size_t>(tbuf.size);
151
+ self.TrainLiveBatch(static_cast<const float*>(sbuf.ptr),
152
+ static_cast<const int*>(tbuf.ptr),
153
+ count, lr, weight_decay);
154
+ },
155
+ py::arg("states"), py::arg("targets"),
156
+ py::arg("lr"), py::arg("weight_decay") = 0.0f,
157
+ "Mini-batch online classification training on pre-accumulated states.\n"
158
+ "states: (count, num_output_verts) float array from copy_live_state.\n"
159
+ "targets: (count,) int array of class indices.")
160
+
161
+ .def("train_live_step_regression", [](ESN& self,
162
+ py::array_t<float, py::array::c_style | py::array::forcecast> target,
163
+ float lr, float weight_decay) {
164
+ auto buf = target.request();
165
+ self.TrainLiveStepRegression(static_cast<const float*>(buf.ptr), lr, weight_decay);
166
+ },
167
+ py::arg("target"), py::arg("lr"), py::arg("weight_decay") = 0.0f,
168
+ "Single-step online regression training on the live reservoir state.\n"
169
+ "target: (num_outputs,) float array.")
170
+
171
+ .def("train_live_batch_regression", [](ESN& self,
172
+ py::array_t<float, py::array::c_style | py::array::forcecast> states,
173
+ py::array_t<float, py::array::c_style | py::array::forcecast> targets,
174
+ float lr, float weight_decay) {
175
+ auto sbuf = states.request();
176
+ auto tbuf = targets.request();
177
+ size_t K = self.NumOutputs();
178
+ size_t count = static_cast<size_t>(tbuf.size) / K;
179
+ self.TrainLiveBatchRegression(static_cast<const float*>(sbuf.ptr),
180
+ static_cast<const float*>(tbuf.ptr),
181
+ count, lr, weight_decay);
182
+ },
183
+ py::arg("states"), py::arg("targets"),
184
+ py::arg("lr"), py::arg("weight_decay") = 0.0f,
185
+ "Mini-batch online regression training on pre-accumulated states.\n"
186
+ "states: (count, num_output_verts) float array from copy_live_state.\n"
187
+ "targets: (count, num_outputs) float array.")
188
+
189
+ .def("copy_live_state", [](const ESN& self) {
190
+ size_t M = self.NumOutputVerts();
191
+ py::array_t<float> arr(M);
192
+ self.CopyLiveState(arr.mutable_data());
193
+ return arr;
194
+ }, "Copy the current subsampled reservoir state for external accumulation.\n"
195
+ "Returns a (num_output_verts,) float array.")
196
+
197
+ // ── Prediction & evaluation ──
198
+ .def("predict_raw", [](const ESN& self, size_t timestep) {
199
+ if (timestep >= self.NumCollected())
200
+ throw std::out_of_range(
201
+ "timestep (" + std::to_string(timestep) +
202
+ ") >= num_collected (" + std::to_string(self.NumCollected()) + ")");
203
+ return self.PredictRaw(timestep);
204
+ }, py::arg("timestep"),
205
+ "Return the raw continuous prediction for a collected timestep.")
206
+
207
+ .def("predict_live_raw", [](const ESN& self) {
208
+ return self.PredictLiveRaw();
209
+ }, "Predict from the reservoir's current live state (no cached states needed).\n"
210
+ "For autoregressive / streaming inference loops.")
211
+
212
+ .def("predict_live_raw_multi", [](const ESN& self) {
213
+ size_t K = self.NumOutputs();
214
+ py::array_t<float> arr(K);
215
+ self.PredictLiveRaw(arr.mutable_data());
216
+ return arr;
217
+ }, "Multi-output live predict: returns (num_outputs,) float array.")
218
+
219
+ .def("r2", [](const ESN& self,
220
+ py::array_t<float, py::array::c_style | py::array::forcecast> targets,
221
+ size_t start, size_t count) {
222
+ if (start + count > self.NumCollected())
223
+ throw std::out_of_range(
224
+ "start + count (" + std::to_string(start + count) +
225
+ ") > num_collected (" + std::to_string(self.NumCollected()) + ")");
226
+ return self.R2(static_cast<const float*>(targets.request().ptr), start, count);
227
+ }, py::arg("targets"), py::arg("start"), py::arg("count"),
228
+ "Compute R-squared on a slice of collected states.")
229
+
230
+ .def("nrmse", [](const ESN& self,
231
+ py::array_t<float, py::array::c_style | py::array::forcecast> targets,
232
+ size_t start, size_t count) {
233
+ if (start + count > self.NumCollected())
234
+ throw std::out_of_range(
235
+ "start + count (" + std::to_string(start + count) +
236
+ ") > num_collected (" + std::to_string(self.NumCollected()) + ")");
237
+ return self.NRMSE(static_cast<const float*>(targets.request().ptr), start, count);
238
+ }, py::arg("targets"), py::arg("start"), py::arg("count"),
239
+ "Compute Normalized RMSE on a slice of collected states.")
240
+
241
+ .def("accuracy", [](const ESN& self,
242
+ py::array_t<float, py::array::c_style | py::array::forcecast> labels,
243
+ size_t start, size_t count) {
244
+ if (start + count > self.NumCollected())
245
+ throw std::out_of_range(
246
+ "start + count (" + std::to_string(start + count) +
247
+ ") > num_collected (" + std::to_string(self.NumCollected()) + ")");
248
+ return self.Accuracy(static_cast<const float*>(labels.request().ptr), start, count);
249
+ }, py::arg("labels"), py::arg("start"), py::arg("count"),
250
+ "Compute classification accuracy on a slice of collected states.")
251
+
252
+ // ── State access ──
253
+ .def("selected_states", [](const ESN& self) {
254
+ auto vec = self.SelectedStates();
255
+ size_t M = self.NumOutputVerts();
256
+ size_t T = self.NumCollected();
257
+ py::array_t<float> arr({T, M});
258
+ memcpy(arr.mutable_data(), vec.data(), vec.size() * sizeof(float));
259
+ return arr;
260
+ }, "Return stride-selected states as a (num_collected, M) array.")
261
+
262
+ .def("predictions", [](const ESN& self) {
263
+ size_t T = self.NumCollected();
264
+ py::array_t<float> arr(T);
265
+ float* ptr = arr.mutable_data();
266
+ for (size_t t = 0; t < T; ++t)
267
+ ptr[t] = self.PredictRaw(t);
268
+ return arr;
269
+ }, "Return predictions for all collected timesteps as a 1D array.")
270
+
271
+ // ── Properties ──
272
+ .def_property_readonly("num_collected", &ESN::NumCollected)
273
+ .def_property_readonly("num_outputs", &ESN::NumOutputs)
274
+ .def_property_readonly("output_fraction", [](const ESN& self) { return self.GetConfig().output_fraction; })
275
+ .def_property_readonly("num_output_verts", &ESN::NumOutputVerts)
276
+ .def_property_readonly("dim", &ESN::Dim)
277
+ .def_property_readonly("N", &ESN::Size)
278
+ .def_property_readonly("num_inputs", &ESN::NumInputs)
279
+ .def_property_readonly("history_depth", [](const ESN& self) { return self.GetConfig().reservoir.history_depth; })
280
+ .def_property_readonly("seed", [](const ESN& self) { return self.GetConfig().reservoir.seed; })
281
+ .def_property_readonly("spectral_radius", [](const ESN& self) { return self.GetConfig().reservoir.spectral_radius; })
282
+ .def_property_readonly("leak_rate", [](const ESN& self) { return self.GetConfig().reservoir.leak_rate; })
283
+ .def_property_readonly("input_scaling", [](const ESN& self) { return self.GetConfig().reservoir.input_scaling; })
284
+
285
+ // ── Persistence ──
286
+ .def("_get_readout_state", [](const ESN& self) -> py::dict {
287
+ auto s = self.GetReadoutState();
288
+ py::dict d;
289
+ d["is_trained"] = s.is_trained;
290
+ d["weights"] = py::array_t<double>(
291
+ {static_cast<py::ssize_t>(s.weights.size())}, s.weights.data());
292
+ return d;
293
+ })
294
+ .def("_set_readout_state", [](ESN& self, py::dict d) {
295
+ ESN::ReadoutState s;
296
+ s.is_trained = d["is_trained"].cast<bool>();
297
+ auto w = d["weights"].cast<py::array_t<double, py::array::c_style | py::array::forcecast>>();
298
+ s.weights.assign(w.data(), w.data() + w.size());
299
+ self.SetReadoutState(s);
300
+ })
301
+ ;
302
+ }