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.
- server/__init__.py +1 -0
- server/__main__.py +55 -0
- server/animation.py +76 -0
- server/app.py +154 -0
- server/backend_handlers.py +59 -0
- server/backend_payloads.py +53 -0
- server/download_errors.py +5 -0
- server/downloads.py +129 -0
- server/encoder.py +106 -0
- server/energy_handlers.py +62 -0
- server/energy_messages.py +22 -0
- server/energy_payloads.py +60 -0
- server/engine_factory.py +79 -0
- server/event_engine.py +145 -0
- server/handlers.py +247 -0
- server/hub_downloads.py +39 -0
- server/hub_handlers.py +117 -0
- server/hub_messages.py +51 -0
- server/hub_payloads.py +59 -0
- server/introspection_handlers.py +173 -0
- server/introspection_payloads.py +117 -0
- server/messages.py +232 -0
- server/model_handlers.py +56 -0
- server/model_payloads.py +26 -0
- server/nir_handlers.py +67 -0
- server/payloads.py +96 -0
- server/protocol_handlers.py +51 -0
- server/protocol_version.py +34 -0
- server/schemas/__init__.py +18 -0
- server/schemas/client_message.py +37 -0
- server/schemas/encode_config.py +34 -0
- server/schemas/hub_query.py +20 -0
- server/schemas/model_query.py +17 -0
- server/schemas/server_message.py +31 -0
- server/schemas/train_config.py +50 -0
- server/session.py +62 -0
- server/stats.py +28 -0
- server/target_handlers.py +117 -0
- server/target_payloads.py +52 -0
- server/training.py +223 -0
- server/web.py +63 -0
- spikeforge_server-0.1.0.dist-info/METADATA +130 -0
- spikeforge_server-0.1.0.dist-info/RECORD +47 -0
- spikeforge_server-0.1.0.dist-info/WHEEL +5 -0
- spikeforge_server-0.1.0.dist-info/entry_points.txt +2 -0
- spikeforge_server-0.1.0.dist-info/licenses/LICENSE +30 -0
- 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()
|
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"
|