juniper-recurrence 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.
- juniper_recurrence-0.1.0/PKG-INFO +148 -0
- juniper_recurrence-0.1.0/README.md +110 -0
- juniper_recurrence-0.1.0/juniper_recurrence/__init__.py +21 -0
- juniper_recurrence-0.1.0/juniper_recurrence/_version.py +9 -0
- juniper_recurrence-0.1.0/juniper_recurrence/app.py +83 -0
- juniper_recurrence-0.1.0/juniper_recurrence/data.py +105 -0
- juniper_recurrence-0.1.0/juniper_recurrence/events.py +34 -0
- juniper_recurrence-0.1.0/juniper_recurrence/main.py +116 -0
- juniper_recurrence-0.1.0/juniper_recurrence/routers/__init__.py +13 -0
- juniper_recurrence-0.1.0/juniper_recurrence/routers/_common.py +55 -0
- juniper_recurrence-0.1.0/juniper_recurrence/routers/dataset.py +22 -0
- juniper_recurrence-0.1.0/juniper_recurrence/routers/model.py +22 -0
- juniper_recurrence-0.1.0/juniper_recurrence/routers/predict.py +67 -0
- juniper_recurrence-0.1.0/juniper_recurrence/routers/training.py +85 -0
- juniper_recurrence-0.1.0/juniper_recurrence/schemas.py +135 -0
- juniper_recurrence-0.1.0/juniper_recurrence/settings.py +118 -0
- juniper_recurrence-0.1.0/juniper_recurrence/state.py +69 -0
- juniper_recurrence-0.1.0/juniper_recurrence.egg-info/PKG-INFO +148 -0
- juniper_recurrence-0.1.0/juniper_recurrence.egg-info/SOURCES.txt +29 -0
- juniper_recurrence-0.1.0/juniper_recurrence.egg-info/dependency_links.txt +1 -0
- juniper_recurrence-0.1.0/juniper_recurrence.egg-info/entry_points.txt +2 -0
- juniper_recurrence-0.1.0/juniper_recurrence.egg-info/requires.txt +17 -0
- juniper_recurrence-0.1.0/juniper_recurrence.egg-info/top_level.txt +1 -0
- juniper_recurrence-0.1.0/pyproject.toml +87 -0
- juniper_recurrence-0.1.0/setup.cfg +4 -0
- juniper_recurrence-0.1.0/tests/test_app_smoke.py +94 -0
- juniper_recurrence-0.1.0/tests/test_cli.py +77 -0
- juniper_recurrence-0.1.0/tests/test_cli_train.py +66 -0
- juniper_recurrence-0.1.0/tests/test_data_adapter.py +100 -0
- juniper_recurrence-0.1.0/tests/test_routes.py +243 -0
- juniper_recurrence-0.1.0/tests/test_settings.py +101 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: juniper-recurrence
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FastAPI + CLI service wrapping the Δt-native LMU recurrence model on the juniper-service-core framework
|
|
5
|
+
Author: Paul Calnon
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/pcalnon/juniper-recurrence
|
|
8
|
+
Project-URL: Repository, https://github.com/pcalnon/juniper-recurrence
|
|
9
|
+
Project-URL: Issues, https://github.com/pcalnon/juniper-recurrence/issues
|
|
10
|
+
Keywords: juniper,recurrence,lmu,fastapi,time-series,irregular-dt,model-service
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: FastAPI
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: fastapi>=0.110
|
|
24
|
+
Requires-Dist: uvicorn[standard]>=0.30
|
|
25
|
+
Requires-Dist: pydantic>=2.0
|
|
26
|
+
Requires-Dist: pydantic-settings>=2.2
|
|
27
|
+
Requires-Dist: numpy>=1.24
|
|
28
|
+
Requires-Dist: juniper-service-core<0.2.0,>=0.1.0
|
|
29
|
+
Requires-Dist: juniper-model-core<0.2.0,>=0.1.0
|
|
30
|
+
Requires-Dist: juniper-recurrence-model<0.2.0,>=0.1.0
|
|
31
|
+
Requires-Dist: juniper-data-client<0.5.0,>=0.4.1
|
|
32
|
+
Provides-Extra: test
|
|
33
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
34
|
+
Requires-Dist: pytest-cov>=5.0; extra == "test"
|
|
35
|
+
Requires-Dist: httpx>=0.27; extra == "test"
|
|
36
|
+
Provides-Extra: observability
|
|
37
|
+
Requires-Dist: juniper-observability>=0.3.1; extra == "observability"
|
|
38
|
+
|
|
39
|
+
# juniper-recurrence
|
|
40
|
+
|
|
41
|
+
**Project**: Juniper — Cascade Correlation Neural Network Research Platform
|
|
42
|
+
**Application**: juniper-recurrence (FastAPI + CLI service)
|
|
43
|
+
**Author**: Paul Calnon
|
|
44
|
+
**License**: MIT License
|
|
45
|
+
**Version**: 0.1.0
|
|
46
|
+
|
|
47
|
+
FastAPI + CLI service that wraps the Δt-native Legendre Memory Unit regressor
|
|
48
|
+
([`juniper-recurrence-model`](https://github.com/pcalnon/juniper-recurrence)) on the
|
|
49
|
+
shared [`juniper-service-core`](https://pypi.org/project/juniper-service-core/)
|
|
50
|
+
framework. It loads 3-D windowed sequences (`equities_seq`, the WS-1 irregular-Δt
|
|
51
|
+
contract) through [`juniper-data-client`](https://pypi.org/project/juniper-data-client/)
|
|
52
|
+
and trains / serves the LMU over HTTP.
|
|
53
|
+
|
|
54
|
+
This is the **application layer** (WS-4b): the first real consumer of
|
|
55
|
+
service-core's `create_app` + `TrainingLifecycle`. The model, the data foundation,
|
|
56
|
+
and the service framework ship separately; this package is the glue + the HTTP/CLI
|
|
57
|
+
surface.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install juniper-recurrence
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
All upstreams resolve from PyPI: `juniper-service-core`, `juniper-model-core`,
|
|
66
|
+
`juniper-recurrence-model`, `juniper-data-client`, plus `fastapi` / `uvicorn`.
|
|
67
|
+
|
|
68
|
+
## Run
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Serve the API (single worker, in-process state). Binds 0.0.0.0:8210 by default;
|
|
72
|
+
# set JUNIPER_RECURRENCE_HOST=127.0.0.1 for local-only.
|
|
73
|
+
juniper-recurrence serve
|
|
74
|
+
juniper-recurrence serve --host 127.0.0.1 --port 8210
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Once running, the API exposes (every `/v1/*` route below requires `X-API-Key` when API
|
|
78
|
+
keys are configured; health + docs are always exempt):
|
|
79
|
+
|
|
80
|
+
| Route | Method | Behavior |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `/v1/health`, `/v1/health/ready` | GET | Liveness / readiness (exempt). |
|
|
83
|
+
| `/v1/train` | POST | Train the LMU on a dataset (synchronous); returns the `TrainResult`. |
|
|
84
|
+
| `/v1/training/status` | GET | `idle` / `trained` + last metrics + training events. |
|
|
85
|
+
| `/v1/predict` | POST | Continuous predictions for inline `X` (+ `dt`) or a dataset ref. |
|
|
86
|
+
| `/v1/model` | GET | Current model topology + regression metrics. |
|
|
87
|
+
| `/v1/dataset` | GET | Descriptor of the trained-on dataset. |
|
|
88
|
+
| `/docs` | GET | OpenAPI / Swagger UI (exempt). |
|
|
89
|
+
|
|
90
|
+
Training runs **inline** (a one-shot closed-form solve), so `POST /v1/train` returns the
|
|
91
|
+
result in the response — no background jobs or WebSocket streams in v1.
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# Train on a juniper-data dataset, then inspect the model.
|
|
95
|
+
curl -sX POST localhost:8210/v1/train \
|
|
96
|
+
-H 'Content-Type: application/json' \
|
|
97
|
+
-d '{"dataset": {"dataset_id": "<id>"}, "d": 16}'
|
|
98
|
+
curl -s localhost:8210/v1/model
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Train (headless CLI)
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# Fit the LMU on a dataset and persist it — no server.
|
|
105
|
+
juniper-recurrence train --dataset <id> --d 16 --out model.npz
|
|
106
|
+
juniper-recurrence train --name equities_seq_v1 --split train
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Configuration
|
|
110
|
+
|
|
111
|
+
All settings read the `JUNIPER_RECURRENCE_` environment namespace (e.g.
|
|
112
|
+
`JUNIPER_RECURRENCE_PORT`). Secrets honor the Docker `_FILE` indirection
|
|
113
|
+
(`JUNIPER_RECURRENCE_API_KEYS_FILE`, `JUNIPER_DATA_API_KEY_FILE`). When no API keys
|
|
114
|
+
are configured, authentication is disabled (open access — development default).
|
|
115
|
+
|
|
116
|
+
| Variable | Default | Purpose |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| `JUNIPER_RECURRENCE_HOST` | `0.0.0.0` | Bind host (container default; `127.0.0.1` locally). |
|
|
119
|
+
| `JUNIPER_RECURRENCE_PORT` | `8210` | Bind port (deploy maps host `8211` → container `8210`). |
|
|
120
|
+
| `JUNIPER_RECURRENCE_API_KEYS` | _(unset)_ | CSV or JSON-array of valid `X-API-Key` values. |
|
|
121
|
+
| `JUNIPER_DATA_URL` | `http://localhost:8100` | Upstream juniper-data base URL. |
|
|
122
|
+
| `JUNIPER_DATA_API_KEY` | _(unset)_ | Outbound `X-API-Key` to juniper-data. |
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
pip install -e ".[test]"
|
|
128
|
+
pytest tests/ -v
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Publishing
|
|
132
|
+
|
|
133
|
+
Releases are published to PyPI via GitHub Actions
|
|
134
|
+
(`.github/workflows/publish-recurrence-app.yml`) on a `juniper-recurrence-v*` tag —
|
|
135
|
+
TestPyPI first (with a `--no-deps` install verification), then PyPI, via OIDC trusted
|
|
136
|
+
publishing (no API tokens). The model package (`juniper-recurrence-model`) publishes
|
|
137
|
+
separately on `juniper-recurrence-model-v*` tags.
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
git tag juniper-recurrence-v0.1.0
|
|
141
|
+
git push origin juniper-recurrence-v0.1.0
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Ecosystem
|
|
145
|
+
|
|
146
|
+
Part of the [Juniper](https://github.com/pcalnon) ML research platform. See the
|
|
147
|
+
WS-4b build plan (`notes/JUNIPER_RECURRENCE_WS4B_APP_BUILD_PLAN_2026-06-15.md` in
|
|
148
|
+
`juniper-ml`) for the design of record.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# juniper-recurrence
|
|
2
|
+
|
|
3
|
+
**Project**: Juniper — Cascade Correlation Neural Network Research Platform
|
|
4
|
+
**Application**: juniper-recurrence (FastAPI + CLI service)
|
|
5
|
+
**Author**: Paul Calnon
|
|
6
|
+
**License**: MIT License
|
|
7
|
+
**Version**: 0.1.0
|
|
8
|
+
|
|
9
|
+
FastAPI + CLI service that wraps the Δt-native Legendre Memory Unit regressor
|
|
10
|
+
([`juniper-recurrence-model`](https://github.com/pcalnon/juniper-recurrence)) on the
|
|
11
|
+
shared [`juniper-service-core`](https://pypi.org/project/juniper-service-core/)
|
|
12
|
+
framework. It loads 3-D windowed sequences (`equities_seq`, the WS-1 irregular-Δt
|
|
13
|
+
contract) through [`juniper-data-client`](https://pypi.org/project/juniper-data-client/)
|
|
14
|
+
and trains / serves the LMU over HTTP.
|
|
15
|
+
|
|
16
|
+
This is the **application layer** (WS-4b): the first real consumer of
|
|
17
|
+
service-core's `create_app` + `TrainingLifecycle`. The model, the data foundation,
|
|
18
|
+
and the service framework ship separately; this package is the glue + the HTTP/CLI
|
|
19
|
+
surface.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install juniper-recurrence
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
All upstreams resolve from PyPI: `juniper-service-core`, `juniper-model-core`,
|
|
28
|
+
`juniper-recurrence-model`, `juniper-data-client`, plus `fastapi` / `uvicorn`.
|
|
29
|
+
|
|
30
|
+
## Run
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Serve the API (single worker, in-process state). Binds 0.0.0.0:8210 by default;
|
|
34
|
+
# set JUNIPER_RECURRENCE_HOST=127.0.0.1 for local-only.
|
|
35
|
+
juniper-recurrence serve
|
|
36
|
+
juniper-recurrence serve --host 127.0.0.1 --port 8210
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Once running, the API exposes (every `/v1/*` route below requires `X-API-Key` when API
|
|
40
|
+
keys are configured; health + docs are always exempt):
|
|
41
|
+
|
|
42
|
+
| Route | Method | Behavior |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `/v1/health`, `/v1/health/ready` | GET | Liveness / readiness (exempt). |
|
|
45
|
+
| `/v1/train` | POST | Train the LMU on a dataset (synchronous); returns the `TrainResult`. |
|
|
46
|
+
| `/v1/training/status` | GET | `idle` / `trained` + last metrics + training events. |
|
|
47
|
+
| `/v1/predict` | POST | Continuous predictions for inline `X` (+ `dt`) or a dataset ref. |
|
|
48
|
+
| `/v1/model` | GET | Current model topology + regression metrics. |
|
|
49
|
+
| `/v1/dataset` | GET | Descriptor of the trained-on dataset. |
|
|
50
|
+
| `/docs` | GET | OpenAPI / Swagger UI (exempt). |
|
|
51
|
+
|
|
52
|
+
Training runs **inline** (a one-shot closed-form solve), so `POST /v1/train` returns the
|
|
53
|
+
result in the response — no background jobs or WebSocket streams in v1.
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# Train on a juniper-data dataset, then inspect the model.
|
|
57
|
+
curl -sX POST localhost:8210/v1/train \
|
|
58
|
+
-H 'Content-Type: application/json' \
|
|
59
|
+
-d '{"dataset": {"dataset_id": "<id>"}, "d": 16}'
|
|
60
|
+
curl -s localhost:8210/v1/model
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Train (headless CLI)
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# Fit the LMU on a dataset and persist it — no server.
|
|
67
|
+
juniper-recurrence train --dataset <id> --d 16 --out model.npz
|
|
68
|
+
juniper-recurrence train --name equities_seq_v1 --split train
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
All settings read the `JUNIPER_RECURRENCE_` environment namespace (e.g.
|
|
74
|
+
`JUNIPER_RECURRENCE_PORT`). Secrets honor the Docker `_FILE` indirection
|
|
75
|
+
(`JUNIPER_RECURRENCE_API_KEYS_FILE`, `JUNIPER_DATA_API_KEY_FILE`). When no API keys
|
|
76
|
+
are configured, authentication is disabled (open access — development default).
|
|
77
|
+
|
|
78
|
+
| Variable | Default | Purpose |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `JUNIPER_RECURRENCE_HOST` | `0.0.0.0` | Bind host (container default; `127.0.0.1` locally). |
|
|
81
|
+
| `JUNIPER_RECURRENCE_PORT` | `8210` | Bind port (deploy maps host `8211` → container `8210`). |
|
|
82
|
+
| `JUNIPER_RECURRENCE_API_KEYS` | _(unset)_ | CSV or JSON-array of valid `X-API-Key` values. |
|
|
83
|
+
| `JUNIPER_DATA_URL` | `http://localhost:8100` | Upstream juniper-data base URL. |
|
|
84
|
+
| `JUNIPER_DATA_API_KEY` | _(unset)_ | Outbound `X-API-Key` to juniper-data. |
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install -e ".[test]"
|
|
90
|
+
pytest tests/ -v
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Publishing
|
|
94
|
+
|
|
95
|
+
Releases are published to PyPI via GitHub Actions
|
|
96
|
+
(`.github/workflows/publish-recurrence-app.yml`) on a `juniper-recurrence-v*` tag —
|
|
97
|
+
TestPyPI first (with a `--no-deps` install verification), then PyPI, via OIDC trusted
|
|
98
|
+
publishing (no API tokens). The model package (`juniper-recurrence-model`) publishes
|
|
99
|
+
separately on `juniper-recurrence-model-v*` tags.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
git tag juniper-recurrence-v0.1.0
|
|
103
|
+
git push origin juniper-recurrence-v0.1.0
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Ecosystem
|
|
107
|
+
|
|
108
|
+
Part of the [Juniper](https://github.com/pcalnon) ML research platform. See the
|
|
109
|
+
WS-4b build plan (`notes/JUNIPER_RECURRENCE_WS4B_APP_BUILD_PLAN_2026-06-15.md` in
|
|
110
|
+
`juniper-ml`) for the design of record.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""juniper-recurrence — FastAPI + CLI service for the Δt-native LMU recurrence model.
|
|
2
|
+
|
|
3
|
+
The application layer (WS-4b) that wraps the already-shipped ``LMURegressor``
|
|
4
|
+
(``juniper-recurrence-model``, WS-4a) on the ``juniper-service-core`` framework
|
|
5
|
+
(WS-2), fed 3-D windowed sequences (``equities_seq``) via ``juniper-data-client``
|
|
6
|
+
(WS-1). It is the first real consumer of service-core's ``create_app`` +
|
|
7
|
+
``TrainingLifecycle`` (the 2nd-implementer proof for the *service* contract).
|
|
8
|
+
|
|
9
|
+
Only :data:`__version__` is exposed at the top level, kept dependency-free so the
|
|
10
|
+
package version is importable without pulling fastapi / pydantic-settings. The
|
|
11
|
+
FastAPI app and its factory live in :mod:`juniper_recurrence.app`
|
|
12
|
+
(``from juniper_recurrence.app import app, build_app``); the CLI entrypoint is
|
|
13
|
+
:func:`juniper_recurrence.main.main`.
|
|
14
|
+
|
|
15
|
+
Design of record: ``notes/JUNIPER_RECURRENCE_WS4B_APP_BUILD_PLAN_2026-06-15.md`` and
|
|
16
|
+
``notes/JUNIPER_RECURRENCE_MODEL_DETAILED_DESIGN_2026-06-14.md`` (juniper-ml).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from juniper_recurrence._version import __version__
|
|
20
|
+
|
|
21
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Single source of truth for the juniper-recurrence application version.
|
|
2
|
+
|
|
3
|
+
Kept import-free so setuptools can parse ``__version__`` statically at build time
|
|
4
|
+
(``[tool.setuptools.dynamic]`` in pyproject.toml) without importing fastapi /
|
|
5
|
+
pydantic-settings. This also lets the TestPyPI publish-verify run a clean
|
|
6
|
+
``import juniper_recurrence`` (top-level package re-exports only this value).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""FastAPI application assembly for the juniper-recurrence service.
|
|
2
|
+
|
|
3
|
+
Builds on the *as-built* ``juniper-service-core`` app factory: ``create_app``
|
|
4
|
+
mounts only the generic health router, so the owning service owns its
|
|
5
|
+
security / middleware stack (the as-built reconciliation, plan §2). This module
|
|
6
|
+
attaches that stack and exposes the module-level ``app`` that ``uvicorn`` (and the
|
|
7
|
+
CLI ``serve`` subcommand) import.
|
|
8
|
+
|
|
9
|
+
Middleware order mirrors the canonical cascor / canopy / data assembly —
|
|
10
|
+
``RequestBodyLimitMiddleware`` → ``SecurityHeadersMiddleware`` →
|
|
11
|
+
``SecurityMiddleware`` — added in that sequence so that, under Starlette's LIFO
|
|
12
|
+
execution, ``SecurityMiddleware`` (API-key auth + rate limiting) runs outermost.
|
|
13
|
+
|
|
14
|
+
The train / predict / model / dataset routers are threaded through
|
|
15
|
+
``create_app(routers=...)``; a fresh :class:`AppState` (the in-process model / result /
|
|
16
|
+
event holder) is created per ``build_app`` and stashed on ``app.state`` for the routers.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from fastapi import FastAPI
|
|
22
|
+
from juniper_service_core import (
|
|
23
|
+
RequestBodyLimitMiddleware,
|
|
24
|
+
SecurityHeadersMiddleware,
|
|
25
|
+
SecurityMiddleware,
|
|
26
|
+
build_api_key_auth,
|
|
27
|
+
build_rate_limiter,
|
|
28
|
+
create_app,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from juniper_recurrence._version import __version__
|
|
32
|
+
from juniper_recurrence.routers import dataset_router, model_router, predict_router, training_router
|
|
33
|
+
from juniper_recurrence.settings import Settings
|
|
34
|
+
from juniper_recurrence.state import AppState
|
|
35
|
+
|
|
36
|
+
__all__ = ["build_app", "app"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_app(settings: Settings | None = None) -> FastAPI:
|
|
40
|
+
"""Assemble the juniper-recurrence FastAPI app.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
settings: Pre-built settings (tests inject these to exercise auth /
|
|
44
|
+
rate-limit configurations). Defaults to ``Settings()``, which reads
|
|
45
|
+
the ``JUNIPER_RECURRENCE_`` environment namespace.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
A configured :class:`~fastapi.FastAPI` instance with the generic health
|
|
49
|
+
router (from ``create_app``) plus the security / middleware stack.
|
|
50
|
+
"""
|
|
51
|
+
settings = settings or Settings()
|
|
52
|
+
|
|
53
|
+
application = create_app(
|
|
54
|
+
title="Juniper Recurrence",
|
|
55
|
+
version=__version__,
|
|
56
|
+
routers=(training_router, predict_router, model_router, dataset_router),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Middleware (Starlette LIFO: last added runs first on the request path). This
|
|
60
|
+
# exact order is the de-cascored canonical assembly shared by cascor / canopy /
|
|
61
|
+
# data: body-limit innermost, security-headers next, API-key auth + rate-limit
|
|
62
|
+
# outermost.
|
|
63
|
+
application.add_middleware(RequestBodyLimitMiddleware)
|
|
64
|
+
application.add_middleware(SecurityHeadersMiddleware)
|
|
65
|
+
api_key_auth = build_api_key_auth(settings.resolve_api_keys())
|
|
66
|
+
rate_limiter = build_rate_limiter(
|
|
67
|
+
requests_per_minute=settings.rate_limit_requests_per_minute,
|
|
68
|
+
enabled=settings.rate_limit_enabled,
|
|
69
|
+
)
|
|
70
|
+
application.add_middleware(SecurityMiddleware, api_key_auth=api_key_auth, rate_limiter=rate_limiter)
|
|
71
|
+
|
|
72
|
+
# Stash per-app instances for routers / tests (mirrors cascor's app.state usage).
|
|
73
|
+
# AppState is created fresh per build_app, so each app — and each test — gets
|
|
74
|
+
# isolated in-process model / result / event state.
|
|
75
|
+
application.state.settings = settings
|
|
76
|
+
application.state.api_key_auth = api_key_auth
|
|
77
|
+
application.state.app_state = AppState()
|
|
78
|
+
|
|
79
|
+
return application
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Module-level ASGI app for ``uvicorn juniper_recurrence.app:app`` (CLI ``serve``).
|
|
83
|
+
app = build_app()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Data path: juniper-data-client → 3-D ``equities_seq`` NPZ → model kwargs (plan §8).
|
|
2
|
+
|
|
3
|
+
Resolves a dataset reference to a ``dataset_id``, downloads the NPZ artifact, runs
|
|
4
|
+
juniper-data-client's full-contract validator **when the installed client exposes it**
|
|
5
|
+
(see the guarded import below), then maps it to the arrays ``LMURegressor`` consumes via
|
|
6
|
+
the model package's ``sequence_data_from_arrays`` (reusing the canonical WS-1 key layout
|
|
7
|
+
+ ``dt`` rules instead of re-deriving them — also the validation floor when the validator
|
|
8
|
+
is absent).
|
|
9
|
+
|
|
10
|
+
Framework-light by design: takes primitives (no FastAPI / pydantic / settings import),
|
|
11
|
+
so the routers and the headless CLI ``train`` share it. ``JuniperDataClient`` and
|
|
12
|
+
``validate_npz_contract`` are imported at module level so tests can monkeypatch them.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from juniper_data_client import JuniperDataClient
|
|
20
|
+
from juniper_recurrence_model import SequenceData, sequence_data_from_arrays
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
# ``validate_npz_contract`` landed in juniper-data-client AFTER the published 0.4.1
|
|
24
|
+
# pin. When the installed client provides it, it runs as the authoritative
|
|
25
|
+
# full-contract gate; otherwise the model-side ``sequence_data_from_arrays`` checks
|
|
26
|
+
# (X is 3-D, the dt rules, a regression target present) are the validation floor.
|
|
27
|
+
# Bump the ``juniper-data-client`` pin once the validator publishes to make it hard.
|
|
28
|
+
from juniper_data_client import validate_npz_contract
|
|
29
|
+
except ImportError: # pragma: no cover - depends on the installed juniper-data-client version
|
|
30
|
+
validate_npz_contract = None
|
|
31
|
+
|
|
32
|
+
__all__ = ["load_sequence_data"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolve_dataset_id(
|
|
36
|
+
client: JuniperDataClient,
|
|
37
|
+
*,
|
|
38
|
+
dataset_id: str | None,
|
|
39
|
+
name: str | None,
|
|
40
|
+
generator: str | None,
|
|
41
|
+
params: dict[str, Any] | None,
|
|
42
|
+
) -> str:
|
|
43
|
+
"""Resolve a dataset reference to a concrete ``dataset_id``.
|
|
44
|
+
|
|
45
|
+
Precedence: explicit ``dataset_id`` → latest version of ``name`` → create a new
|
|
46
|
+
dataset from ``generator`` + ``params``.
|
|
47
|
+
"""
|
|
48
|
+
if dataset_id:
|
|
49
|
+
return dataset_id
|
|
50
|
+
if name:
|
|
51
|
+
latest = client.get_latest(name)
|
|
52
|
+
resolved = latest.get("dataset_id")
|
|
53
|
+
if not resolved:
|
|
54
|
+
raise ValueError(f"no dataset_id in latest version of {name!r}")
|
|
55
|
+
return resolved
|
|
56
|
+
if generator:
|
|
57
|
+
created = client.create_dataset(generator=generator, params=dict(params or {}), persist=True)
|
|
58
|
+
resolved = created.get("dataset_id")
|
|
59
|
+
if not resolved:
|
|
60
|
+
raise ValueError(f"no dataset_id returned creating {generator!r} dataset")
|
|
61
|
+
return resolved
|
|
62
|
+
raise ValueError("dataset ref requires one of: dataset_id, name, generator")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_sequence_data(
|
|
66
|
+
*,
|
|
67
|
+
base_url: str,
|
|
68
|
+
api_key: str | None = None,
|
|
69
|
+
dataset_id: str | None = None,
|
|
70
|
+
name: str | None = None,
|
|
71
|
+
generator: str | None = None,
|
|
72
|
+
params: dict[str, Any] | None = None,
|
|
73
|
+
split: str = "train",
|
|
74
|
+
) -> tuple[SequenceData, dict[str, Any]]:
|
|
75
|
+
"""Fetch and map one split of a 3-D sequence dataset for the LMU regressor.
|
|
76
|
+
|
|
77
|
+
Returns the :class:`SequenceData` (``X`` / ``y`` / ``dt`` / ``target_dt`` /
|
|
78
|
+
``seq_lengths``) plus a plain descriptor dict for ``DatasetDescriptor``.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
juniper_data_client.JuniperDataClientError: on upstream fetch failures.
|
|
82
|
+
ValueError: when the artifact violates the contract or is not a 3-D sequence.
|
|
83
|
+
"""
|
|
84
|
+
client = JuniperDataClient(base_url=base_url, api_key=api_key)
|
|
85
|
+
try:
|
|
86
|
+
resolved_id = _resolve_dataset_id(client, dataset_id=dataset_id, name=name, generator=generator, params=params)
|
|
87
|
+
arrays = client.download_artifact_npz(resolved_id)
|
|
88
|
+
if validate_npz_contract is not None:
|
|
89
|
+
validate_npz_contract(arrays) # full-contract gate when the client provides it (raises ValueError)
|
|
90
|
+
sequence = sequence_data_from_arrays(arrays, split)
|
|
91
|
+
finally:
|
|
92
|
+
client.close()
|
|
93
|
+
|
|
94
|
+
descriptor = {
|
|
95
|
+
"dataset_id": resolved_id,
|
|
96
|
+
"name": name,
|
|
97
|
+
"split": split,
|
|
98
|
+
"n_windows": int(sequence.X.shape[0]),
|
|
99
|
+
"lookback": int(sequence.X.shape[1]),
|
|
100
|
+
"n_features": int(sequence.X.shape[2]),
|
|
101
|
+
"output_dim": int(sequence.y.shape[1]) if sequence.y.ndim > 1 else 1,
|
|
102
|
+
"has_target_dt": sequence.target_dt is not None,
|
|
103
|
+
"has_seq_lengths": sequence.seq_lengths is not None,
|
|
104
|
+
}
|
|
105
|
+
return sequence, descriptor
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""In-memory training-event sink for the juniper-recurrence app (plan §9).
|
|
2
|
+
|
|
3
|
+
A bounded, ordered ring buffer that is itself the ``on_event`` callable passed to
|
|
4
|
+
``juniper_service_core.TrainingLifecycle``. The lifecycle stamps a monotonic ``seq``
|
|
5
|
+
on each event before it arrives here, so a snapshot is already legally ordered. The
|
|
6
|
+
buffer feeds ``GET /v1/training/status``; older events past ``maxlen`` are dropped
|
|
7
|
+
(a single synchronous run emits only ``training_start`` → ``epoch_end`` →
|
|
8
|
+
``training_end``, so the default cap is never reached in practice).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections import deque
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from juniper_model_core import TrainingEvent
|
|
18
|
+
|
|
19
|
+
__all__ = ["EventSink"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EventSink:
|
|
23
|
+
"""A bounded, ordered sink for :class:`~juniper_model_core.TrainingEvent`\\ s."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, maxlen: int = 256) -> None:
|
|
26
|
+
self._events: deque[TrainingEvent] = deque(maxlen=maxlen)
|
|
27
|
+
|
|
28
|
+
def __call__(self, event: TrainingEvent) -> None:
|
|
29
|
+
"""Append an emitted event (the ``on_event`` lifecycle hook)."""
|
|
30
|
+
self._events.append(event)
|
|
31
|
+
|
|
32
|
+
def snapshot(self) -> list[TrainingEvent]:
|
|
33
|
+
"""An ordered copy of the buffered events (oldest first)."""
|
|
34
|
+
return list(self._events)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""CLI entrypoint for the juniper-recurrence service (C2 dual-mode).
|
|
2
|
+
|
|
3
|
+
* ``juniper-recurrence serve`` launches the FastAPI app under uvicorn (single
|
|
4
|
+
worker; in-process state).
|
|
5
|
+
* ``juniper-recurrence train`` is headless: load a 3-D NPZ via the shared data
|
|
6
|
+
adapter, fit ``LMURegressor``, print the regression metrics, and optionally persist
|
|
7
|
+
the model via ``LMUSerializer``. It reuses the exact ``data.load_sequence_data`` +
|
|
8
|
+
model construction the ``/v1/train`` route uses.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
|
|
17
|
+
from juniper_recurrence._version import __version__
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
21
|
+
"""Build the ``juniper-recurrence`` argument parser."""
|
|
22
|
+
parser = argparse.ArgumentParser(
|
|
23
|
+
prog="juniper-recurrence",
|
|
24
|
+
description="FastAPI + CLI service for the Δt-native LMU recurrence model.",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument("--version", action="version", version=f"juniper-recurrence {__version__}")
|
|
27
|
+
subparsers = parser.add_subparsers(dest="command", required=True, metavar="{serve,train}")
|
|
28
|
+
|
|
29
|
+
serve = subparsers.add_parser("serve", help="Run the FastAPI service under uvicorn.")
|
|
30
|
+
serve.add_argument("--host", default=None, help="Bind host (defaults to JUNIPER_RECURRENCE_HOST / settings).")
|
|
31
|
+
serve.add_argument("--port", type=int, default=None, help="Bind port (defaults to JUNIPER_RECURRENCE_PORT / settings).")
|
|
32
|
+
|
|
33
|
+
train = subparsers.add_parser("train", help="Headless: fit the LMU on a dataset and print metrics.")
|
|
34
|
+
train.add_argument("--dataset", default=None, help="Dataset id to train on.")
|
|
35
|
+
train.add_argument("--name", default=None, help="Dataset name (uses the latest version).")
|
|
36
|
+
train.add_argument("--generator", default=None, help="Generator to create a dataset from (e.g. equities_seq).")
|
|
37
|
+
train.add_argument("--split", default="train", help="Split to train on (train/test/full; default: train).")
|
|
38
|
+
train.add_argument("--d", type=int, default=None, help="LMU memory order (default: settings.default_d).")
|
|
39
|
+
train.add_argument("--theta", type=float, default=None, help="LMU window length θ (default: data-driven).")
|
|
40
|
+
train.add_argument("--ridge", type=float, default=None, help="Readout L2 penalty (default: settings.default_ridge).")
|
|
41
|
+
train.add_argument("--out", default=None, help="Path to save the trained model (.npz) via LMUSerializer.")
|
|
42
|
+
|
|
43
|
+
return parser
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _serve(args: argparse.Namespace) -> int:
|
|
47
|
+
"""Run ``uvicorn`` against the module-level app, honoring host/port overrides."""
|
|
48
|
+
import uvicorn
|
|
49
|
+
|
|
50
|
+
from juniper_recurrence.settings import Settings
|
|
51
|
+
|
|
52
|
+
settings = Settings()
|
|
53
|
+
host = args.host or settings.host
|
|
54
|
+
port = args.port or settings.port
|
|
55
|
+
# Import string (not the app object) so uvicorn owns process/worker lifecycle.
|
|
56
|
+
uvicorn.run("juniper_recurrence.app:app", host=host, port=port)
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _train(args: argparse.Namespace) -> int:
|
|
61
|
+
"""Headless train: load a 3-D NPZ, fit ``LMURegressor``, print metrics, persist."""
|
|
62
|
+
from juniper_recurrence_model import LMURegressor, LMUSerializer
|
|
63
|
+
|
|
64
|
+
from juniper_recurrence.data import load_sequence_data
|
|
65
|
+
from juniper_recurrence.settings import Settings
|
|
66
|
+
|
|
67
|
+
if not (args.dataset or args.name or args.generator):
|
|
68
|
+
print("error: train requires one of --dataset / --name / --generator", file=sys.stderr)
|
|
69
|
+
return 2
|
|
70
|
+
|
|
71
|
+
settings = Settings()
|
|
72
|
+
sequence, descriptor = load_sequence_data(
|
|
73
|
+
base_url=settings.juniper_data_url,
|
|
74
|
+
api_key=settings.juniper_data_api_key,
|
|
75
|
+
dataset_id=args.dataset,
|
|
76
|
+
name=args.name,
|
|
77
|
+
generator=args.generator,
|
|
78
|
+
split=args.split,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
d = args.d if args.d is not None else settings.default_d
|
|
82
|
+
theta = args.theta if args.theta is not None else settings.default_theta
|
|
83
|
+
ridge = args.ridge if args.ridge is not None else settings.default_ridge
|
|
84
|
+
|
|
85
|
+
model = LMURegressor(d=d, theta=theta, ridge=ridge)
|
|
86
|
+
result = model.fit(sequence.X, sequence.y, **sequence.fit_kwargs())
|
|
87
|
+
|
|
88
|
+
print(f"Trained LMURegressor on dataset {descriptor['dataset_id']} (split={descriptor['split']}, windows={descriptor['n_windows']}, F={descriptor['n_features']}).")
|
|
89
|
+
print("Metrics:")
|
|
90
|
+
for key, value in result.final_metrics.items():
|
|
91
|
+
print(f" {key}: {value:.6f}")
|
|
92
|
+
|
|
93
|
+
if args.out:
|
|
94
|
+
LMUSerializer().save(model, args.out)
|
|
95
|
+
print(f"Saved model to {args.out}")
|
|
96
|
+
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
101
|
+
"""CLI dispatch entrypoint (``[project.scripts] juniper-recurrence``)."""
|
|
102
|
+
parser = _build_parser()
|
|
103
|
+
args = parser.parse_args(argv)
|
|
104
|
+
|
|
105
|
+
if args.command == "serve":
|
|
106
|
+
return _serve(args)
|
|
107
|
+
if args.command == "train":
|
|
108
|
+
return _train(args)
|
|
109
|
+
|
|
110
|
+
# ``required=True`` on the subparser makes this unreachable; kept as a guard.
|
|
111
|
+
parser.error(f"unknown command: {args.command!r}")
|
|
112
|
+
return 2
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__": # pragma: no cover
|
|
116
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""API routers for the juniper-recurrence service (plan §6).
|
|
2
|
+
|
|
3
|
+
Each router is mounted by :func:`juniper_recurrence.app.build_app` via
|
|
4
|
+
``create_app(routers=...)``. All routes are regression-generic (RK-6) and protected by
|
|
5
|
+
the app's ``SecurityMiddleware`` (health / docs stay exempt) — no per-route auth needed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from juniper_recurrence.routers.dataset import router as dataset_router
|
|
9
|
+
from juniper_recurrence.routers.model import router as model_router
|
|
10
|
+
from juniper_recurrence.routers.predict import router as predict_router
|
|
11
|
+
from juniper_recurrence.routers.training import router as training_router
|
|
12
|
+
|
|
13
|
+
__all__ = ["training_router", "predict_router", "model_router", "dataset_router"]
|