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.
- spikeforge_serve-0.1.0/LICENSE +30 -0
- spikeforge_serve-0.1.0/PKG-INFO +126 -0
- spikeforge_serve-0.1.0/README.md +94 -0
- spikeforge_serve-0.1.0/pyproject.toml +82 -0
- spikeforge_serve-0.1.0/setup.cfg +4 -0
- spikeforge_serve-0.1.0/spikeforge_serve/__init__.py +16 -0
- spikeforge_serve-0.1.0/spikeforge_serve/__main__.py +66 -0
- spikeforge_serve-0.1.0/spikeforge_serve/app.py +337 -0
- spikeforge_serve-0.1.0/spikeforge_serve/errors.py +60 -0
- spikeforge_serve-0.1.0/spikeforge_serve/metrics.py +112 -0
- spikeforge_serve-0.1.0/spikeforge_serve/payloads.py +60 -0
- spikeforge_serve-0.1.0/spikeforge_serve/service.py +141 -0
- spikeforge_serve-0.1.0/spikeforge_serve.egg-info/PKG-INFO +126 -0
- spikeforge_serve-0.1.0/spikeforge_serve.egg-info/SOURCES.txt +17 -0
- spikeforge_serve-0.1.0/spikeforge_serve.egg-info/dependency_links.txt +1 -0
- spikeforge_serve-0.1.0/spikeforge_serve.egg-info/entry_points.txt +2 -0
- spikeforge_serve-0.1.0/spikeforge_serve.egg-info/not-zip-safe +1 -0
- spikeforge_serve-0.1.0/spikeforge_serve.egg-info/requires.txt +8 -0
- spikeforge_serve-0.1.0/spikeforge_serve.egg-info/top_level.txt +1 -0
|
@@ -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
|
+
[](https://github.com/capsize-games/spikeforge/actions/workflows/ci.yml)
|
|
36
|
+
[](OPEN_SOURCE_CHECKLIST.md)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
[](https://www.python.org/downloads/)
|
|
39
|
+
[](https://github.com/astral-sh/ruff)
|
|
40
|
+
[](https://github.com/capsize-games/spikeforge/blob/main/CONTRIBUTING.md)
|
|
41
|
+
[](documentation/README.md)
|
|
42
|
+
|
|
43
|
+
A spiking-neural-network (SNN) toolkit built on
|
|
44
|
+
[snnTorch](https://snntorch.readthedocs.io/) and PyTorch. Loads MNIST-style
|
|
45
|
+
and neuromorphic event datasets, encodes them into rate, latency, delta, and
|
|
46
|
+
random spikes, and trains, validates, exports, and deploys LIF networks —
|
|
47
|
+
with a live browser dashboard served over WebSockets.
|
|
48
|
+
|
|
49
|
+
> **Pre-1.0 and unpublished.** `install.sh` is the supported path today;
|
|
50
|
+
> PyPI ships once the distributions are published. Before trusting any
|
|
51
|
+
> number this produces, read
|
|
52
|
+
> [Implications and boundaries](documentation/implications-and-boundaries.md).
|
|
53
|
+
|
|
54
|
+
## Quickstart
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
git clone https://github.com/capsize-games/spikeforge.git
|
|
58
|
+
cd spikeforge
|
|
59
|
+
docker compose up --build
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Open <http://localhost:8877> — the dashboard connects to the WebSocket on
|
|
63
|
+
the same host and port. No separate backend or proxy to run.
|
|
64
|
+
|
|
65
|
+
Prefer a local install, a headless example, or the CLI tools instead? See
|
|
66
|
+
[Usage](documentation/usage.md) and [Quickstart](documentation/quickstart.md)
|
|
67
|
+
for every path (`./install.sh`, local dev with Vite, `examples/`, and the
|
|
68
|
+
eight `spikeforge-*` console scripts).
|
|
69
|
+
|
|
70
|
+
## Features
|
|
71
|
+
|
|
72
|
+
- **Encoding** — rate, latency, delta, and random spike coders.
|
|
73
|
+
- **Training** — fully-connected and convolutional LIF networks with
|
|
74
|
+
surrogate-gradient cross-entropy, checkpointing, and opt-in AMP / gradient
|
|
75
|
+
checkpointing / truncated BPTT / multi-GPU.
|
|
76
|
+
- **Topologies** — `fc_legacy`, `fc_small`, `conv_net`, `recurrent_net`, plus
|
|
77
|
+
the sequence presets `sequence_mlp` and `sequence_attn`.
|
|
78
|
+
- **Datasets** — MNIST, Fashion-MNIST, KMNIST, QMNIST, USPS, EMNIST,
|
|
79
|
+
CIFAR-10, and (via the `events` extra) N-MNIST, DVS128 Gesture,
|
|
80
|
+
CIFAR10-DVS, and Spiking Speech Commands.
|
|
81
|
+
- **Interpreter spine** — NIR export, an independent NIR interpreter, and
|
|
82
|
+
numerical drift validation.
|
|
83
|
+
- **Introspection** — educational-mode `U[t]`/`I[t]`/`S[t]` traces,
|
|
84
|
+
trajectory metrics, and surrogate-derivative curves.
|
|
85
|
+
- **Deployment** — a capability matrix, weight quantization, energy
|
|
86
|
+
accounting, and executable `reference`, `norse`, and `lava_loihi2`
|
|
87
|
+
backends.
|
|
88
|
+
- **Model hub** — a curated, offline-first catalog plus optional live
|
|
89
|
+
Hugging Face search.
|
|
90
|
+
- **Dashboard** — a React + TypeScript UI with training, introspection,
|
|
91
|
+
analysis, targets, energy, and hub panels, and seven guided walkthroughs.
|
|
92
|
+
|
|
93
|
+
## Packages
|
|
94
|
+
|
|
95
|
+
This repository is a single workspace that publishes four distributions:
|
|
96
|
+
|
|
97
|
+
| Distribution | Import root | Purpose |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| `spikeforge` | `spikeforge` | Core package: encoders, topologies, training, simulator, NIR bridge, tracking |
|
|
100
|
+
| `spikeforge-targets` | `spikeforge_targets` | Deployment targets, quantization, energy accounting, sparse event runtime |
|
|
101
|
+
| `spikeforge-hub` | `spikeforge_hub` | Curated model hub and optional Hugging Face access |
|
|
102
|
+
| `spikeforge-server` | `server` | FastAPI + WebSocket server and dashboard hosting |
|
|
103
|
+
|
|
104
|
+
## Documentation
|
|
105
|
+
|
|
106
|
+
This README stays short on purpose.
|
|
107
|
+
[`documentation/`](documentation/README.md) is the full reference — install
|
|
108
|
+
paths, the CLI tools, architecture, module layout, and the dev workflow —
|
|
109
|
+
written for contributors and coding agents alike. Also see
|
|
110
|
+
[COOKBOOK.md](COOKBOOK.md) for copy-pasteable recipes,
|
|
111
|
+
[examples/](examples/) for runnable end-to-end scripts, and
|
|
112
|
+
[plans/](plans/) for design documents and the roadmap.
|
|
113
|
+
|
|
114
|
+
See [CONTRIBUTING.md](https://github.com/capsize-games/spikeforge/blob/main/CONTRIBUTING.md)
|
|
115
|
+
and [rules.md](rules.md) before opening a pull request.
|
|
116
|
+
|
|
117
|
+
## Citing
|
|
118
|
+
|
|
119
|
+
If spikeforge is useful in your research, please cite it — see
|
|
120
|
+
[CITATION.cff](CITATION.cff) (GitHub renders a "Cite this repository"
|
|
121
|
+
button from it automatically).
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
Released under the **BSD 3-Clause License** — see [LICENSE](LICENSE) and
|
|
126
|
+
[AUTHORS](AUTHORS).
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# spikeforge
|
|
2
|
+
|
|
3
|
+
[](https://github.com/capsize-games/spikeforge/actions/workflows/ci.yml)
|
|
4
|
+
[](OPEN_SOURCE_CHECKLIST.md)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://www.python.org/downloads/)
|
|
7
|
+
[](https://github.com/astral-sh/ruff)
|
|
8
|
+
[](https://github.com/capsize-games/spikeforge/blob/main/CONTRIBUTING.md)
|
|
9
|
+
[](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,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
|
+
[](https://github.com/capsize-games/spikeforge/actions/workflows/ci.yml)
|
|
36
|
+
[](OPEN_SOURCE_CHECKLIST.md)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
[](https://www.python.org/downloads/)
|
|
39
|
+
[](https://github.com/astral-sh/ruff)
|
|
40
|
+
[](https://github.com/capsize-games/spikeforge/blob/main/CONTRIBUTING.md)
|
|
41
|
+
[](documentation/README.md)
|
|
42
|
+
|
|
43
|
+
A spiking-neural-network (SNN) toolkit built on
|
|
44
|
+
[snnTorch](https://snntorch.readthedocs.io/) and PyTorch. Loads MNIST-style
|
|
45
|
+
and neuromorphic event datasets, encodes them into rate, latency, delta, and
|
|
46
|
+
random spikes, and trains, validates, exports, and deploys LIF networks —
|
|
47
|
+
with a live browser dashboard served over WebSockets.
|
|
48
|
+
|
|
49
|
+
> **Pre-1.0 and unpublished.** `install.sh` is the supported path today;
|
|
50
|
+
> PyPI ships once the distributions are published. Before trusting any
|
|
51
|
+
> number this produces, read
|
|
52
|
+
> [Implications and boundaries](documentation/implications-and-boundaries.md).
|
|
53
|
+
|
|
54
|
+
## Quickstart
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
git clone https://github.com/capsize-games/spikeforge.git
|
|
58
|
+
cd spikeforge
|
|
59
|
+
docker compose up --build
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Open <http://localhost:8877> — the dashboard connects to the WebSocket on
|
|
63
|
+
the same host and port. No separate backend or proxy to run.
|
|
64
|
+
|
|
65
|
+
Prefer a local install, a headless example, or the CLI tools instead? See
|
|
66
|
+
[Usage](documentation/usage.md) and [Quickstart](documentation/quickstart.md)
|
|
67
|
+
for every path (`./install.sh`, local dev with Vite, `examples/`, and the
|
|
68
|
+
eight `spikeforge-*` console scripts).
|
|
69
|
+
|
|
70
|
+
## Features
|
|
71
|
+
|
|
72
|
+
- **Encoding** — rate, latency, delta, and random spike coders.
|
|
73
|
+
- **Training** — fully-connected and convolutional LIF networks with
|
|
74
|
+
surrogate-gradient cross-entropy, checkpointing, and opt-in AMP / gradient
|
|
75
|
+
checkpointing / truncated BPTT / multi-GPU.
|
|
76
|
+
- **Topologies** — `fc_legacy`, `fc_small`, `conv_net`, `recurrent_net`, plus
|
|
77
|
+
the sequence presets `sequence_mlp` and `sequence_attn`.
|
|
78
|
+
- **Datasets** — MNIST, Fashion-MNIST, KMNIST, QMNIST, USPS, EMNIST,
|
|
79
|
+
CIFAR-10, and (via the `events` extra) N-MNIST, DVS128 Gesture,
|
|
80
|
+
CIFAR10-DVS, and Spiking Speech Commands.
|
|
81
|
+
- **Interpreter spine** — NIR export, an independent NIR interpreter, and
|
|
82
|
+
numerical drift validation.
|
|
83
|
+
- **Introspection** — educational-mode `U[t]`/`I[t]`/`S[t]` traces,
|
|
84
|
+
trajectory metrics, and surrogate-derivative curves.
|
|
85
|
+
- **Deployment** — a capability matrix, weight quantization, energy
|
|
86
|
+
accounting, and executable `reference`, `norse`, and `lava_loihi2`
|
|
87
|
+
backends.
|
|
88
|
+
- **Model hub** — a curated, offline-first catalog plus optional live
|
|
89
|
+
Hugging Face search.
|
|
90
|
+
- **Dashboard** — a React + TypeScript UI with training, introspection,
|
|
91
|
+
analysis, targets, energy, and hub panels, and seven guided walkthroughs.
|
|
92
|
+
|
|
93
|
+
## Packages
|
|
94
|
+
|
|
95
|
+
This repository is a single workspace that publishes four distributions:
|
|
96
|
+
|
|
97
|
+
| Distribution | Import root | Purpose |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| `spikeforge` | `spikeforge` | Core package: encoders, topologies, training, simulator, NIR bridge, tracking |
|
|
100
|
+
| `spikeforge-targets` | `spikeforge_targets` | Deployment targets, quantization, energy accounting, sparse event runtime |
|
|
101
|
+
| `spikeforge-hub` | `spikeforge_hub` | Curated model hub and optional Hugging Face access |
|
|
102
|
+
| `spikeforge-server` | `server` | FastAPI + WebSocket server and dashboard hosting |
|
|
103
|
+
|
|
104
|
+
## Documentation
|
|
105
|
+
|
|
106
|
+
This README stays short on purpose.
|
|
107
|
+
[`documentation/`](documentation/README.md) is the full reference — install
|
|
108
|
+
paths, the CLI tools, architecture, module layout, and the dev workflow —
|
|
109
|
+
written for contributors and coding agents alike. Also see
|
|
110
|
+
[COOKBOOK.md](COOKBOOK.md) for copy-pasteable recipes,
|
|
111
|
+
[examples/](examples/) for runnable end-to-end scripts, and
|
|
112
|
+
[plans/](plans/) for design documents and the roadmap.
|
|
113
|
+
|
|
114
|
+
See [CONTRIBUTING.md](https://github.com/capsize-games/spikeforge/blob/main/CONTRIBUTING.md)
|
|
115
|
+
and [rules.md](rules.md) before opening a pull request.
|
|
116
|
+
|
|
117
|
+
## Citing
|
|
118
|
+
|
|
119
|
+
If spikeforge is useful in your research, please cite it — see
|
|
120
|
+
[CITATION.cff](CITATION.cff) (GitHub renders a "Cite this repository"
|
|
121
|
+
button from it automatically).
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
Released under the **BSD 3-Clause License** — see [LICENSE](LICENSE) and
|
|
126
|
+
[AUTHORS](AUTHORS).
|
|
@@ -0,0 +1,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
spikeforge_serve
|