spikeforge-io 0.1.0__py3-none-any.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.
- spikeforge_io/__init__.py +88 -0
- spikeforge_io/__main__.py +6 -0
- spikeforge_io/adapters.py +320 -0
- spikeforge_io/cli.py +135 -0
- spikeforge_io/errors.py +72 -0
- spikeforge_io/py.typed +0 -0
- spikeforge_io/replay.py +146 -0
- spikeforge_io/windowing.py +71 -0
- spikeforge_io-0.1.0.dist-info/METADATA +126 -0
- spikeforge_io-0.1.0.dist-info/RECORD +14 -0
- spikeforge_io-0.1.0.dist-info/WHEEL +5 -0
- spikeforge_io-0.1.0.dist-info/entry_points.txt +2 -0
- spikeforge_io-0.1.0.dist-info/licenses/LICENSE +30 -0
- spikeforge_io-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""``spikeforge-io``: dependency-light I/O adapters and the windowing surface.
|
|
2
|
+
|
|
3
|
+
PT-W7's second deliverable. The distribution is deliberately thin: it adapts a
|
|
4
|
+
recorded numeric source (CSV, JSON, NPY, in-memory, or any object implementing
|
|
5
|
+
the dataset hook) into the core frozen window/normalize/encode contract, and
|
|
6
|
+
replays it into ``spikeforge-serve``'s ``/v1/stream``.
|
|
7
|
+
|
|
8
|
+
The windowing *implementation* stays in core
|
|
9
|
+
(:mod:`spikeforge.streaming.window_spec`) because core must never depend on a
|
|
10
|
+
satellite; :mod:`spikeforge_io.windowing` re-exports that one canonical
|
|
11
|
+
contract. No pandas, no fastapi, no client SDK is imported here.
|
|
12
|
+
|
|
13
|
+
from spikeforge_io import CsvAdapter, replay_to_stream
|
|
14
|
+
|
|
15
|
+
adapter = CsvAdapter("recording.csv")
|
|
16
|
+
frames = adapter.stream() # [N, D]
|
|
17
|
+
windows = adapter.windows(spec) # [W, L, D], z-scored by the frozen spec
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from spikeforge_io.adapters import (
|
|
21
|
+
DATASET_METHODS,
|
|
22
|
+
FACTORIES,
|
|
23
|
+
CsvAdapter,
|
|
24
|
+
DatasetAdapter,
|
|
25
|
+
InMemoryAdapter,
|
|
26
|
+
JsonAdapter,
|
|
27
|
+
NpyAdapter,
|
|
28
|
+
StreamAdapter,
|
|
29
|
+
adapter_for,
|
|
30
|
+
as_stream,
|
|
31
|
+
)
|
|
32
|
+
from spikeforge_io.errors import (
|
|
33
|
+
AdapterDependencyError,
|
|
34
|
+
AdapterError,
|
|
35
|
+
AdapterFormatError,
|
|
36
|
+
AdapterShapeError,
|
|
37
|
+
AdapterUnsupportedError,
|
|
38
|
+
IoError,
|
|
39
|
+
)
|
|
40
|
+
from spikeforge_io.replay import (
|
|
41
|
+
ReplayReport,
|
|
42
|
+
replay,
|
|
43
|
+
replay_to_stream,
|
|
44
|
+
stream_messages,
|
|
45
|
+
)
|
|
46
|
+
from spikeforge_io.windowing import (
|
|
47
|
+
WINDOW_SPEC_VERSION,
|
|
48
|
+
WindowSpec,
|
|
49
|
+
delta_over_window,
|
|
50
|
+
encode_windows,
|
|
51
|
+
fit_window_spec,
|
|
52
|
+
normalize_windows,
|
|
53
|
+
window_spec_from_bundle,
|
|
54
|
+
window_stream,
|
|
55
|
+
windows,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"DATASET_METHODS",
|
|
60
|
+
"FACTORIES",
|
|
61
|
+
"WINDOW_SPEC_VERSION",
|
|
62
|
+
"AdapterDependencyError",
|
|
63
|
+
"AdapterError",
|
|
64
|
+
"AdapterFormatError",
|
|
65
|
+
"AdapterShapeError",
|
|
66
|
+
"AdapterUnsupportedError",
|
|
67
|
+
"CsvAdapter",
|
|
68
|
+
"DatasetAdapter",
|
|
69
|
+
"InMemoryAdapter",
|
|
70
|
+
"IoError",
|
|
71
|
+
"JsonAdapter",
|
|
72
|
+
"NpyAdapter",
|
|
73
|
+
"ReplayReport",
|
|
74
|
+
"StreamAdapter",
|
|
75
|
+
"WindowSpec",
|
|
76
|
+
"adapter_for",
|
|
77
|
+
"as_stream",
|
|
78
|
+
"delta_over_window",
|
|
79
|
+
"encode_windows",
|
|
80
|
+
"fit_window_spec",
|
|
81
|
+
"normalize_windows",
|
|
82
|
+
"replay",
|
|
83
|
+
"replay_to_stream",
|
|
84
|
+
"stream_messages",
|
|
85
|
+
"window_spec_from_bundle",
|
|
86
|
+
"window_stream",
|
|
87
|
+
"windows",
|
|
88
|
+
]
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Dependency-light adapters for ingesting numeric streams into the contract.
|
|
2
|
+
|
|
3
|
+
Every adapter answers one question — "what is this recorded source, as an
|
|
4
|
+
``[N, D]`` numeric stream?" — and nothing more. Windowing, normalization, and
|
|
5
|
+
encoding are then the shared core contract (see
|
|
6
|
+
:mod:`spikeforge_io.windowing`), so an adapter can never invent its own
|
|
7
|
+
preprocessing. Only the standard library and torch are used for CSV/JSON and
|
|
8
|
+
in-memory data; numpy is imported lazily and only for ``.npy``, and its absence
|
|
9
|
+
is a typed :class:`~spikeforge_io.errors.AdapterDependencyError` rather than an
|
|
10
|
+
``ImportError`` at the call site.
|
|
11
|
+
|
|
12
|
+
The dataset-adapter **hook** (:class:`DatasetAdapter`) is deliberately
|
|
13
|
+
duck-typed: any object exposing ``to_tensor()``/``array()`` or the sequence
|
|
14
|
+
protocol (``__len__`` + ``__getitem__``) adapts, so a future dataset lib can
|
|
15
|
+
join without this module depending on it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import csv
|
|
19
|
+
import json
|
|
20
|
+
from abc import ABC, abstractmethod
|
|
21
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
22
|
+
|
|
23
|
+
import torch
|
|
24
|
+
|
|
25
|
+
from spikeforge_io.errors import (
|
|
26
|
+
AdapterDependencyError,
|
|
27
|
+
AdapterFormatError,
|
|
28
|
+
AdapterShapeError,
|
|
29
|
+
AdapterUnsupportedError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
#: Methods the dataset-adapter hook recognises, in probing order.
|
|
33
|
+
DATASET_METHODS: Tuple[str, ...] = ("to_tensor", "array", "numpy")
|
|
34
|
+
|
|
35
|
+
#: Keys a JSON object may use to carry the stream payload.
|
|
36
|
+
JSON_KEYS: Tuple[str, ...] = ("data", "stream", "values")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def as_stream(payload: Any, source: str) -> torch.Tensor:
|
|
40
|
+
"""Return ``payload`` as an ``[N, D]`` float32 tensor, or a typed error.
|
|
41
|
+
|
|
42
|
+
A 1-D payload is promoted to a single channel; a 2-D payload is taken as
|
|
43
|
+
``[N, D]``; anything else is refused with
|
|
44
|
+
:class:`~spikeforge_io.errors.AdapterShapeError`. A non-numeric payload is
|
|
45
|
+
a :class:`~spikeforge_io.errors.AdapterFormatError`.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
tensor = torch.as_tensor(payload, dtype=torch.float32)
|
|
49
|
+
except (TypeError, ValueError) as error:
|
|
50
|
+
raise AdapterFormatError(
|
|
51
|
+
source, f"stream is not numeric: {error}"
|
|
52
|
+
) from None
|
|
53
|
+
if tensor.dim() == 0:
|
|
54
|
+
return tensor.reshape(1, 1)
|
|
55
|
+
if tensor.dim() == 1:
|
|
56
|
+
return tensor.unsqueeze(-1)
|
|
57
|
+
if tensor.dim() != 2:
|
|
58
|
+
raise AdapterShapeError(
|
|
59
|
+
source, f"a stream must be 1-D or 2-D, got {tuple(tensor.shape)}"
|
|
60
|
+
)
|
|
61
|
+
return tensor
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class StreamAdapter(ABC):
|
|
65
|
+
"""Base class for every recorded-stream adapter.
|
|
66
|
+
|
|
67
|
+
Subclasses implement :meth:`stream`; :meth:`windows` and :meth:`encoded`
|
|
68
|
+
are provided here so windowing/encoding always flow through the one core
|
|
69
|
+
contract.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
#: Human-readable source label used in error messages.
|
|
73
|
+
source: str = "<adapter>"
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def stream(self) -> torch.Tensor:
|
|
77
|
+
"""Return the source as a ``[N, D]`` float32 tensor."""
|
|
78
|
+
|
|
79
|
+
def windows(self, spec: Any) -> torch.Tensor:
|
|
80
|
+
"""Return the z-scored ``[W, L, D]`` windows of :meth:`stream`."""
|
|
81
|
+
spec.validate()
|
|
82
|
+
return spec.windows(self.stream())
|
|
83
|
+
|
|
84
|
+
def encoded(
|
|
85
|
+
self, spec: Any, encode_spec: Any, **kwargs: Any
|
|
86
|
+
) -> torch.Tensor:
|
|
87
|
+
"""Return windows encoded with the shared window->spike contract."""
|
|
88
|
+
from spikeforge_io.windowing import encode_windows
|
|
89
|
+
|
|
90
|
+
return encode_windows(self.windows(spec), encode_spec, **kwargs)
|
|
91
|
+
|
|
92
|
+
def __len__(self) -> int:
|
|
93
|
+
"""Return the number of samples in the stream."""
|
|
94
|
+
return int(self.stream().size(0))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class InMemoryAdapter(StreamAdapter):
|
|
98
|
+
"""Adapt an already-materialized payload (tensor, nested list, ndarray)."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, payload: Any, source: str = "<memory>") -> None:
|
|
101
|
+
"""Wrap ``payload`` and label it with ``source``."""
|
|
102
|
+
self._payload = payload
|
|
103
|
+
self.source = source
|
|
104
|
+
|
|
105
|
+
def stream(self) -> torch.Tensor:
|
|
106
|
+
"""Return the payload as a ``[N, D]`` tensor."""
|
|
107
|
+
return as_stream(self._payload, self.source)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _read_rows(path: str, delimiter: str) -> List[List[str]]:
|
|
111
|
+
"""Read a CSV file into raw rows, or raise a typed format error."""
|
|
112
|
+
try:
|
|
113
|
+
with open(path, newline="", encoding="utf-8") as handle:
|
|
114
|
+
return list(csv.reader(handle, delimiter=delimiter))
|
|
115
|
+
except OSError as error:
|
|
116
|
+
raise AdapterFormatError(path, f"cannot read: {error}") from None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _parse_csv_row(path: str, index: int, row: Sequence[str]) -> List[float]:
|
|
120
|
+
"""Parse one CSV row into floats, naming the first bad field."""
|
|
121
|
+
values: List[float] = []
|
|
122
|
+
for column, cell in enumerate(row):
|
|
123
|
+
text = cell.strip()
|
|
124
|
+
if not text:
|
|
125
|
+
continue
|
|
126
|
+
try:
|
|
127
|
+
values.append(float(text))
|
|
128
|
+
except ValueError:
|
|
129
|
+
raise AdapterFormatError(
|
|
130
|
+
path,
|
|
131
|
+
f"row {index} column {column} is not numeric: {cell!r}",
|
|
132
|
+
position=index,
|
|
133
|
+
) from None
|
|
134
|
+
if not values:
|
|
135
|
+
raise AdapterFormatError(
|
|
136
|
+
path, f"row {index} has no numeric fields", position=index
|
|
137
|
+
)
|
|
138
|
+
return values
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class CsvAdapter(StreamAdapter):
|
|
142
|
+
"""Read a ``.csv`` file of numeric rows into an ``[N, D]`` stream."""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
path: str,
|
|
147
|
+
*,
|
|
148
|
+
delimiter: str = ",",
|
|
149
|
+
has_header: bool = True,
|
|
150
|
+
channels: Optional[int] = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Record the ``path``, delimiter, header flag, and expected width."""
|
|
153
|
+
self.source = path
|
|
154
|
+
self._delimiter = delimiter
|
|
155
|
+
self._has_header = has_header
|
|
156
|
+
self._channels = channels
|
|
157
|
+
|
|
158
|
+
def stream(self) -> torch.Tensor:
|
|
159
|
+
"""Return the CSV's numeric rows as a ``[N, D]`` tensor."""
|
|
160
|
+
rows = _read_rows(self.source, self._delimiter)
|
|
161
|
+
if self._has_header and rows:
|
|
162
|
+
rows = rows[1:]
|
|
163
|
+
if not rows:
|
|
164
|
+
raise AdapterShapeError(self.source, "CSV has no data rows")
|
|
165
|
+
parsed = [
|
|
166
|
+
_parse_csv_row(self.source, index, row)
|
|
167
|
+
for index, row in enumerate(rows)
|
|
168
|
+
]
|
|
169
|
+
width = len(parsed[0])
|
|
170
|
+
for index, row in enumerate(parsed):
|
|
171
|
+
if len(row) != width:
|
|
172
|
+
raise AdapterFormatError(
|
|
173
|
+
self.source,
|
|
174
|
+
f"row {index} has {len(row)} fields, expected {width}",
|
|
175
|
+
position=index,
|
|
176
|
+
)
|
|
177
|
+
if self._channels is not None and width != self._channels:
|
|
178
|
+
raise AdapterShapeError(
|
|
179
|
+
self.source,
|
|
180
|
+
f"expected {self._channels} channels, got {width}",
|
|
181
|
+
)
|
|
182
|
+
return as_stream(parsed, self.source)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _json_payload(payload: Any, path: str) -> Any:
|
|
186
|
+
"""Return the stream payload inside a JSON document to adapt."""
|
|
187
|
+
if isinstance(payload, dict):
|
|
188
|
+
for key in (JSON_KEYS + ("payload",)):
|
|
189
|
+
if key in payload:
|
|
190
|
+
return payload[key]
|
|
191
|
+
raise AdapterFormatError(
|
|
192
|
+
path, f"JSON object has no stream key ({', '.join(JSON_KEYS)})"
|
|
193
|
+
)
|
|
194
|
+
return payload
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class JsonAdapter(StreamAdapter):
|
|
198
|
+
"""Read a ``.json`` file (rows, scalars, or an object) as a stream."""
|
|
199
|
+
|
|
200
|
+
def __init__(self, path: str, *, key: Optional[str] = None) -> None:
|
|
201
|
+
"""Record the ``path`` and an optional explicit object ``key``."""
|
|
202
|
+
self.source = path
|
|
203
|
+
self._key = key
|
|
204
|
+
|
|
205
|
+
def _load(self) -> Any:
|
|
206
|
+
"""Return the raw JSON payload, or raise a typed format error."""
|
|
207
|
+
try:
|
|
208
|
+
with open(self.source, encoding="utf-8") as handle:
|
|
209
|
+
return json.load(handle)
|
|
210
|
+
except OSError as error:
|
|
211
|
+
raise AdapterFormatError(
|
|
212
|
+
self.source, f"cannot read: {error}"
|
|
213
|
+
) from None
|
|
214
|
+
except json.JSONDecodeError as error:
|
|
215
|
+
raise AdapterFormatError(
|
|
216
|
+
self.source, f"malformed JSON: {error}"
|
|
217
|
+
) from None
|
|
218
|
+
|
|
219
|
+
def stream(self) -> torch.Tensor:
|
|
220
|
+
"""Return the JSON stream as a ``[N, D]`` tensor."""
|
|
221
|
+
payload = self._load()
|
|
222
|
+
if self._key is not None:
|
|
223
|
+
if not isinstance(payload, dict) or self._key not in payload:
|
|
224
|
+
raise AdapterFormatError(
|
|
225
|
+
self.source, f"JSON object has no {self._key!r} key"
|
|
226
|
+
)
|
|
227
|
+
payload = payload[self._key]
|
|
228
|
+
else:
|
|
229
|
+
payload = _json_payload(payload, self.source)
|
|
230
|
+
try:
|
|
231
|
+
return as_stream(payload, self.source)
|
|
232
|
+
except AdapterFormatError as error:
|
|
233
|
+
raise AdapterFormatError(
|
|
234
|
+
self.source, f"JSON rows are not rectangular: {error.detail}"
|
|
235
|
+
) from None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class NpyAdapter(StreamAdapter):
|
|
239
|
+
"""Read a ``.npy`` array into an ``[N, D]`` stream via numpy."""
|
|
240
|
+
|
|
241
|
+
def __init__(self, path: str) -> None:
|
|
242
|
+
"""Record the ``path`` to the ``.npy`` file."""
|
|
243
|
+
self.source = path
|
|
244
|
+
|
|
245
|
+
def stream(self) -> torch.Tensor:
|
|
246
|
+
"""Return the array as a ``[N, D]`` tensor, or report numpy absent."""
|
|
247
|
+
try:
|
|
248
|
+
import numpy as np
|
|
249
|
+
except ImportError:
|
|
250
|
+
raise AdapterDependencyError("numpy", "a .npy stream") from None
|
|
251
|
+
try:
|
|
252
|
+
payload = np.load(self.source, allow_pickle=False)
|
|
253
|
+
except (OSError, ValueError) as error:
|
|
254
|
+
raise AdapterFormatError(
|
|
255
|
+
self.source, f"cannot read .npy: {error}"
|
|
256
|
+
) from None
|
|
257
|
+
return as_stream(payload, self.source)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class DatasetAdapter(StreamAdapter):
|
|
261
|
+
"""Adapt any object implementing the documented dataset hook.
|
|
262
|
+
|
|
263
|
+
The hook is intentionally tiny and library-agnostic: an object is adapt-
|
|
264
|
+
able when it exposes ``to_tensor()`` (or ``array()``/``numpy()``) returning
|
|
265
|
+
an array-like, **or** when it supports the sequence protocol
|
|
266
|
+
(``__len__`` + ``__getitem__``). This is the seam a future dataset
|
|
267
|
+
distribution plugs into without ``spikeforge-io`` importing it.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
def __init__(self, dataset: Any, source: Optional[str] = None) -> None:
|
|
271
|
+
"""Wrap ``dataset`` and label it with ``source`` or its type name."""
|
|
272
|
+
self._dataset = dataset
|
|
273
|
+
self.source = source or type(dataset).__name__
|
|
274
|
+
|
|
275
|
+
def _payload(self) -> Any:
|
|
276
|
+
"""Return the array-like the dataset exposes, or a typed refusal."""
|
|
277
|
+
for name in DATASET_METHODS:
|
|
278
|
+
method = getattr(self._dataset, name, None)
|
|
279
|
+
if callable(method):
|
|
280
|
+
return method()
|
|
281
|
+
if hasattr(self._dataset, "__len__") and hasattr(
|
|
282
|
+
self._dataset, "__getitem__"
|
|
283
|
+
):
|
|
284
|
+
indices = range(len(self._dataset))
|
|
285
|
+
return [self._dataset[index] for index in indices]
|
|
286
|
+
raise AdapterFormatError(
|
|
287
|
+
self.source,
|
|
288
|
+
"dataset must expose to_tensor()/array() or the sequence protocol",
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def stream(self) -> torch.Tensor:
|
|
292
|
+
"""Return the dataset's samples as a ``[N, D]`` tensor."""
|
|
293
|
+
return as_stream(self._payload(), self.source)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
#: Adapter factories keyed by a lower-cased filename suffix.
|
|
297
|
+
FACTORIES: Dict[str, Callable[..., StreamAdapter]] = {
|
|
298
|
+
".csv": CsvAdapter,
|
|
299
|
+
".tsv": lambda path, **kwargs: CsvAdapter(path, delimiter="\t", **kwargs),
|
|
300
|
+
".json": JsonAdapter,
|
|
301
|
+
".npy": NpyAdapter,
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def adapter_for(path: str, **kwargs: Any) -> StreamAdapter:
|
|
306
|
+
"""Return the adapter registered for ``path``'s suffix.
|
|
307
|
+
|
|
308
|
+
An unrecognised suffix is refused with
|
|
309
|
+
:class:`~spikeforge_io.errors.AdapterUnsupportedError` naming the supported
|
|
310
|
+
formats, so a caller never gets a silent misparse.
|
|
311
|
+
"""
|
|
312
|
+
lowered = path.lower()
|
|
313
|
+
for suffix, factory in FACTORIES.items():
|
|
314
|
+
if lowered.endswith(suffix):
|
|
315
|
+
return factory(path, **kwargs)
|
|
316
|
+
raise AdapterUnsupportedError(
|
|
317
|
+
path,
|
|
318
|
+
"no adapter for this suffix; supported: "
|
|
319
|
+
+ ", ".join(sorted(FACTORIES)),
|
|
320
|
+
)
|
spikeforge_io/cli.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""The ``spikeforge-io`` console script.
|
|
2
|
+
|
|
3
|
+
A small inspection/replay surface over the adapters: ``formats`` lists the
|
|
4
|
+
registered suffixes, ``info`` describes a recorded stream, and ``replay``
|
|
5
|
+
prints the ``/v1/stream`` messages an adapter would send (a dry run, so it can
|
|
6
|
+
be used in CI without a running service). Mirrors the argparse style of
|
|
7
|
+
:mod:`spikeforge_hub.cli`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
import torch
|
|
15
|
+
|
|
16
|
+
from spikeforge_io.adapters import FACTORIES, adapter_for
|
|
17
|
+
from spikeforge_io.errors import IoError
|
|
18
|
+
from spikeforge_io.replay import stream_messages
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _print(payload: Any) -> None:
|
|
22
|
+
"""Print a payload as indented JSON."""
|
|
23
|
+
print(json.dumps(payload, indent=2, default=_default))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _default(value: Any) -> Any:
|
|
27
|
+
"""Coerce a non-JSON value (a tensor shape) for printing."""
|
|
28
|
+
if isinstance(value, torch.Tensor):
|
|
29
|
+
return value.tolist()
|
|
30
|
+
return str(value)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _fail(message: str) -> int:
|
|
34
|
+
"""Print an error message and return the failure status."""
|
|
35
|
+
print(message)
|
|
36
|
+
return 1
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _run_formats(_args: argparse.Namespace) -> int:
|
|
40
|
+
"""Print the registered adapter suffixes."""
|
|
41
|
+
_print({"formats": sorted(FACTORIES)})
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _run_info(args: argparse.Namespace) -> int:
|
|
46
|
+
"""Describe a recorded stream: shape, channels, and value range."""
|
|
47
|
+
try:
|
|
48
|
+
adapter = adapter_for(
|
|
49
|
+
args.path,
|
|
50
|
+
delimiter=args.delimiter,
|
|
51
|
+
has_header=not args.no_header,
|
|
52
|
+
)
|
|
53
|
+
stream = adapter.stream()
|
|
54
|
+
except IoError as error:
|
|
55
|
+
return _fail(str(error))
|
|
56
|
+
_print(
|
|
57
|
+
{
|
|
58
|
+
"source": adapter.source,
|
|
59
|
+
"samples": int(stream.size(0)),
|
|
60
|
+
"channels": int(stream.size(1)),
|
|
61
|
+
"min": float(stream.min().item()) if stream.numel() else None,
|
|
62
|
+
"max": float(stream.max().item()) if stream.numel() else None,
|
|
63
|
+
"mean": float(stream.mean().item()) if stream.numel() else None,
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _run_replay(args: argparse.Namespace) -> int:
|
|
70
|
+
"""Print the ``/v1/stream`` messages an adapter would send (a dry run)."""
|
|
71
|
+
try:
|
|
72
|
+
adapter = adapter_for(
|
|
73
|
+
args.path,
|
|
74
|
+
delimiter=args.delimiter,
|
|
75
|
+
has_header=not args.no_header,
|
|
76
|
+
)
|
|
77
|
+
spec = None
|
|
78
|
+
if args.length is not None:
|
|
79
|
+
from spikeforge_io.windowing import fit_window_spec
|
|
80
|
+
|
|
81
|
+
spec = fit_window_spec(
|
|
82
|
+
adapter.stream(), int(args.length), int(args.stride or 1)
|
|
83
|
+
)
|
|
84
|
+
messages: List[Dict[str, Any]] = list(
|
|
85
|
+
stream_messages(
|
|
86
|
+
adapter,
|
|
87
|
+
window_spec=spec,
|
|
88
|
+
encoded=args.encoded,
|
|
89
|
+
session_id=args.session_id,
|
|
90
|
+
reset=args.reset,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
except IoError as error:
|
|
94
|
+
return _fail(str(error))
|
|
95
|
+
_print({"source": adapter.source, "messages": messages})
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
100
|
+
"""Parse ``argv`` and dispatch to an I/O subcommand."""
|
|
101
|
+
parser = argparse.ArgumentParser(
|
|
102
|
+
prog="spikeforge-io",
|
|
103
|
+
description="Inspect and replay recorded numeric streams.",
|
|
104
|
+
)
|
|
105
|
+
subs = parser.add_subparsers(dest="command", required=True)
|
|
106
|
+
|
|
107
|
+
formats = subs.add_parser("formats", help="list adapter formats")
|
|
108
|
+
formats.set_defaults(handler=_run_formats)
|
|
109
|
+
|
|
110
|
+
info = subs.add_parser("info", help="describe a recorded stream")
|
|
111
|
+
_add_source(info)
|
|
112
|
+
info.set_defaults(handler=_run_info)
|
|
113
|
+
|
|
114
|
+
replay = subs.add_parser("replay", help="print /v1/stream messages")
|
|
115
|
+
_add_source(replay)
|
|
116
|
+
replay.add_argument("--length", type=int, default=None)
|
|
117
|
+
replay.add_argument("--stride", type=int, default=None)
|
|
118
|
+
replay.add_argument("--encoded", action="store_true")
|
|
119
|
+
replay.add_argument("--reset", action="store_true")
|
|
120
|
+
replay.add_argument("--session-id", dest="session_id", default=None)
|
|
121
|
+
replay.set_defaults(handler=_run_replay)
|
|
122
|
+
|
|
123
|
+
args = parser.parse_args(argv)
|
|
124
|
+
return int(args.handler(args))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _add_source(parser: argparse.ArgumentParser) -> None:
|
|
128
|
+
"""Add the shared source arguments to ``parser``."""
|
|
129
|
+
parser.add_argument("path")
|
|
130
|
+
parser.add_argument("--delimiter", default=",")
|
|
131
|
+
parser.add_argument("--no-header", action="store_true")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
raise SystemExit(main())
|
spikeforge_io/errors.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Typed errors raised by the ``spikeforge-io`` adapters.
|
|
2
|
+
|
|
3
|
+
Grouped here (mirroring :mod:`spikeforge_hub.errors` and
|
|
4
|
+
:mod:`spikeforge.serving.errors`) so a caller can react to a missing file, a
|
|
5
|
+
malformed row, a wrong channel count, or an absent optional dependency without
|
|
6
|
+
parsing a message. An adapter never silently reshapes bad input: a refusal is a
|
|
7
|
+
named type.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class IoError(Exception):
|
|
14
|
+
"""Base class for every typed I/O-adapter failure."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AdapterError(IoError):
|
|
18
|
+
"""Base error for reading one recorded source.
|
|
19
|
+
|
|
20
|
+
The offending ``source`` (a path, a dataset name, or ``"<memory>"``) and a
|
|
21
|
+
human ``detail`` are stored as attributes.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, source: str, detail: str) -> None:
|
|
25
|
+
"""Record ``source`` and ``detail`` and build a clear message."""
|
|
26
|
+
super().__init__(f"I/O adapter failed for {source!r}: {detail}")
|
|
27
|
+
self.source: str = source
|
|
28
|
+
self.detail: str = detail
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AdapterFormatError(AdapterError):
|
|
32
|
+
"""Raised when a source cannot be parsed into a numeric stream."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self, source: str, detail: str, position: Optional[int] = None
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Record the malformed ``detail`` and an optional ``position``."""
|
|
38
|
+
super().__init__(source, detail)
|
|
39
|
+
self.position: Optional[int] = position
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AdapterShapeError(AdapterError):
|
|
43
|
+
"""Raised when a source's shape is not a usable ``[N, D]`` stream."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, source: str, detail: str) -> None:
|
|
46
|
+
"""Record the shape ``detail``."""
|
|
47
|
+
super().__init__(source, detail)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AdapterUnsupportedError(AdapterError):
|
|
51
|
+
"""Raised when no adapter is registered for a source's suffix."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, source: str, detail: str) -> None:
|
|
54
|
+
"""Record why the source has no adapter."""
|
|
55
|
+
super().__init__(source, detail)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AdapterDependencyError(IoError):
|
|
59
|
+
"""Raised when an optional dependency (for example ``numpy``) is absent.
|
|
60
|
+
|
|
61
|
+
The message names the dependency and how to install it, so an unavailable
|
|
62
|
+
format is reported rather than guessed at.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, dependency: str, feature: str) -> None:
|
|
66
|
+
"""Name the missing dependency and the feature that needs it."""
|
|
67
|
+
super().__init__(
|
|
68
|
+
f"{dependency} is required to read {feature}; install the "
|
|
69
|
+
"`spikeforge-io[npy]` extra"
|
|
70
|
+
)
|
|
71
|
+
self.dependency: str = dependency
|
|
72
|
+
self.feature: str = feature
|
spikeforge_io/py.typed
ADDED
|
File without changes
|
spikeforge_io/replay.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Replay a recorded stream into a sink or into ``spikeforge-serve``'s stream.
|
|
2
|
+
|
|
3
|
+
Two entry points:
|
|
4
|
+
|
|
5
|
+
* :func:`replay` feeds each adapted frame to any callable sink, so a recorded
|
|
6
|
+
file can drive the ``encode`` function, an
|
|
7
|
+
:class:`~spikeforge.serving.session.InferenceSession`, or a test double.
|
|
8
|
+
* :func:`stream_messages` / :func:`replay_to_stream` build and send the exact
|
|
9
|
+
message shape the ``WS /v1/stream`` endpoint accepts (``{"frame": ...}`` with
|
|
10
|
+
an optional leading ``{"reset": true}``), so an adapter replays a recorded
|
|
11
|
+
stream into the live service without a bespoke transport.
|
|
12
|
+
|
|
13
|
+
Neither function imports the serve or client distributions: the protocol is a
|
|
14
|
+
plain dict, and the caller supplies the ``send`` callable, so
|
|
15
|
+
``spikeforge-io`` stays dependency-light.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence
|
|
20
|
+
|
|
21
|
+
import torch
|
|
22
|
+
|
|
23
|
+
from spikeforge_io.adapters import StreamAdapter
|
|
24
|
+
|
|
25
|
+
#: A callable that receives one frame tensor (or message dict).
|
|
26
|
+
Sink = Callable[[Any], Any]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ReplayReport:
|
|
31
|
+
"""What one replay sent: the source, frame count, and any replies."""
|
|
32
|
+
|
|
33
|
+
source: str
|
|
34
|
+
frames: int
|
|
35
|
+
windows: bool = False
|
|
36
|
+
replies: Sequence[Any] = field(default_factory=tuple)
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
39
|
+
"""Return the report as a JSON-able mapping."""
|
|
40
|
+
return {
|
|
41
|
+
"source": self.source,
|
|
42
|
+
"frames": int(self.frames),
|
|
43
|
+
"windows": bool(self.windows),
|
|
44
|
+
"replies": len(self.replies),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _frames(adapter: StreamAdapter, window_spec: Any) -> List[torch.Tensor]:
|
|
49
|
+
"""Return the adapter's replay frames as a list of tensors.
|
|
50
|
+
|
|
51
|
+
Without a ``window_spec`` each frame is one raw ``[D]`` sample; with one,
|
|
52
|
+
each frame is a normalized ``[L, D]`` window.
|
|
53
|
+
"""
|
|
54
|
+
if window_spec is None:
|
|
55
|
+
return list(adapter.stream())
|
|
56
|
+
return list(adapter.windows(window_spec))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def replay(
|
|
60
|
+
adapter: StreamAdapter,
|
|
61
|
+
sink: Sink,
|
|
62
|
+
*,
|
|
63
|
+
window_spec: Any = None,
|
|
64
|
+
) -> ReplayReport:
|
|
65
|
+
"""Feed every frame of ``adapter`` to ``sink`` and return the report.
|
|
66
|
+
|
|
67
|
+
``sink`` receives one frame at a time (a ``[D]`` sample, or an ``[L, D]``
|
|
68
|
+
window when ``window_spec`` is given); its return value is ignored. This is
|
|
69
|
+
the general hook an :class:`~spikeforge.serving.session.InferenceSession`,
|
|
70
|
+
the ``encode`` function, or a test double binds to.
|
|
71
|
+
"""
|
|
72
|
+
frames = _frames(adapter, window_spec)
|
|
73
|
+
for frame in frames:
|
|
74
|
+
sink(frame)
|
|
75
|
+
return ReplayReport(
|
|
76
|
+
source=adapter.source,
|
|
77
|
+
frames=len(frames),
|
|
78
|
+
windows=window_spec is not None,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def stream_messages(
|
|
83
|
+
adapter: StreamAdapter,
|
|
84
|
+
*,
|
|
85
|
+
window_spec: Any = None,
|
|
86
|
+
encoded: bool = False,
|
|
87
|
+
session_id: Optional[str] = None,
|
|
88
|
+
reset: bool = False,
|
|
89
|
+
) -> Iterator[Dict[str, Any]]:
|
|
90
|
+
"""Yield the ``/v1/stream`` messages that replay ``adapter``.
|
|
91
|
+
|
|
92
|
+
The first message is a ``{"reset": true}`` envelope when ``reset`` is set,
|
|
93
|
+
then one ``{"frame": ...}`` message per sample (or per normalized window
|
|
94
|
+
when ``window_spec`` is given). The shape matches
|
|
95
|
+
:func:`spikeforge_serve.app._stream_reply` exactly.
|
|
96
|
+
"""
|
|
97
|
+
if reset:
|
|
98
|
+
yield _message({"reset": True}, session_id)
|
|
99
|
+
for frame in _frames(adapter, window_spec):
|
|
100
|
+
yield _message(
|
|
101
|
+
{"frame": frame.tolist(), "encoded": bool(encoded)}, session_id
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _message(
|
|
106
|
+
body: Dict[str, Any], session_id: Optional[str]
|
|
107
|
+
) -> Dict[str, Any]:
|
|
108
|
+
"""Return ``body`` with an optional session id folded in."""
|
|
109
|
+
if session_id:
|
|
110
|
+
body["session_id"] = session_id
|
|
111
|
+
return body
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def replay_to_stream(
|
|
115
|
+
adapter: StreamAdapter,
|
|
116
|
+
send: Sink,
|
|
117
|
+
*,
|
|
118
|
+
window_spec: Any = None,
|
|
119
|
+
encoded: bool = False,
|
|
120
|
+
session_id: Optional[str] = None,
|
|
121
|
+
reset: bool = False,
|
|
122
|
+
) -> ReplayReport:
|
|
123
|
+
"""Replay ``adapter`` into ``/v1/stream`` through ``send``.
|
|
124
|
+
|
|
125
|
+
``send`` takes one protocol message and returns the service reply (for a
|
|
126
|
+
WebSocket, ``send`` would serialize and await the response). The replies
|
|
127
|
+
are collected into the returned :class:`ReplayReport`.
|
|
128
|
+
"""
|
|
129
|
+
frames = 0
|
|
130
|
+
replies: List[Any] = []
|
|
131
|
+
for message in stream_messages(
|
|
132
|
+
adapter,
|
|
133
|
+
window_spec=window_spec,
|
|
134
|
+
encoded=encoded,
|
|
135
|
+
session_id=session_id,
|
|
136
|
+
reset=reset,
|
|
137
|
+
):
|
|
138
|
+
if "frame" in message:
|
|
139
|
+
frames += 1
|
|
140
|
+
replies.append(send(message))
|
|
141
|
+
return ReplayReport(
|
|
142
|
+
source=adapter.source,
|
|
143
|
+
frames=frames,
|
|
144
|
+
windows=window_spec is not None,
|
|
145
|
+
replies=tuple(replies),
|
|
146
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Windowing and normalization surface for ``spikeforge-io``.
|
|
2
|
+
|
|
3
|
+
The plan places "windowing/normalization" on the ``spikeforge-io``
|
|
4
|
+
distribution. The *implementation*, however, stays in the core
|
|
5
|
+
:mod:`spikeforge.streaming.window_spec` module, because core must never depend
|
|
6
|
+
on a satellite: the training recipe, the serving session, and the UC-1 tests
|
|
7
|
+
all consume the frozen :class:`~spikeforge.streaming.window_spec.WindowSpec`
|
|
8
|
+
directly. This module therefore **re-exports** the one canonical contract
|
|
9
|
+
rather than copying it, so there is a single definition of "how a stream
|
|
10
|
+
becomes a normalized window"; ``spikeforge_io.windows(...)`` is byte-for-byte
|
|
11
|
+
the core behaviour.
|
|
12
|
+
|
|
13
|
+
A caller that only wants the I/O surface can import everything here:
|
|
14
|
+
|
|
15
|
+
from spikeforge_io.windowing import WindowSpec, window_stream
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from spikeforge.streaming.encoding import (
|
|
21
|
+
delta_over_window,
|
|
22
|
+
encode_windows,
|
|
23
|
+
)
|
|
24
|
+
from spikeforge.streaming.window_spec import (
|
|
25
|
+
WINDOW_SPEC_VERSION,
|
|
26
|
+
WindowSpec,
|
|
27
|
+
fit_window_spec,
|
|
28
|
+
normalize_windows,
|
|
29
|
+
window_stream,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"WINDOW_SPEC_VERSION",
|
|
34
|
+
"WindowSpec",
|
|
35
|
+
"delta_over_window",
|
|
36
|
+
"encode_windows",
|
|
37
|
+
"fit_window_spec",
|
|
38
|
+
"normalize_windows",
|
|
39
|
+
"window_spec_from_bundle",
|
|
40
|
+
"window_stream",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def window_spec_from_bundle(bundle: Any) -> WindowSpec:
|
|
45
|
+
"""Return the frozen window contract a bundle carries.
|
|
46
|
+
|
|
47
|
+
A bundle whose ``preprocessing`` block has no ``window`` entry cannot be
|
|
48
|
+
windowed by this helper; the caller must fit a spec instead. The returned
|
|
49
|
+
spec is validated, so a corrupt recorded contract is refused rather than
|
|
50
|
+
used.
|
|
51
|
+
"""
|
|
52
|
+
preprocessing = dict(getattr(bundle, "preprocessing", None) or {})
|
|
53
|
+
recorded = preprocessing.get("window")
|
|
54
|
+
if not recorded:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
"bundle carries no frozen window contract; fit a WindowSpec "
|
|
57
|
+
"with fit_window_spec"
|
|
58
|
+
)
|
|
59
|
+
spec = WindowSpec.from_dict(recorded)
|
|
60
|
+
spec.validate()
|
|
61
|
+
return spec
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def windows(bundle: Any, stream: Any) -> Any:
|
|
65
|
+
"""Return ``stream``'s normalized ``[W, L, D]`` windows for ``bundle``.
|
|
66
|
+
|
|
67
|
+
This is the one-call path an adapter uses: the window geometry and the
|
|
68
|
+
z-score statistics both come from the bundle's frozen contract, so a
|
|
69
|
+
recorded stream is normalized exactly as training was.
|
|
70
|
+
"""
|
|
71
|
+
return window_spec_from_bundle(bundle).windows(stream)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spikeforge-io
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Recorded-stream I/O adapters and the windowing surface for spikeforge.
|
|
5
|
+
Author-email: Capsize LLC <contact@capsizegames.com>
|
|
6
|
+
Maintainer-email: Capsize LLC <contact@capsizegames.com>
|
|
7
|
+
License-Expression: BSD-3-Clause
|
|
8
|
+
Project-URL: Homepage, https://github.com/capsize-games/spikeforge
|
|
9
|
+
Project-URL: Repository, https://github.com/capsize-games/spikeforge
|
|
10
|
+
Keywords: spiking neural networks,streaming,io,adapters,windowing
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: spikeforge~=0.3.0
|
|
25
|
+
Requires-Dist: torch>=2.5
|
|
26
|
+
Requires-Dist: numpy>=1.26
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff>=0.0.280; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# spikeforge
|
|
34
|
+
|
|
35
|
+
[](https://github.com/capsize-games/spikeforge/actions/workflows/ci.yml)
|
|
36
|
+
[](OPEN_SOURCE_CHECKLIST.md)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
[](https://www.python.org/downloads/)
|
|
39
|
+
[](https://github.com/astral-sh/ruff)
|
|
40
|
+
[](https://github.com/capsize-games/spikeforge/blob/main/CONTRIBUTING.md)
|
|
41
|
+
[](documentation/README.md)
|
|
42
|
+
|
|
43
|
+
A spiking-neural-network (SNN) toolkit built on
|
|
44
|
+
[snnTorch](https://snntorch.readthedocs.io/) and PyTorch. Loads MNIST-style
|
|
45
|
+
and neuromorphic event datasets, encodes them into rate, latency, delta, and
|
|
46
|
+
random spikes, and trains, validates, exports, and deploys LIF networks —
|
|
47
|
+
with a live browser dashboard served over WebSockets.
|
|
48
|
+
|
|
49
|
+
> **Pre-1.0 and unpublished.** `install.sh` is the supported path today;
|
|
50
|
+
> PyPI ships once the distributions are published. Before trusting any
|
|
51
|
+
> number this produces, read
|
|
52
|
+
> [Implications and boundaries](documentation/implications-and-boundaries.md).
|
|
53
|
+
|
|
54
|
+
## Quickstart
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
git clone https://github.com/capsize-games/spikeforge.git
|
|
58
|
+
cd spikeforge
|
|
59
|
+
docker compose up --build
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Open <http://localhost:8877> — the dashboard connects to the WebSocket on
|
|
63
|
+
the same host and port. No separate backend or proxy to run.
|
|
64
|
+
|
|
65
|
+
Prefer a local install, a headless example, or the CLI tools instead? See
|
|
66
|
+
[Usage](documentation/usage.md) and [Quickstart](documentation/quickstart.md)
|
|
67
|
+
for every path (`./install.sh`, local dev with Vite, `examples/`, and the
|
|
68
|
+
eight `spikeforge-*` console scripts).
|
|
69
|
+
|
|
70
|
+
## Features
|
|
71
|
+
|
|
72
|
+
- **Encoding** — rate, latency, delta, and random spike coders.
|
|
73
|
+
- **Training** — fully-connected and convolutional LIF networks with
|
|
74
|
+
surrogate-gradient cross-entropy, checkpointing, and opt-in AMP / gradient
|
|
75
|
+
checkpointing / truncated BPTT / multi-GPU.
|
|
76
|
+
- **Topologies** — `fc_legacy`, `fc_small`, `conv_net`, `recurrent_net`, plus
|
|
77
|
+
the sequence presets `sequence_mlp` and `sequence_attn`.
|
|
78
|
+
- **Datasets** — MNIST, Fashion-MNIST, KMNIST, QMNIST, USPS, EMNIST,
|
|
79
|
+
CIFAR-10, and (via the `events` extra) N-MNIST, DVS128 Gesture,
|
|
80
|
+
CIFAR10-DVS, and Spiking Speech Commands.
|
|
81
|
+
- **Interpreter spine** — NIR export, an independent NIR interpreter, and
|
|
82
|
+
numerical drift validation.
|
|
83
|
+
- **Introspection** — educational-mode `U[t]`/`I[t]`/`S[t]` traces,
|
|
84
|
+
trajectory metrics, and surrogate-derivative curves.
|
|
85
|
+
- **Deployment** — a capability matrix, weight quantization, energy
|
|
86
|
+
accounting, and executable `reference`, `norse`, and `lava_loihi2`
|
|
87
|
+
backends.
|
|
88
|
+
- **Model hub** — a curated, offline-first catalog plus optional live
|
|
89
|
+
Hugging Face search.
|
|
90
|
+
- **Dashboard** — a React + TypeScript UI with training, introspection,
|
|
91
|
+
analysis, targets, energy, and hub panels, and seven guided walkthroughs.
|
|
92
|
+
|
|
93
|
+
## Packages
|
|
94
|
+
|
|
95
|
+
This repository is a single workspace that publishes four distributions:
|
|
96
|
+
|
|
97
|
+
| Distribution | Import root | Purpose |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| `spikeforge` | `spikeforge` | Core package: encoders, topologies, training, simulator, NIR bridge, tracking |
|
|
100
|
+
| `spikeforge-targets` | `spikeforge_targets` | Deployment targets, quantization, energy accounting, sparse event runtime |
|
|
101
|
+
| `spikeforge-hub` | `spikeforge_hub` | Curated model hub and optional Hugging Face access |
|
|
102
|
+
| `spikeforge-server` | `server` | FastAPI + WebSocket server and dashboard hosting |
|
|
103
|
+
|
|
104
|
+
## Documentation
|
|
105
|
+
|
|
106
|
+
This README stays short on purpose.
|
|
107
|
+
[`documentation/`](documentation/README.md) is the full reference — install
|
|
108
|
+
paths, the CLI tools, architecture, module layout, and the dev workflow —
|
|
109
|
+
written for contributors and coding agents alike. Also see
|
|
110
|
+
[COOKBOOK.md](COOKBOOK.md) for copy-pasteable recipes,
|
|
111
|
+
[examples/](examples/) for runnable end-to-end scripts, and
|
|
112
|
+
[plans/](plans/) for design documents and the roadmap.
|
|
113
|
+
|
|
114
|
+
See [CONTRIBUTING.md](https://github.com/capsize-games/spikeforge/blob/main/CONTRIBUTING.md)
|
|
115
|
+
and [rules.md](rules.md) before opening a pull request.
|
|
116
|
+
|
|
117
|
+
## Citing
|
|
118
|
+
|
|
119
|
+
If spikeforge is useful in your research, please cite it — see
|
|
120
|
+
[CITATION.cff](CITATION.cff) (GitHub renders a "Cite this repository"
|
|
121
|
+
button from it automatically).
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
Released under the **BSD 3-Clause License** — see [LICENSE](LICENSE) and
|
|
126
|
+
[AUTHORS](AUTHORS).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
spikeforge_io/__init__.py,sha256=efoHQ054E3hy2YW4EPlz7CbweXx761poc_1YdvfktFA,2241
|
|
2
|
+
spikeforge_io/__main__.py,sha256=Hgs4w5-Bt7I-A4oHBiuw6kqHv7QPmUVAUA7KWpFePWs,156
|
|
3
|
+
spikeforge_io/adapters.py,sha256=PRomCj5yVbQwv3KdEryeV3alAKBbvHZaWz5Z2yinmBo,11708
|
|
4
|
+
spikeforge_io/cli.py,sha256=106viYV8T7CNuG0Rwu8xcOPQI4UGbq5VJi6Mc8v94AQ,4359
|
|
5
|
+
spikeforge_io/errors.py,sha256=os4tn7wHI8K9KlZwj38Uo57MSJNZCT8W4zDrXTWytrQ,2500
|
|
6
|
+
spikeforge_io/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
spikeforge_io/replay.py,sha256=UNqO42RH57z4Gh65w6mJj7a3yC-M_rsj3pQuY_C9pxM,4640
|
|
8
|
+
spikeforge_io/windowing.py,sha256=BorQIB6sojDOf79_XPRw1mZ-_2oknWVQXvZBDgXRCjI,2411
|
|
9
|
+
spikeforge_io-0.1.0.dist-info/licenses/LICENSE,sha256=AMWvpmlE0gnq9edII9aRBnpbGNM7wBhbJ6ysoraXjgE,1511
|
|
10
|
+
spikeforge_io-0.1.0.dist-info/METADATA,sha256=dBjKU_yABJ6Q1Ewy7K1U0fkiW-aTBVheglt-BThkGI8,6036
|
|
11
|
+
spikeforge_io-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
12
|
+
spikeforge_io-0.1.0.dist-info/entry_points.txt,sha256=s10Y4NEvwNAepc7_etOdxLSUTKY4unTGWh11KZXEOR4,57
|
|
13
|
+
spikeforge_io-0.1.0.dist-info/top_level.txt,sha256=tKiX2s0Qc1ujeCvxOoarR0vcW0HycDucd2FUCh67Y9g,14
|
|
14
|
+
spikeforge_io-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2018 Capsize LLC
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
* Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
* Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
* Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
30
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
spikeforge_io
|