spikeforge-serve 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: spikeforge-serve
3
+ Version: 0.1.0
4
+ Summary: Headless REST/WebSocket inference service for a spikeforge deployment bundle.
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,inference,serving,fastapi
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: fastapi>=0.110
25
+ Requires-Dist: uvicorn[standard]>=0.27
26
+ Requires-Dist: spikeforge~=0.3.0
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,94 @@
1
+ # spikeforge
2
+
3
+ [![CI](https://github.com/capsize-games/spikeforge/actions/workflows/ci.yml/badge.svg)](https://github.com/capsize-games/spikeforge/actions/workflows/ci.yml)
4
+ [![Status: pre-1.0](https://img.shields.io/badge/status-pre--1.0-orange.svg)](OPEN_SOURCE_CHECKLIST.md)
5
+ [![License: BSD-3-Clause](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](LICENSE)
6
+ [![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/)
7
+ [![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)
8
+ [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/capsize-games/spikeforge/blob/main/CONTRIBUTING.md)
9
+ [![Docs](https://img.shields.io/badge/docs-long--form%20reference-blue.svg)](documentation/README.md)
10
+
11
+ A spiking-neural-network (SNN) toolkit built on
12
+ [snnTorch](https://snntorch.readthedocs.io/) and PyTorch. Loads MNIST-style
13
+ and neuromorphic event datasets, encodes them into rate, latency, delta, and
14
+ random spikes, and trains, validates, exports, and deploys LIF networks —
15
+ with a live browser dashboard served over WebSockets.
16
+
17
+ > **Pre-1.0 and unpublished.** `install.sh` is the supported path today;
18
+ > PyPI ships once the distributions are published. Before trusting any
19
+ > number this produces, read
20
+ > [Implications and boundaries](documentation/implications-and-boundaries.md).
21
+
22
+ ## Quickstart
23
+
24
+ ```bash
25
+ git clone https://github.com/capsize-games/spikeforge.git
26
+ cd spikeforge
27
+ docker compose up --build
28
+ ```
29
+
30
+ Open <http://localhost:8877> — the dashboard connects to the WebSocket on
31
+ the same host and port. No separate backend or proxy to run.
32
+
33
+ Prefer a local install, a headless example, or the CLI tools instead? See
34
+ [Usage](documentation/usage.md) and [Quickstart](documentation/quickstart.md)
35
+ for every path (`./install.sh`, local dev with Vite, `examples/`, and the
36
+ eight `spikeforge-*` console scripts).
37
+
38
+ ## Features
39
+
40
+ - **Encoding** — rate, latency, delta, and random spike coders.
41
+ - **Training** — fully-connected and convolutional LIF networks with
42
+ surrogate-gradient cross-entropy, checkpointing, and opt-in AMP / gradient
43
+ checkpointing / truncated BPTT / multi-GPU.
44
+ - **Topologies** — `fc_legacy`, `fc_small`, `conv_net`, `recurrent_net`, plus
45
+ the sequence presets `sequence_mlp` and `sequence_attn`.
46
+ - **Datasets** — MNIST, Fashion-MNIST, KMNIST, QMNIST, USPS, EMNIST,
47
+ CIFAR-10, and (via the `events` extra) N-MNIST, DVS128 Gesture,
48
+ CIFAR10-DVS, and Spiking Speech Commands.
49
+ - **Interpreter spine** — NIR export, an independent NIR interpreter, and
50
+ numerical drift validation.
51
+ - **Introspection** — educational-mode `U[t]`/`I[t]`/`S[t]` traces,
52
+ trajectory metrics, and surrogate-derivative curves.
53
+ - **Deployment** — a capability matrix, weight quantization, energy
54
+ accounting, and executable `reference`, `norse`, and `lava_loihi2`
55
+ backends.
56
+ - **Model hub** — a curated, offline-first catalog plus optional live
57
+ Hugging Face search.
58
+ - **Dashboard** — a React + TypeScript UI with training, introspection,
59
+ analysis, targets, energy, and hub panels, and seven guided walkthroughs.
60
+
61
+ ## Packages
62
+
63
+ This repository is a single workspace that publishes four distributions:
64
+
65
+ | Distribution | Import root | Purpose |
66
+ |---|---|---|
67
+ | `spikeforge` | `spikeforge` | Core package: encoders, topologies, training, simulator, NIR bridge, tracking |
68
+ | `spikeforge-targets` | `spikeforge_targets` | Deployment targets, quantization, energy accounting, sparse event runtime |
69
+ | `spikeforge-hub` | `spikeforge_hub` | Curated model hub and optional Hugging Face access |
70
+ | `spikeforge-server` | `server` | FastAPI + WebSocket server and dashboard hosting |
71
+
72
+ ## Documentation
73
+
74
+ This README stays short on purpose.
75
+ [`documentation/`](documentation/README.md) is the full reference — install
76
+ paths, the CLI tools, architecture, module layout, and the dev workflow —
77
+ written for contributors and coding agents alike. Also see
78
+ [COOKBOOK.md](COOKBOOK.md) for copy-pasteable recipes,
79
+ [examples/](examples/) for runnable end-to-end scripts, and
80
+ [plans/](plans/) for design documents and the roadmap.
81
+
82
+ See [CONTRIBUTING.md](https://github.com/capsize-games/spikeforge/blob/main/CONTRIBUTING.md)
83
+ and [rules.md](rules.md) before opening a pull request.
84
+
85
+ ## Citing
86
+
87
+ If spikeforge is useful in your research, please cite it — see
88
+ [CITATION.cff](CITATION.cff) (GitHub renders a "Cite this repository"
89
+ button from it automatically).
90
+
91
+ ## License
92
+
93
+ Released under the **BSD 3-Clause License** — see [LICENSE](LICENSE) and
94
+ [AUTHORS](AUTHORS).
@@ -0,0 +1,82 @@
1
+ # PEP 621 packaging authority for the spikeforge-serve distribution (PT-W3).
2
+ #
3
+ # The serve distribution owns the top-level ``spikeforge_serve`` package: the
4
+ # headless, REST/WebSocket inference service that loads a ``.spkf`` deployment
5
+ # bundle. FastAPI/uvicorn are this distribution's dependencies and never the
6
+ # core's, so ``pip install spikeforge`` stays web-framework free.
7
+ #
8
+ # ``spikeforge_serve/`` stays at its repository path and is exposed to the
9
+ # build via the ``spikeforge_serve`` symlink next to this file (see the
10
+ # ``packages.find`` table below), mirroring ``spikeforge-server``. Discovery
11
+ # depends only on this top-level package, never on core.
12
+
13
+ [build-system]
14
+ requires = ["setuptools>=68"]
15
+ build-backend = "setuptools.build_meta"
16
+
17
+ [project]
18
+ name = "spikeforge-serve"
19
+ version = "0.1.0"
20
+ description = "Headless REST/WebSocket inference service for a spikeforge deployment bundle."
21
+ # ``README.md`` and ``LICENSE`` are committed symlinks to the repository-root
22
+ # files, mirroring the core and server distributions' metadata approach.
23
+ readme = "README.md"
24
+ requires-python = ">=3.10"
25
+ license = "BSD-3-Clause"
26
+ license-files = ["LICENSE"]
27
+ authors = [
28
+ { name = "Capsize LLC", email = "contact@capsizegames.com" },
29
+ ]
30
+ maintainers = [
31
+ { name = "Capsize LLC", email = "contact@capsizegames.com" },
32
+ ]
33
+ keywords = [
34
+ "spiking neural networks",
35
+ "inference",
36
+ "serving",
37
+ "fastapi",
38
+ ]
39
+ classifiers = [
40
+ "Development Status :: 4 - Beta",
41
+ "Intended Audience :: Developers",
42
+ "Intended Audience :: Science/Research",
43
+ "Operating System :: OS Independent",
44
+ "Programming Language :: Python :: 3",
45
+ "Programming Language :: Python :: 3.10",
46
+ "Programming Language :: Python :: 3.11",
47
+ "Programming Language :: Python :: 3.12",
48
+ "Programming Language :: Python :: 3.13",
49
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
50
+ ]
51
+ dependencies = [
52
+ "fastapi>=0.110",
53
+ "uvicorn[standard]>=0.27",
54
+ "spikeforge~=0.3.0",
55
+ ]
56
+
57
+ [project.optional-dependencies]
58
+ dev = [
59
+ "pytest>=7.0",
60
+ "pytest-cov>=4.0",
61
+ "ruff>=0.0.280",
62
+ ]
63
+
64
+ [project.scripts]
65
+ spikeforge-serve = "spikeforge_serve.__main__:main"
66
+
67
+ [project.urls]
68
+ Homepage = "https://github.com/capsize-games/spikeforge"
69
+ Repository = "https://github.com/capsize-games/spikeforge"
70
+
71
+ [tool.setuptools]
72
+ zip-safe = false
73
+
74
+ [tool.setuptools.packages.find]
75
+ # The `spikeforge_serve` import root is exposed next to this file as a symlink
76
+ # to `../../spikeforge_serve` so the default via-sdist `python -m build`
77
+ # produces a complete wheel. Discovery depends only on this top-level package,
78
+ # never on core: the bare ``spikeforge``/``spikeforge.*`` patterns exclude the
79
+ # core root without catching ``spikeforge_serve``.
80
+ where = ["."]
81
+ include = ["spikeforge_serve*"]
82
+ exclude = ["spikeforge", "spikeforge.*", "server*", "tests*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,16 @@
1
+ """Headless HTTP inference service for a ``.spkf`` deployment bundle.
2
+
3
+ This distribution owns the FastAPI/uvicorn dependency: the ``spikeforge`` core
4
+ must stay web-framework free, so the ASGI surface and the pure serving core
5
+ live here and only here.
6
+ """
7
+
8
+ from spikeforge_serve.app import SERVE_VERSION, create_app
9
+ from spikeforge_serve.service import DEFAULT_SESSION, ServingService
10
+
11
+ __all__ = [
12
+ "DEFAULT_SESSION",
13
+ "SERVE_VERSION",
14
+ "ServingService",
15
+ "create_app",
16
+ ]
@@ -0,0 +1,66 @@
1
+ """Run the headless inference service: ``python -m spikeforge_serve``."""
2
+
3
+ import argparse
4
+ from typing import Optional, Sequence
5
+
6
+ import uvicorn
7
+
8
+ from spikeforge_serve.app import create_app
9
+
10
+ #: Default bind address and port for the serving API.
11
+ DEFAULT_HOST = "0.0.0.0"
12
+ DEFAULT_PORT = 8899
13
+
14
+
15
+ def _parse_args(argv: Optional[Sequence[str]]) -> argparse.Namespace:
16
+ """Parse the bundle path and the optional bind arguments."""
17
+ parser = argparse.ArgumentParser(
18
+ prog="spikeforge-serve",
19
+ description="Serve a .spkf deployment bundle headlessly.",
20
+ )
21
+ parser.add_argument(
22
+ "--bundle",
23
+ required=True,
24
+ help="path to the .spkf deployment bundle to serve",
25
+ )
26
+ parser.add_argument(
27
+ "--host",
28
+ default=DEFAULT_HOST,
29
+ help=f"bind address (default: {DEFAULT_HOST})",
30
+ )
31
+ parser.add_argument(
32
+ "--port",
33
+ type=int,
34
+ default=DEFAULT_PORT,
35
+ help=f"bind port (default: {DEFAULT_PORT})",
36
+ )
37
+ parser.add_argument(
38
+ "--device",
39
+ default="cpu",
40
+ help="torch device the sessions run on (default: cpu)",
41
+ )
42
+ parser.add_argument(
43
+ "--metrics-token",
44
+ dest="metrics_token",
45
+ default=None,
46
+ help=(
47
+ "bearer token required by /metrics (also read from "
48
+ "SPIKEFORGE_SERVE_METRICS_TOKEN; unset leaves /metrics open)"
49
+ ),
50
+ )
51
+ return parser.parse_args(argv)
52
+
53
+
54
+ def main(argv: Optional[Sequence[str]] = None) -> None:
55
+ """Build the app for ``--bundle`` and run it under uvicorn."""
56
+ args = _parse_args(argv)
57
+ app = create_app(
58
+ args.bundle,
59
+ device=args.device,
60
+ metrics_token=args.metrics_token,
61
+ )
62
+ uvicorn.run(app, host=args.host, port=args.port)
63
+
64
+
65
+ if __name__ == "__main__":
66
+ main()
@@ -0,0 +1,337 @@
1
+ """The FastAPI application factory for ``spikeforge-serve``.
2
+
3
+ ``create_app`` builds the whole ASGI surface from one bundle without binding a
4
+ port, so tests and embedding callers can hand the app to any ASGI client. The
5
+ routes are intentionally thin wrappers over
6
+ :class:`~spikeforge_serve.service.ServingService`, and every
7
+ :class:`~spikeforge.serving.errors.ServingError` is mapped to an honest HTTP
8
+ status instead of a stack trace.
9
+ """
10
+
11
+ import hmac
12
+ import os
13
+ import time
14
+ from typing import Any, Dict, Mapping, Optional, Tuple, Union
15
+
16
+ from fastapi import (
17
+ FastAPI,
18
+ HTTPException,
19
+ Request,
20
+ Response,
21
+ WebSocket,
22
+ WebSocketDisconnect,
23
+ )
24
+ from fastapi.responses import JSONResponse
25
+
26
+ from spikeforge.observability import prometheus
27
+ from spikeforge.runtime.execution_mode import ExecutionMode
28
+ from spikeforge.serving.bundle import DeploymentBundle
29
+ from spikeforge.serving.errors import ServingError
30
+ from spikeforge_serve import metrics as serve_metrics
31
+ from spikeforge_serve.errors import error_body, status_for
32
+ from spikeforge_serve.payloads import (
33
+ encoded_flag,
34
+ frames_from,
35
+ prediction_json,
36
+ session_id_from,
37
+ )
38
+ from spikeforge_serve.service import DEFAULT_SESSION, ServingService
39
+
40
+ #: Version of the HTTP surface (distinct from the package version).
41
+ SERVE_VERSION = "0.1.0"
42
+
43
+ #: Environment variable that opts ``/metrics`` into bearer auth when no token
44
+ #: is passed to :func:`create_app`. An empty value leaves the route open.
45
+ METRICS_TOKEN_ENV = "SPIKEFORGE_SERVE_METRICS_TOKEN"
46
+
47
+
48
+ class MetricsMiddleware:
49
+ """Record request count, latency, errors, and in-flight for HTTP calls.
50
+
51
+ A tiny pure-ASGI wrapper rather than a framework middleware, so it works
52
+ with the dependency-free ASGI driver the tests use. The ``/metrics``
53
+ scrape itself is passed through untouched so a scrape never inflates the
54
+ counters it is reading.
55
+ """
56
+
57
+ def __init__(self, app: Any) -> None:
58
+ """Wrap the ASGI ``app`` below this middleware."""
59
+ self._app = app
60
+
61
+ async def __call__(
62
+ self, scope: Any, receive: Any, send: Any
63
+ ) -> None:
64
+ """Instrument one ASGI call when it is an ordinary HTTP request."""
65
+ path = scope.get("path")
66
+ if scope.get("type") != "http" or path == serve_metrics.METRICS_PATH:
67
+ await self._app(scope, receive, send)
68
+ return
69
+ status = {"code": 500}
70
+
71
+ async def send_wrapper(message: Any) -> None:
72
+ """Capture the response status before forwarding it."""
73
+ if message.get("type") == "http.response.start":
74
+ status["code"] = int(message.get("status", 500))
75
+ await send(message)
76
+
77
+ start = time.perf_counter()
78
+ with serve_metrics.track_in_flight():
79
+ try:
80
+ await self._app(scope, receive, send_wrapper)
81
+ finally:
82
+ serve_metrics.observe_request(
83
+ status["code"], serve_metrics.elapsed_since(start)
84
+ )
85
+
86
+
87
+ def _resolve_token(metrics_token: Optional[str]) -> Optional[str]:
88
+ """Return the configured ``/metrics`` token, or None when auth is off."""
89
+ if metrics_token is not None:
90
+ return metrics_token or None
91
+ return os.environ.get(METRICS_TOKEN_ENV) or None
92
+
93
+
94
+ def _authorized(header: Optional[str], token: Optional[str]) -> bool:
95
+ """Return True when ``header`` carries the expected bearer ``token``."""
96
+ if not token:
97
+ return True
98
+ if not header:
99
+ return False
100
+ scheme, _, value = header.partition(" ")
101
+ if scheme.lower() != "bearer":
102
+ return False
103
+ return hmac.compare_digest(value.strip(), token)
104
+
105
+
106
+ def create_app(
107
+ bundle: Union[str, DeploymentBundle],
108
+ device: Union[str, Any] = "cpu",
109
+ mode: ExecutionMode = ExecutionMode.PRODUCTION,
110
+ service: Optional[ServingService] = None,
111
+ title: str = "spikeforge-serve",
112
+ version: str = SERVE_VERSION,
113
+ metrics_token: Optional[str] = None,
114
+ ) -> FastAPI:
115
+ """Build the ASGI app that serves ``bundle`` without binding a port.
116
+
117
+ Tests hand the returned app to any ASGI client. The bundle is loaded
118
+ lazily, so a missing or malformed artifact is reported as a typed HTTP
119
+ error rather than crashing the process before the first request.
120
+
121
+ When ``metrics_token`` (or ``SPIKEFORGE_SERVE_METRICS_TOKEN``) is set,
122
+ ``/metrics`` requires a matching ``Authorization: Bearer`` header; with
123
+ no token the route is open, preserving the existing behaviour.
124
+ """
125
+ serving = service or ServingService(bundle, device=device, mode=mode)
126
+ app = FastAPI(title=title, version=version)
127
+ app.state.serving = serving
128
+ app.state.metrics_token = _resolve_token(metrics_token)
129
+ app.add_middleware(MetricsMiddleware)
130
+ _install_handlers(app)
131
+ _install_routes(app, serving)
132
+ return app
133
+
134
+
135
+ def _install_handlers(app: FastAPI) -> None:
136
+ """Register the mapping from serving errors to HTTP responses."""
137
+
138
+ @app.exception_handler(ServingError)
139
+ async def _serving_error(
140
+ _request: Request, error: ServingError
141
+ ) -> JSONResponse:
142
+ """Return the taxonomy-mapped status and a typed error body."""
143
+ return JSONResponse(
144
+ status_code=status_for(error), content=error_body(error)
145
+ )
146
+
147
+
148
+ def _install_routes(app: FastAPI, serving: ServingService) -> None:
149
+ """Register the health, bundle, predict, reset, and stream routes."""
150
+
151
+ @app.get("/health")
152
+ async def health() -> Dict[str, str]:
153
+ """Liveness probe: the process is up."""
154
+ return {"status": "ok"}
155
+
156
+ @app.get("/metrics")
157
+ async def metrics_endpoint(request: Request) -> Response:
158
+ """Expose the shared registry as Prometheus text, gated by auth."""
159
+ token = getattr(request.app.state, "metrics_token", None)
160
+ if not _authorized(request.headers.get("authorization"), token):
161
+ return Response(
162
+ content="unauthorized\n",
163
+ status_code=401,
164
+ media_type="text/plain",
165
+ headers={"WWW-Authenticate": "Bearer"},
166
+ )
167
+ return Response(
168
+ content=serve_metrics.render_text(),
169
+ media_type=prometheus.CONTENT_TYPE,
170
+ )
171
+
172
+ @app.get("/ready")
173
+ async def ready() -> Any:
174
+ """Readiness probe: the bundle loaded and sessions can run."""
175
+ try:
176
+ _ = serving.bundle
177
+ except ServingError as error:
178
+ return JSONResponse(
179
+ status_code=503, content=error_body(error)
180
+ )
181
+ return {"status": "ready"}
182
+
183
+ @app.get("/v1/bundle")
184
+ async def bundle_info() -> Dict[str, Any]:
185
+ """Return the loaded bundle's self-describing metadata."""
186
+ return _bundle_info(serving.bundle)
187
+
188
+ @app.post("/v1/predict")
189
+ async def predict(request: Request) -> Dict[str, Any]:
190
+ """Stream the request's frames through a session."""
191
+ payload = await _body(request)
192
+ try:
193
+ frames = frames_from(payload)
194
+ encoded = encoded_flag(payload)
195
+ session_id = session_id_from(payload)
196
+ predictions = serving.predict(
197
+ frames, session_id=session_id, encoded=encoded
198
+ )
199
+ except (TypeError, ValueError) as error:
200
+ raise HTTPException(
201
+ status_code=400, detail=str(error)
202
+ ) from None
203
+ return {
204
+ "session_id": session_id,
205
+ "steps": serving.session(session_id).steps,
206
+ "predictions": [
207
+ prediction_json(item) for item in predictions
208
+ ],
209
+ }
210
+
211
+ @app.post("/v1/reset")
212
+ async def reset(request: Request) -> Dict[str, Any]:
213
+ """Clear a session's temporal state."""
214
+ payload = await _optional_body(request)
215
+ try:
216
+ session_id = session_id_from(payload)
217
+ steps = serving.reset(session_id)
218
+ except (TypeError, ValueError) as error:
219
+ raise HTTPException(
220
+ status_code=400, detail=str(error)
221
+ ) from None
222
+ return {"session_id": session_id, "steps": steps}
223
+
224
+ @app.get("/v1/stream")
225
+ async def stream_info() -> Dict[str, Any]:
226
+ """Describe the WebSocket streaming protocol."""
227
+ return {
228
+ "protocol": "websocket",
229
+ "path": "/v1/stream",
230
+ "frames": "raw samples or pre-encoded frames",
231
+ "messages": {
232
+ "predict": {"frame": "<sample or frame>"},
233
+ "reset": {"reset": True},
234
+ },
235
+ }
236
+
237
+ @app.websocket("/v1/stream")
238
+ async def stream(ws: WebSocket) -> None:
239
+ """Stream one step per frame, with on-demand reset."""
240
+ await ws.accept()
241
+ session_id = DEFAULT_SESSION
242
+ try:
243
+ while True:
244
+ message = await ws.receive_json()
245
+ serve_metrics.count_stream_frames()
246
+ session_id, reply = _stream_reply(
247
+ serving, session_id, message
248
+ )
249
+ await ws.send_json(reply)
250
+ except WebSocketDisconnect:
251
+ return
252
+
253
+
254
+ async def _body(request: Request) -> Any:
255
+ """Return the request's JSON body, or raise a 400."""
256
+ try:
257
+ return await request.json()
258
+ except Exception as error: # any parse failure is a client error
259
+ raise HTTPException(
260
+ status_code=400,
261
+ detail=f"request body is not valid JSON: {error}",
262
+ ) from None
263
+
264
+
265
+ async def _optional_body(request: Request) -> Dict[str, Any]:
266
+ """Return the JSON object body, treating an empty body as ``{}``."""
267
+ raw = await request.body()
268
+ if not raw:
269
+ return {}
270
+ return await _body(request)
271
+
272
+
273
+ def _stream_reply(
274
+ serving: ServingService, session_id: str, message: Any
275
+ ) -> Tuple[str, Dict[str, Any]]:
276
+ """Return ``(session_id, reply)`` for one stream message."""
277
+ if not isinstance(message, Mapping):
278
+ return session_id, _stream_error(
279
+ "stream message must be a JSON object"
280
+ )
281
+ try:
282
+ if "session_id" in message:
283
+ session_id = session_id_from(message)
284
+ if message.get("reset"):
285
+ steps = serving.reset(session_id)
286
+ return session_id, {
287
+ "type": "reset",
288
+ "payload": {"session_id": session_id, "steps": steps},
289
+ }
290
+ if "frame" not in message:
291
+ return session_id, {
292
+ "type": "session",
293
+ "payload": {"session_id": session_id},
294
+ }
295
+ encoded = encoded_flag(message)
296
+ prediction = serving.predict(
297
+ [message["frame"]], session_id=session_id, encoded=encoded
298
+ )[0]
299
+ payload = prediction_json(prediction)
300
+ payload["session_id"] = session_id
301
+ payload["steps"] = serving.session(session_id).steps
302
+ return session_id, {"type": "prediction", "payload": payload}
303
+ except (TypeError, ValueError) as error:
304
+ return session_id, _stream_error(str(error))
305
+
306
+
307
+ def _stream_error(detail: str) -> Dict[str, Any]:
308
+ """Return a stream error envelope for ``detail``."""
309
+ return {
310
+ "type": "error",
311
+ "payload": {"type": "bad_request", "message": detail},
312
+ }
313
+
314
+
315
+ def _bundle_info(bundle: DeploymentBundle) -> Dict[str, Any]:
316
+ """Return the self-describing metadata a client needs for the model."""
317
+ manifest = bundle.manifest
318
+ spec = bundle.encode_spec()
319
+ encode = spec.to_dict()
320
+ return {
321
+ "format": manifest.get("format"),
322
+ "version": manifest.get("version"),
323
+ "path": bundle.path,
324
+ "topology": manifest.get("topology"),
325
+ "topology_params": dict(manifest.get("topology_params") or {}),
326
+ "spec": manifest.get("spec"),
327
+ "protocol_version": manifest.get("protocol_version"),
328
+ "encode_spec_version": manifest.get("encode_spec_version"),
329
+ "encode_spec": encode,
330
+ "encode_digest": spec.digest(),
331
+ "input_size": encode["input_size"],
332
+ "num_classes": manifest.get("num_classes"),
333
+ "num_steps": manifest.get("num_steps"),
334
+ "label_map": dict(manifest.get("label_map") or {}),
335
+ "expected_metrics": dict(manifest.get("expected_metrics") or {}),
336
+ "library_versions": dict(manifest.get("library_versions") or {}),
337
+ }
@@ -0,0 +1,60 @@
1
+ """Map the ``spikeforge.serving`` error taxonomy onto HTTP responses.
2
+
3
+ The service never invents its own error types: it forwards the typed errors
4
+ :mod:`spikeforge.serving.errors` already raises (a missing, malformed,
5
+ tampered, or incompatible bundle; a bad encode spec; a bad carried state) and
6
+ assigns each an honest HTTP status so a client can react without parsing
7
+ prose.
8
+ """
9
+
10
+ import re
11
+ from typing import Any, Dict, Tuple, Type
12
+
13
+ from spikeforge.serving.errors import (
14
+ BundleCompatibilityError,
15
+ BundleFormatError,
16
+ BundleIntegrityError,
17
+ BundleNotFoundError,
18
+ EncodeSpecError,
19
+ ServingError,
20
+ StateError,
21
+ )
22
+
23
+ #: ``(error class, status)`` pairs, most specific first.
24
+ _STATUS: Tuple[Tuple[Type[ServingError], int], ...] = (
25
+ (BundleNotFoundError, 404),
26
+ (BundleCompatibilityError, 409),
27
+ (BundleIntegrityError, 422),
28
+ (BundleFormatError, 422),
29
+ (EncodeSpecError, 422),
30
+ (StateError, 409),
31
+ )
32
+
33
+
34
+ def status_for(error: ServingError) -> int:
35
+ """Return the HTTP status code that fits ``error``."""
36
+ for error_type, status in _STATUS:
37
+ if isinstance(error, error_type):
38
+ return status
39
+ return 500
40
+
41
+
42
+ def error_type(error: ServingError) -> str:
43
+ """Return ``error``'s snake_case type name for the JSON payload."""
44
+ name = type(error).__name__
45
+ if name.endswith("Error"):
46
+ name = name[: -len("Error")]
47
+ return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
48
+
49
+
50
+ def error_body(error: ServingError) -> Dict[str, Any]:
51
+ """Return a JSON-ready body describing ``error``."""
52
+ payload: Dict[str, Any] = {
53
+ "type": error_type(error),
54
+ "message": str(error),
55
+ }
56
+ for attribute in ("detail", "path", "entry"):
57
+ value = getattr(error, attribute, None)
58
+ if value is not None:
59
+ payload[attribute] = value
60
+ return {"error": payload}
@@ -0,0 +1,112 @@
1
+ """Serving-level instrumentation wired into the core observability registry.
2
+
3
+ PT-W6 asks for request-level counters and a latency histogram over the
4
+ existing :mod:`spikeforge.observability` registry, so the service never grows
5
+ a second metrics store. Every name is dotted and therefore sanitised by the
6
+ core Prometheus exporter (``serve.request_seconds`` becomes the histogram
7
+ ``serve_request_seconds``).
8
+
9
+ Recorded series:
10
+
11
+ * ``serve.requests`` - counter of handled HTTP requests;
12
+ * ``serve.errors`` - counter of responses with status >= 400;
13
+ * ``serve.in_flight`` - gauge of requests currently being handled;
14
+ * ``serve.request_seconds``- histogram of request latency;
15
+ * ``serve.steps`` - counter of timesteps the sessions advanced;
16
+ * ``serve.stream_frames`` - counter of WebSocket stream frames processed.
17
+
18
+ The ``/metrics`` scrape itself is *not* instrumented so a scrape cannot
19
+ inflate the request counters it is reading.
20
+ """
21
+
22
+ import threading
23
+ import time
24
+ from contextlib import contextmanager
25
+ from typing import Iterator
26
+
27
+ from spikeforge.observability import metrics, prometheus
28
+
29
+ #: Counter of handled HTTP requests.
30
+ REQUESTS = "serve.requests"
31
+ #: Counter of responses that failed (status >= 400).
32
+ ERRORS = "serve.errors"
33
+ #: Gauge of requests currently in flight.
34
+ IN_FLIGHT = "serve.in_flight"
35
+ #: Histogram of request latency, in seconds.
36
+ REQUEST_SECONDS = "serve.request_seconds"
37
+ #: Counter of timesteps advanced by the serving sessions.
38
+ STEPS = "serve.steps"
39
+ #: Counter of WebSocket frames processed by the stream route.
40
+ STREAM_FRAMES = "serve.stream_frames"
41
+
42
+ #: Path whose requests are excluded from the request counters.
43
+ METRICS_PATH = "/metrics"
44
+
45
+ #: Status at or above which a response counts as an error.
46
+ _ERROR_STATUS = 400
47
+
48
+
49
+ class _InFlight:
50
+ """Track concurrent requests and mirror the count into a gauge."""
51
+
52
+ def __init__(self) -> None:
53
+ """Create a zeroed in-flight counter."""
54
+ self._lock = threading.Lock()
55
+ self._count = 0
56
+
57
+ def enter(self) -> None:
58
+ """Record a request starting and publish the new gauge value."""
59
+ with self._lock:
60
+ self._count += 1
61
+ value = self._count
62
+ metrics.gauge(IN_FLIGHT, value)
63
+
64
+ def exit(self) -> None:
65
+ """Record a request finishing and publish the new gauge value."""
66
+ with self._lock:
67
+ self._count = max(0, self._count - 1)
68
+ value = self._count
69
+ metrics.gauge(IN_FLIGHT, value)
70
+
71
+
72
+ _in_flight = _InFlight()
73
+
74
+
75
+ @contextmanager
76
+ def track_in_flight() -> Iterator[None]:
77
+ """Count one request as in flight for the duration of the block."""
78
+ _in_flight.enter()
79
+ try:
80
+ yield
81
+ finally:
82
+ _in_flight.exit()
83
+
84
+
85
+ def observe_request(status: int, seconds: float) -> None:
86
+ """Record one finished request's status and latency."""
87
+ metrics.counter(REQUESTS)
88
+ metrics.observe(REQUEST_SECONDS, seconds)
89
+ if int(status) >= _ERROR_STATUS:
90
+ metrics.counter(ERRORS)
91
+
92
+
93
+ def count_steps(amount: int = 1) -> None:
94
+ """Add ``amount`` to the timestep counter."""
95
+ if amount:
96
+ metrics.counter(STEPS, amount)
97
+
98
+
99
+ def count_stream_frames(amount: int = 1) -> None:
100
+ """Add ``amount`` to the WebSocket frame counter."""
101
+ if amount:
102
+ metrics.counter(STREAM_FRAMES, amount)
103
+
104
+
105
+ def elapsed_since(start: float) -> float:
106
+ """Return the seconds elapsed since a ``perf_counter`` reading."""
107
+ return time.perf_counter() - start
108
+
109
+
110
+ def render_text() -> str:
111
+ """Render the shared registry as Prometheus exposition text."""
112
+ return prometheus.render(metrics.registry())
@@ -0,0 +1,60 @@
1
+ """JSON-shaped request and response payloads for the headless service."""
2
+
3
+ from typing import Any, Dict, List, Mapping
4
+
5
+ import torch
6
+
7
+ from spikeforge.serving.prediction import Prediction
8
+ from spikeforge_serve.service import DEFAULT_SESSION
9
+
10
+
11
+ def tensor_json(tensor: torch.Tensor) -> Dict[str, Any]:
12
+ """Return a typed JSON view of ``tensor`` (dtype, shape, values)."""
13
+ value = tensor.detach().cpu()
14
+ dtype = str(value.dtype).replace("torch.", "", 1)
15
+ return {
16
+ "dtype": dtype,
17
+ "shape": list(value.shape),
18
+ "values": value.tolist(),
19
+ }
20
+
21
+
22
+ def prediction_json(prediction: Prediction) -> Dict[str, Any]:
23
+ """Return ``prediction`` as a JSON-ready typed payload."""
24
+ return {
25
+ "steps": int(prediction.steps),
26
+ "label": int(prediction.label),
27
+ "predicted": int(prediction.predicted),
28
+ "logits": tensor_json(prediction.logits),
29
+ "mean_logits": tensor_json(prediction.mean_logits),
30
+ "class_totals": tensor_json(prediction.class_totals),
31
+ "spikes": {
32
+ name: tensor_json(value)
33
+ for name, value in prediction.spikes.items()
34
+ },
35
+ }
36
+
37
+
38
+ def frames_from(payload: Any) -> List[Any]:
39
+ """Return the non-empty ``frames`` list carried by a request body."""
40
+ if not isinstance(payload, Mapping):
41
+ raise ValueError("request body must be a JSON object")
42
+ frames = payload.get("frames")
43
+ if not isinstance(frames, list) or not frames:
44
+ raise ValueError("'frames' must be a non-empty list")
45
+ return list(frames)
46
+
47
+
48
+ def encoded_flag(payload: Mapping[str, Any]) -> bool:
49
+ """Return the request's ``encoded`` flag, defaulting to False."""
50
+ return bool(payload.get("encoded", False))
51
+
52
+
53
+ def session_id_from(payload: Mapping[str, Any]) -> str:
54
+ """Return the request's session id, defaulting to the shared one."""
55
+ value = payload.get("session_id")
56
+ if value is None:
57
+ return DEFAULT_SESSION
58
+ if not isinstance(value, str) or not value:
59
+ raise ValueError("'session_id' must be a non-empty string")
60
+ return value
@@ -0,0 +1,141 @@
1
+ """Bundle loading, session storage, and inference for ``spikeforge-serve``.
2
+
3
+ The service is a thin core over
4
+ :class:`~spikeforge.serving.session.InferenceSession`: it loads one
5
+ :class:`~spikeforge.serving.bundle.DeploymentBundle`, keeps a stateful session
6
+ per client id, and turns requests into steps. It imports no web framework, so
7
+ the ASGI layer and this core can be tested apart.
8
+ """
9
+
10
+ import threading
11
+ from typing import Any, Dict, Iterator, List, Optional, Union
12
+
13
+ import torch
14
+
15
+ from spikeforge.runtime.execution_mode import ExecutionMode
16
+ from spikeforge.serving.bundle import DeploymentBundle
17
+ from spikeforge.serving.prediction import Prediction
18
+ from spikeforge.serving.session import InferenceSession
19
+ from spikeforge_serve import metrics as serve_metrics
20
+
21
+ #: Session id used when a request does not name one.
22
+ DEFAULT_SESSION = "default"
23
+
24
+ #: A bundle may be passed as a path or already loaded.
25
+ BundleSource = Union[str, DeploymentBundle]
26
+
27
+
28
+ def _as_frame(frame: Any) -> torch.Tensor:
29
+ """Return ``frame`` as a float tensor with a leading batch dimension."""
30
+ tensor = torch.as_tensor(frame, dtype=torch.float32)
31
+ if tensor.dim() == 1:
32
+ return tensor.unsqueeze(0)
33
+ return tensor
34
+
35
+
36
+ class ServingService:
37
+ """Own one bundle and the stateful sessions that stream through it."""
38
+
39
+ def __init__(
40
+ self,
41
+ bundle: BundleSource,
42
+ device: Union[str, torch.device] = "cpu",
43
+ mode: ExecutionMode = ExecutionMode.PRODUCTION,
44
+ ) -> None:
45
+ """Remember the ``bundle`` source and the execution settings."""
46
+ self._device = device
47
+ self._mode = mode
48
+ self._bundle: Optional[DeploymentBundle] = (
49
+ bundle if isinstance(bundle, DeploymentBundle) else None
50
+ )
51
+ self._path: Optional[str] = (
52
+ bundle if isinstance(bundle, str) else None
53
+ )
54
+ self._sessions: Dict[str, InferenceSession] = {}
55
+ self._lock = threading.RLock()
56
+
57
+ @property
58
+ def bundle(self) -> DeploymentBundle:
59
+ """Return the loaded bundle, reading it from disk on first use."""
60
+ with self._lock:
61
+ return self._ensure_bundle()
62
+
63
+ def session(self, session_id: str = DEFAULT_SESSION) -> InferenceSession:
64
+ """Return the session for ``session_id``, creating it on first use."""
65
+ with self._lock:
66
+ sid = session_id or DEFAULT_SESSION
67
+ existing = self._sessions.get(sid)
68
+ if existing is None:
69
+ existing = InferenceSession.load(
70
+ self._ensure_bundle(),
71
+ device=self._device,
72
+ mode=self._mode,
73
+ )
74
+ self._sessions[sid] = existing
75
+ return existing
76
+
77
+ def reset(self, session_id: str = DEFAULT_SESSION) -> int:
78
+ """Clear ``session_id``'s temporal state and return its step count."""
79
+ with self._lock:
80
+ session = self.session(session_id)
81
+ session.reset()
82
+ return session.steps
83
+
84
+ def predict(
85
+ self,
86
+ frames: List[Any],
87
+ session_id: str = DEFAULT_SESSION,
88
+ encoded: bool = False,
89
+ ) -> List[Prediction]:
90
+ """Advance the session over ``frames``; one prediction per frame."""
91
+ with self._lock:
92
+ session = self.session(session_id)
93
+ before = session.steps
94
+ predictions = [
95
+ self._step(session, frame, encoded) for frame in frames
96
+ ]
97
+ serve_metrics.count_steps(session.steps - before)
98
+ return predictions
99
+
100
+ def stream(
101
+ self,
102
+ frames: Any,
103
+ session_id: str = DEFAULT_SESSION,
104
+ encoded: bool = False,
105
+ ) -> Iterator[Prediction]:
106
+ """Yield one prediction per frame, carrying the session's state."""
107
+ with self._lock:
108
+ session = self.session(session_id)
109
+ for frame in frames:
110
+ before = session.steps
111
+ prediction = self._step(session, frame, encoded)
112
+ serve_metrics.count_steps(session.steps - before)
113
+ yield prediction
114
+
115
+ def _ensure_bundle(self) -> DeploymentBundle:
116
+ """Return the cached bundle, loading it once from the path."""
117
+ if self._bundle is None and self._path is not None:
118
+ self._bundle = DeploymentBundle.load(self._path, strict=True)
119
+ if self._bundle is None: # pragma: no cover - defensive
120
+ raise RuntimeError("no bundle configured")
121
+ return self._bundle
122
+
123
+ def _step(
124
+ self, session: InferenceSession, frame: Any, encoded: bool
125
+ ) -> Prediction:
126
+ """Advance ``session`` one frame, encoding a raw sample when asked."""
127
+ if encoded:
128
+ return session.step(_as_frame(frame))
129
+ return self._run_sample(session, frame)
130
+
131
+ def _run_sample(
132
+ self, session: InferenceSession, sample: Any
133
+ ) -> Prediction:
134
+ """Encode one raw sample and stream its whole spike train."""
135
+ spikes = session.encode(sample)
136
+ prediction: Optional[Prediction] = None
137
+ for index in range(int(spikes.size(0))):
138
+ prediction = session.step(spikes[index])
139
+ if prediction is None: # pragma: no cover - a train is never empty
140
+ raise ValueError("encoded sample produced no timesteps")
141
+ return prediction
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: spikeforge-serve
3
+ Version: 0.1.0
4
+ Summary: Headless REST/WebSocket inference service for a spikeforge deployment bundle.
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,inference,serving,fastapi
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: fastapi>=0.110
25
+ Requires-Dist: uvicorn[standard]>=0.27
26
+ Requires-Dist: spikeforge~=0.3.0
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,17 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ spikeforge_serve/__init__.py
5
+ spikeforge_serve/__main__.py
6
+ spikeforge_serve/app.py
7
+ spikeforge_serve/errors.py
8
+ spikeforge_serve/metrics.py
9
+ spikeforge_serve/payloads.py
10
+ spikeforge_serve/service.py
11
+ spikeforge_serve.egg-info/PKG-INFO
12
+ spikeforge_serve.egg-info/SOURCES.txt
13
+ spikeforge_serve.egg-info/dependency_links.txt
14
+ spikeforge_serve.egg-info/entry_points.txt
15
+ spikeforge_serve.egg-info/not-zip-safe
16
+ spikeforge_serve.egg-info/requires.txt
17
+ spikeforge_serve.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ spikeforge-serve = spikeforge_serve.__main__:main
@@ -0,0 +1,8 @@
1
+ fastapi>=0.110
2
+ uvicorn[standard]>=0.27
3
+ spikeforge~=0.3.0
4
+
5
+ [dev]
6
+ pytest>=7.0
7
+ pytest-cov>=4.0
8
+ ruff>=0.0.280
@@ -0,0 +1 @@
1
+ spikeforge_serve