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.
@@ -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,6 @@
1
+ """``python -m spikeforge_io`` entry point for the I/O CLI."""
2
+
3
+ from spikeforge_io.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -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())
@@ -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
@@ -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
+ [![CI](https://github.com/capsize-games/spikeforge/actions/workflows/ci.yml/badge.svg)](https://github.com/capsize-games/spikeforge/actions/workflows/ci.yml)
36
+ [![Status: pre-1.0](https://img.shields.io/badge/status-pre--1.0-orange.svg)](OPEN_SOURCE_CHECKLIST.md)
37
+ [![License: BSD-3-Clause](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](LICENSE)
38
+ [![Python 3.10–3.13](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-3776AB.svg)](https://www.python.org/downloads/)
39
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
40
+ [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/capsize-games/spikeforge/blob/main/CONTRIBUTING.md)
41
+ [![Docs](https://img.shields.io/badge/docs-long--form%20reference-blue.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ spikeforge-io = spikeforge_io.cli:main
@@ -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