spikeforge-server 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.
Files changed (47) hide show
  1. server/__init__.py +1 -0
  2. server/__main__.py +55 -0
  3. server/animation.py +76 -0
  4. server/app.py +154 -0
  5. server/backend_handlers.py +59 -0
  6. server/backend_payloads.py +53 -0
  7. server/download_errors.py +5 -0
  8. server/downloads.py +129 -0
  9. server/encoder.py +106 -0
  10. server/energy_handlers.py +62 -0
  11. server/energy_messages.py +22 -0
  12. server/energy_payloads.py +60 -0
  13. server/engine_factory.py +79 -0
  14. server/event_engine.py +145 -0
  15. server/handlers.py +247 -0
  16. server/hub_downloads.py +39 -0
  17. server/hub_handlers.py +117 -0
  18. server/hub_messages.py +51 -0
  19. server/hub_payloads.py +59 -0
  20. server/introspection_handlers.py +173 -0
  21. server/introspection_payloads.py +117 -0
  22. server/messages.py +232 -0
  23. server/model_handlers.py +56 -0
  24. server/model_payloads.py +26 -0
  25. server/nir_handlers.py +67 -0
  26. server/payloads.py +96 -0
  27. server/protocol_handlers.py +51 -0
  28. server/protocol_version.py +34 -0
  29. server/schemas/__init__.py +18 -0
  30. server/schemas/client_message.py +37 -0
  31. server/schemas/encode_config.py +34 -0
  32. server/schemas/hub_query.py +20 -0
  33. server/schemas/model_query.py +17 -0
  34. server/schemas/server_message.py +31 -0
  35. server/schemas/train_config.py +50 -0
  36. server/session.py +62 -0
  37. server/stats.py +28 -0
  38. server/target_handlers.py +117 -0
  39. server/target_payloads.py +52 -0
  40. server/training.py +223 -0
  41. server/web.py +63 -0
  42. spikeforge_server-0.1.0.dist-info/METADATA +130 -0
  43. spikeforge_server-0.1.0.dist-info/RECORD +47 -0
  44. spikeforge_server-0.1.0.dist-info/WHEEL +5 -0
  45. spikeforge_server-0.1.0.dist-info/entry_points.txt +2 -0
  46. spikeforge_server-0.1.0.dist-info/licenses/LICENSE +30 -0
  47. spikeforge_server-0.1.0.dist-info/top_level.txt +1 -0
server/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """FastAPI server exposing SNN encoding experiments over WebSocket."""
server/__main__.py ADDED
@@ -0,0 +1,55 @@
1
+ """Run the FastAPI server with uvicorn: ``python -m server``."""
2
+
3
+ import argparse
4
+ from typing import Optional, Sequence
5
+
6
+ import uvicorn
7
+
8
+ #: Default bind address and port (the single-port dashboard contract on :8877).
9
+ DEFAULT_HOST = "127.0.0.1"
10
+ DEFAULT_PORT = 8877
11
+
12
+
13
+ def _parse_args(argv: Optional[Sequence[str]]) -> argparse.Namespace:
14
+ """Parse the optional bind arguments, keeping the historical defaults."""
15
+ parser = argparse.ArgumentParser(
16
+ prog="spikeforge-server",
17
+ description="Run the spikeforge dashboard/WebSocket server.",
18
+ )
19
+ parser.add_argument(
20
+ "--host",
21
+ default=DEFAULT_HOST,
22
+ help=f"bind address (default: {DEFAULT_HOST})",
23
+ )
24
+ parser.add_argument(
25
+ "--port",
26
+ type=int,
27
+ default=DEFAULT_PORT,
28
+ help=f"bind port (default: {DEFAULT_PORT})",
29
+ )
30
+ parser.add_argument(
31
+ "--no-reload",
32
+ action="store_true",
33
+ help="disable uvicorn's auto-reload watcher",
34
+ )
35
+ return parser.parse_args(argv)
36
+
37
+
38
+ def main(argv: Optional[Sequence[str]] = None) -> None:
39
+ """Start the dashboard server with uvicorn.
40
+
41
+ ``python -m server`` and the ``spikeforge-server`` console script both
42
+ land here; the defaults are the historical ``127.0.0.1:8877`` with
43
+ auto-reload, matching the project's single-port contract.
44
+ """
45
+ args = _parse_args(argv)
46
+ uvicorn.run(
47
+ "server.app:app",
48
+ host=args.host,
49
+ port=args.port,
50
+ reload=not args.no_reload,
51
+ )
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
server/animation.py ADDED
@@ -0,0 +1,76 @@
1
+ """Per-step hidden-layer animation frames and their availability message.
2
+
3
+ The frames come from the loaded model's hidden stage. Without a model there
4
+ is nothing honest to animate, so availability and its named reason are
5
+ reported to the client rather than a fabricated frame; with no model the
6
+ input-frame stream stays exactly as it was.
7
+ """
8
+
9
+ from typing import Any, List, Optional, Tuple
10
+
11
+ import torch
12
+ from fastapi import WebSocket
13
+
14
+ from server.messages import send_locked
15
+ from server.session import Session
16
+ from spikeforge.network.hidden_frames import hidden_frame_series
17
+
18
+ #: Named reasons the hidden animation cannot run.
19
+ NO_MODEL = "no model loaded; train or load one"
20
+ NO_SAMPLE = "no sample configured"
21
+
22
+
23
+ def _on_device(net: Any, spikes: torch.Tensor) -> torch.Tensor:
24
+ """Move ``spikes`` onto the network's parameter device."""
25
+ param = next(net.parameters(), None)
26
+ return spikes if param is None else spikes.to(param.device)
27
+
28
+
29
+ def hidden_series(session: Session) -> Tuple[Optional[List[Any]], str]:
30
+ """Return the per-step hidden frames and a reason when unavailable."""
31
+ engine = session.training.engine
32
+ if engine is None:
33
+ return None, NO_MODEL
34
+ sample = session.engine
35
+ if sample is None:
36
+ return None, NO_SAMPLE
37
+ try:
38
+ spikes = _on_device(engine.net, sample.spike_input())
39
+ return hidden_frame_series(engine.net, spikes), ""
40
+ except Exception as exc:
41
+ return None, f"{type(exc).__name__}: {exc}"
42
+
43
+
44
+ async def send_animation_state(
45
+ ws: WebSocket, session: Session, available: bool, reason: str
46
+ ) -> None:
47
+ """Report whether the hidden animation is available, and why not."""
48
+ await send_locked(ws, session, {
49
+ "type": "animation_state",
50
+ "payload": {"available": available, "reason": reason,
51
+ "source": "hidden"},
52
+ })
53
+
54
+
55
+ async def emit_hidden_frame(
56
+ ws: WebSocket, session: Session, frames: List[Any], step: int
57
+ ) -> None:
58
+ """Send one step's hidden-layer frame as a ``spike_frame``."""
59
+ index = min(max(int(step), 0), len(frames) - 1)
60
+ await send_locked(ws, session, {
61
+ "type": "spike_frame",
62
+ "payload": frames[index],
63
+ "step": index,
64
+ "source": "hidden",
65
+ })
66
+
67
+
68
+ async def prepare(
69
+ ws: WebSocket, session: Session, animate: bool
70
+ ) -> List[Any]:
71
+ """Return the hidden frame series, reporting availability once."""
72
+ if not animate:
73
+ return []
74
+ frames, reason = hidden_series(session)
75
+ await send_animation_state(ws, session, frames is not None, reason)
76
+ return frames or []
server/app.py ADDED
@@ -0,0 +1,154 @@
1
+ """FastAPI application exposing the encoding engine over WebSocket."""
2
+
3
+ import asyncio
4
+ from typing import Any, Dict, Optional
5
+
6
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
7
+
8
+ from server.downloads import manager
9
+ from server.handlers import dispatch
10
+ from server.hub_downloads import manager as hub_manager
11
+ from server.messages import send_locked
12
+ from server.protocol_version import PROTOCOL_MAJOR, PROTOCOL_VERSION
13
+ from server.schemas import ClientMessage
14
+ from server.session import Session
15
+ from server.web import mount_client
16
+ from spikeforge.runtime import device as device_mod
17
+
18
+ app = FastAPI(title="spikeforge server")
19
+
20
+ # Warm the CUDA context once at startup so training/benchmarks don't stall.
21
+ device_mod.prime()
22
+
23
+ # Serve the built React app (no-op when client/dist is absent).
24
+ mount_client(app)
25
+
26
+ # One session per connected client.
27
+ _sessions: Dict[int, Session] = {}
28
+
29
+ #: Payload code returned when an inbound version is missing or mismatched.
30
+ VERSION_MISMATCH = "protocol_version_mismatch"
31
+
32
+
33
+ def _version_error(raw: Dict[str, Any]) -> Optional[Dict[str, Any]]:
34
+ """Return a mismatch payload, or None when the inbound version fits.
35
+
36
+ From Phase 2 onward ``protocol_version`` is required: a message that
37
+ omits it -- or carries one whose MAJOR component differs from ours -- is
38
+ rejected with ``payload.code = "protocol_version_mismatch"``.
39
+ """
40
+ incoming = raw.get("protocol_version")
41
+ if incoming is None:
42
+ return {
43
+ "code": VERSION_MISMATCH,
44
+ "message": (
45
+ "client message is missing protocol_version; server "
46
+ f"protocol is {PROTOCOL_VERSION}"
47
+ ),
48
+ "client": None,
49
+ "server": PROTOCOL_VERSION,
50
+ }
51
+ major = str(incoming).split(".", 1)[0]
52
+ if major == PROTOCOL_MAJOR:
53
+ return None
54
+ return {
55
+ "code": VERSION_MISMATCH,
56
+ "message": (
57
+ f"client protocol {incoming!r} is incompatible with server "
58
+ f"protocol {PROTOCOL_VERSION}"
59
+ ),
60
+ "client": incoming,
61
+ "server": PROTOCOL_VERSION,
62
+ }
63
+
64
+
65
+ async def _drain_training(ws: WebSocket, session: Session) -> None:
66
+ """Forward worker-produced training messages to the socket."""
67
+ while True:
68
+ message = await session.training.queue.get()
69
+ await send_locked(ws, session, message)
70
+
71
+
72
+ async def _dispatch_inbox(ws: WebSocket, session: Session) -> None:
73
+ """Process queued client messages one at a time."""
74
+ while True:
75
+ message = await session.inbox.get()
76
+ try:
77
+ await dispatch(ws, session, message)
78
+ except Exception as exc: # keep the connection alive on bad input
79
+ await send_locked(ws, session, {
80
+ "type": "error", "payload": str(exc),
81
+ })
82
+
83
+
84
+ async def _cleanup(
85
+ session: Session,
86
+ drain: asyncio.Task,
87
+ worker: asyncio.Task,
88
+ session_id: int,
89
+ ) -> None:
90
+ """Stop background work and forget the session."""
91
+ session.training.stop()
92
+ drain.cancel()
93
+ worker.cancel()
94
+ await session.cancel()
95
+ _sessions.pop(session_id, None)
96
+
97
+
98
+ async def _serve(ws: WebSocket, session: Session) -> None:
99
+ """Read client messages, giving downloads their own cancel path."""
100
+ while True:
101
+ raw = await ws.receive_json()
102
+ version_error = _version_error(raw)
103
+ if version_error is not None:
104
+ await send_locked(ws, session, {
105
+ "type": "error", "payload": version_error,
106
+ })
107
+ continue
108
+ try:
109
+ message = ClientMessage.model_validate(raw)
110
+ except Exception as exc: # keep the connection alive on bad input
111
+ await send_locked(ws, session, {
112
+ "type": "error", "payload": str(exc),
113
+ })
114
+ continue
115
+ # Cancels must bypass the queue so they land while a download blocks
116
+ # the dispatcher on its progress stream.
117
+ if message.type == "cancel_download":
118
+ manager.cancel()
119
+ continue
120
+ if message.type == "hub_cancel":
121
+ hub_manager.cancel()
122
+ await send_locked(ws, session, {
123
+ "type": "hub_download_state",
124
+ "payload": hub_manager.snapshot(),
125
+ })
126
+ continue
127
+ await session.inbox.put(message)
128
+
129
+
130
+ @app.websocket("/ws")
131
+ async def websocket_endpoint(ws: WebSocket) -> None:
132
+ """Accept a client, run its session, and always clean up."""
133
+ await ws.accept()
134
+ session_id = id(ws)
135
+ session = Session(asyncio.get_running_loop())
136
+ _sessions[session_id] = session
137
+ drain = asyncio.create_task(_drain_training(ws, session))
138
+ worker = asyncio.create_task(_dispatch_inbox(ws, session))
139
+ try:
140
+ await _serve(ws, session)
141
+ except WebSocketDisconnect:
142
+ pass
143
+ except Exception as exc: # surface unexpected errors to the client
144
+ await send_locked(ws, session, {
145
+ "type": "error", "payload": str(exc),
146
+ })
147
+ finally:
148
+ await _cleanup(session, drain, worker, session_id)
149
+
150
+
151
+ @app.get("/health")
152
+ async def health() -> Dict[str, str]:
153
+ """Liveness probe for container/orchestrator health checks."""
154
+ return {"status": "ok"}
@@ -0,0 +1,59 @@
1
+ """Handler for the ``deploy_run`` backend-execution WebSocket action.
2
+
3
+ Mirrors :mod:`server.target_handlers`: it stays outside :mod:`server.handlers`
4
+ so that module keeps within the line limit. The capability view is unchanged
5
+ (it is still the ``deployment_report`` action); ``deploy_run`` adds the
6
+ *executed* view, compiling the target-ready graph and running it on the
7
+ backend. An unknown target is reported on the existing ``error`` channel and a
8
+ missing sample degrades to an error-shaped backend payload with a named
9
+ reason, never a raise.
10
+ """
11
+
12
+ from typing import Any, Dict
13
+
14
+ from fastapi import WebSocket
15
+
16
+ from server.backend_payloads import backend_run_payload
17
+ from server.messages import send_backend_run, send_locked
18
+ from server.schemas import ClientMessage
19
+ from server.session import Session
20
+ from server.target_handlers import DEFAULT_TARGET, shaped_sample, target_pair
21
+ from spikeforge_targets.registry import target_names
22
+
23
+
24
+ async def _resolve(
25
+ ws: WebSocket, session: Session, message: ClientMessage
26
+ ) -> Any:
27
+ """Return the active ``(spec, module)`` pair or report an error."""
28
+ try:
29
+ return target_pair(session, message.train)
30
+ except ValueError as exc:
31
+ await send_locked(ws, session, {"type": "error", "payload": str(exc)})
32
+ return None
33
+
34
+
35
+ async def handle_deploy_run(
36
+ ws: WebSocket, session: Session, message: ClientMessage
37
+ ) -> None:
38
+ """Compile and run the active topology on the named backend."""
39
+ target = message.name or DEFAULT_TARGET
40
+ if target not in target_names():
41
+ await send_locked(ws, session, {
42
+ "type": "error", "payload": f"unknown target: {target!r}",
43
+ })
44
+ return
45
+ pair = await _resolve(ws, session, message)
46
+ if pair is None:
47
+ return
48
+ spec, module = pair
49
+ payload: Dict[str, Any] = backend_run_payload(
50
+ spec, module, target, shaped_sample(session, spec)
51
+ )
52
+ await send_backend_run(ws, session, payload)
53
+
54
+
55
+ async def dispatch_backend(
56
+ ws: WebSocket, session: Session, message: ClientMessage
57
+ ) -> None:
58
+ """Route the backend-execution action."""
59
+ await handle_deploy_run(ws, session, message)
@@ -0,0 +1,53 @@
1
+ """JSON-able payloads for the backend-run WebSocket action.
2
+
3
+ The payload is the :meth:`BackendResult.to_dict` of an executed (or refused)
4
+ backend run. A missing sample or an export failure is reported as an honest
5
+ ``error`` result with a named reason rather than a raised exception, matching
6
+ the deployment-report handler's degrade-don't-raise contract.
7
+ """
8
+
9
+ from typing import Any, Dict, Optional
10
+
11
+ import torch
12
+
13
+ from spikeforge.nir_bridge.exporter import to_nir
14
+ from spikeforge.topology.spec import TopologySpec
15
+ from spikeforge_targets.backends import STATUS_ERROR, compile_run
16
+ from spikeforge_targets.backends.result import BackendResult
17
+
18
+ #: Reason reported when no shaped sample is available to run.
19
+ NO_SAMPLE = "no sample is loaded; select a sample before running a backend"
20
+
21
+
22
+ def _device_input(module: Any, spikes: torch.Tensor) -> torch.Tensor:
23
+ """Move ``spikes`` onto the module's device for the backend run."""
24
+ param = next(module.parameters(), None)
25
+ return spikes if param is None else spikes.to(param.device)
26
+
27
+
28
+ def _error(target: str, reason: str) -> Dict[str, Any]:
29
+ """Return an error-shaped backend payload with a named reason."""
30
+ return BackendResult(
31
+ target=target, status=STATUS_ERROR, notes=(reason,)
32
+ ).to_dict()
33
+
34
+
35
+ def backend_run_payload(
36
+ spec: TopologySpec,
37
+ module: Any,
38
+ target: str,
39
+ spikes: Optional[torch.Tensor] = None,
40
+ ) -> Dict[str, Any]:
41
+ """Return the backend run payload for a spec, module, and shaped spikes.
42
+
43
+ ``spikes`` must already be shaped for ``spec``; without them the payload
44
+ is an honest error rather than a fabricated run.
45
+ """
46
+ if spikes is None:
47
+ return _error(target, NO_SAMPLE)
48
+ try:
49
+ graph = to_nir(spec, module)
50
+ result = compile_run(target, graph, _device_input(module, spikes))
51
+ except Exception as exc:
52
+ return _error(target, f"{type(exc).__name__}: {exc}")
53
+ return result.to_dict()
@@ -0,0 +1,5 @@
1
+ """Errors raised by the dataset download service."""
2
+
3
+
4
+ class DownloadCancelledError(Exception):
5
+ """Raised when a dataset download is cancelled by the user."""
server/downloads.py ADDED
@@ -0,0 +1,129 @@
1
+ """Dataset downloads: isolated worker process, progress, and cancellation."""
2
+
3
+ import asyncio
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ from typing import Any, Awaitable, Callable, Dict, Optional, Set
8
+
9
+ from server.download_errors import DownloadCancelledError
10
+ from spikeforge.config import DATA_DIR
11
+ from spikeforge.data.datasets import build_dataset
12
+
13
+ _POLL_SECONDS = 0.4
14
+
15
+ Emit = Callable[[Dict[str, Any]], Awaitable[None]]
16
+
17
+
18
+ def _spawn(dataset: str, train: bool) -> subprocess.Popen:
19
+ """Start the isolated downloader child process."""
20
+ return subprocess.Popen([
21
+ sys.executable,
22
+ "-m",
23
+ "spikeforge.data.download_cli",
24
+ dataset,
25
+ "1" if train else "0",
26
+ ])
27
+
28
+
29
+ def _dir_bytes(path: str) -> int:
30
+ """Sum the sizes of every file under ``path`` (best effort)."""
31
+ total = 0
32
+ for root, _dirs, files in os.walk(path):
33
+ for name in files:
34
+ try:
35
+ total += os.path.getsize(os.path.join(root, name))
36
+ except OSError:
37
+ continue
38
+ return total
39
+
40
+
41
+ class DownloadManager:
42
+ """Prepare datasets off the event loop and stream progress to a client."""
43
+
44
+ def __init__(self) -> None:
45
+ """Start idle, with an empty set of already-prepared datasets."""
46
+ self._prepared: Set[str] = set()
47
+ self._process: Optional[subprocess.Popen] = None
48
+ self._dataset = ""
49
+ self._status = "idle"
50
+ self._bytes = 0
51
+ self._baseline = 0
52
+
53
+ def snapshot(self) -> Dict[str, Any]:
54
+ """Return the current download state for the client."""
55
+ return {
56
+ "dataset": self._dataset,
57
+ "status": self._status,
58
+ "bytes": self._bytes,
59
+ }
60
+
61
+ def cancel(self) -> None:
62
+ """Terminate the worker process if a download is in flight."""
63
+ if self._process is not None and self._process.poll() is None:
64
+ self._process.terminate()
65
+ self._status = "cancelled"
66
+
67
+ async def ensure(self, dataset: str, train: bool, emit: Emit) -> None:
68
+ """Ensure ``dataset`` is on disk, emitting progress until it is."""
69
+ if dataset in self._prepared:
70
+ return
71
+ loop = asyncio.get_running_loop()
72
+ if await loop.run_in_executor(None, self._loads, dataset, train):
73
+ self._prepared.add(dataset)
74
+ return
75
+ await self._download(loop, dataset, train, emit)
76
+ self._prepared.add(dataset)
77
+
78
+ @staticmethod
79
+ def _loads(dataset: str, train: bool) -> bool:
80
+ """Return True when the dataset is already available locally."""
81
+ try:
82
+ build_dataset(dataset, train=train, download=False)
83
+ return True
84
+ except (RuntimeError, OSError, ValueError, KeyError):
85
+ return False
86
+
87
+ async def _download(
88
+ self,
89
+ loop: asyncio.AbstractEventLoop,
90
+ dataset: str,
91
+ train: bool,
92
+ emit: Emit,
93
+ ) -> None:
94
+ """Run the worker process, polling progress until it exits."""
95
+ self._dataset = dataset
96
+ self._status = "downloading"
97
+ self._baseline = _dir_bytes(DATA_DIR)
98
+ self._bytes = 0
99
+ await emit(self.snapshot())
100
+ self._process = _spawn(dataset, train)
101
+ await self._poll(loop, emit)
102
+ await self._finish(emit)
103
+
104
+ async def _poll(
105
+ self, loop: asyncio.AbstractEventLoop, emit: Emit
106
+ ) -> None:
107
+ """Emit progress snapshots until the worker process exits."""
108
+ while self._process is not None and self._process.poll() is None:
109
+ current = await loop.run_in_executor(None, _dir_bytes, DATA_DIR)
110
+ self._bytes = max(0, current - self._baseline)
111
+ await emit(self.snapshot())
112
+ await asyncio.sleep(_POLL_SECONDS)
113
+
114
+ async def _finish(self, emit: Emit) -> None:
115
+ """Resolve the terminal state and report it to the client."""
116
+ process = self._process
117
+ self._process = None
118
+ if self._status == "cancelled":
119
+ await emit(self.snapshot())
120
+ raise DownloadCancelledError()
121
+ if process is None or process.returncode != 0:
122
+ self._status = "error"
123
+ await emit(self.snapshot())
124
+ raise RuntimeError("dataset download failed")
125
+ self._status = "done"
126
+ await emit(self.snapshot())
127
+
128
+
129
+ manager = DownloadManager()
server/encoder.py ADDED
@@ -0,0 +1,106 @@
1
+ """Render raw samples and encoded spike volumes from a client config."""
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ import torch
6
+
7
+ from server.schemas import EncodeConfig
8
+ from spikeforge.data.sample_source import SampleSource
9
+ from spikeforge.encoding.spike_encoder import SpikeEncoder
10
+
11
+
12
+ def to_list(tensor: torch.Tensor) -> Any:
13
+ """Convert a tensor to a nested python list of floats."""
14
+ return tensor.detach().cpu().float().tolist()
15
+
16
+
17
+ class EncoderEngine:
18
+ """Build a dataset-backed sample and its encoded spike volume."""
19
+
20
+ def __init__(self, config: EncodeConfig) -> None:
21
+ """Encode the selected sample under the client's config."""
22
+ self._config = config
23
+ self._source = SampleSource(config.dataset, size=config.input_size)
24
+ self._index = self._source.clamp(config.sample_index)
25
+ self._encoder = SpikeEncoder.from_encode_config(config)
26
+ self._image = self._source.image(self._index)
27
+ self._spikes = self._encoder.encode_image(self._image)
28
+
29
+ # --- payload builders -------------------------------------------------
30
+
31
+ def sample_image(self) -> Any:
32
+ """Return the raw input image grid as a nested list."""
33
+ return to_list(self._image[0])
34
+
35
+ def sample_tensor(self) -> torch.Tensor:
36
+ """Return the raw input image as a ``[C, H, W]`` tensor."""
37
+ return self._image
38
+
39
+ def reconstruction(self) -> Optional[Dict[str, Any]]:
40
+ """Return averaged spike reconstructions for the rate coding."""
41
+ if self._config.coding != "rate":
42
+ return None
43
+ height, width = self._source.size
44
+ gain1 = self._spikes.mean(dim=0)[0].reshape(height, width)
45
+ low = gain1 * self._config.gain
46
+ return {
47
+ "gain1": to_list(gain1),
48
+ "low": to_list(low),
49
+ "size": [height, width],
50
+ }
51
+
52
+ def spike_frame(self, step: int) -> Any:
53
+ """Return one 2-D spike frame of the selected sample."""
54
+ height, width = self._source.size
55
+ index = min(max(int(step), 0), self._spikes.size(0) - 1)
56
+ return to_list(self._spikes[index, 0].reshape(height, width))
57
+
58
+ def spike_tensor(self) -> torch.Tensor:
59
+ """Return the full [T,1,784] spike volume."""
60
+ return self._spikes
61
+
62
+ def raster(self, max_neurons: int = 784) -> Dict[str, Any]:
63
+ """Return (time, neuron) spike coordinates for the sample."""
64
+ matrix = self._spikes[:, 0, :]
65
+ time_idx, neuron_idx = torch.where(matrix > 0)
66
+ return {
67
+ "time": to_list(time_idx),
68
+ "neurons": to_list(neuron_idx),
69
+ "num_steps": int(matrix.size(0)),
70
+ "num_neurons": int(matrix.size(1)),
71
+ }
72
+
73
+ def num_steps(self) -> int:
74
+ """Return the number of encoded time steps."""
75
+ return int(self._spikes.size(0))
76
+
77
+ def target_label(self) -> int:
78
+ """Return the label of the selected sample."""
79
+ return self.sample_label()
80
+
81
+ def sample_label(self) -> int:
82
+ """Return the label of the selected sample."""
83
+ return self._source.label(self._index)
84
+
85
+ def sample_index(self) -> int:
86
+ """Return the clamped index of the selected sample."""
87
+ return self._index
88
+
89
+ def spike_input(self) -> torch.Tensor:
90
+ """Return the [T,1,784] spikes for training/inference."""
91
+ return self._spikes
92
+
93
+ @property
94
+ def encoder(self) -> SpikeEncoder:
95
+ """Return the encoder that produced the sample's spikes."""
96
+ return self._encoder
97
+
98
+ @property
99
+ def dataset(self) -> str:
100
+ """Return the registry key of the loaded sample source."""
101
+ return self._source.dataset
102
+
103
+ @property
104
+ def modality(self) -> str:
105
+ """Return the sample modality, always ``image``."""
106
+ return "image"