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.
Files changed (31) hide show
  1. juniper_recurrence-0.1.0/PKG-INFO +148 -0
  2. juniper_recurrence-0.1.0/README.md +110 -0
  3. juniper_recurrence-0.1.0/juniper_recurrence/__init__.py +21 -0
  4. juniper_recurrence-0.1.0/juniper_recurrence/_version.py +9 -0
  5. juniper_recurrence-0.1.0/juniper_recurrence/app.py +83 -0
  6. juniper_recurrence-0.1.0/juniper_recurrence/data.py +105 -0
  7. juniper_recurrence-0.1.0/juniper_recurrence/events.py +34 -0
  8. juniper_recurrence-0.1.0/juniper_recurrence/main.py +116 -0
  9. juniper_recurrence-0.1.0/juniper_recurrence/routers/__init__.py +13 -0
  10. juniper_recurrence-0.1.0/juniper_recurrence/routers/_common.py +55 -0
  11. juniper_recurrence-0.1.0/juniper_recurrence/routers/dataset.py +22 -0
  12. juniper_recurrence-0.1.0/juniper_recurrence/routers/model.py +22 -0
  13. juniper_recurrence-0.1.0/juniper_recurrence/routers/predict.py +67 -0
  14. juniper_recurrence-0.1.0/juniper_recurrence/routers/training.py +85 -0
  15. juniper_recurrence-0.1.0/juniper_recurrence/schemas.py +135 -0
  16. juniper_recurrence-0.1.0/juniper_recurrence/settings.py +118 -0
  17. juniper_recurrence-0.1.0/juniper_recurrence/state.py +69 -0
  18. juniper_recurrence-0.1.0/juniper_recurrence.egg-info/PKG-INFO +148 -0
  19. juniper_recurrence-0.1.0/juniper_recurrence.egg-info/SOURCES.txt +29 -0
  20. juniper_recurrence-0.1.0/juniper_recurrence.egg-info/dependency_links.txt +1 -0
  21. juniper_recurrence-0.1.0/juniper_recurrence.egg-info/entry_points.txt +2 -0
  22. juniper_recurrence-0.1.0/juniper_recurrence.egg-info/requires.txt +17 -0
  23. juniper_recurrence-0.1.0/juniper_recurrence.egg-info/top_level.txt +1 -0
  24. juniper_recurrence-0.1.0/pyproject.toml +87 -0
  25. juniper_recurrence-0.1.0/setup.cfg +4 -0
  26. juniper_recurrence-0.1.0/tests/test_app_smoke.py +94 -0
  27. juniper_recurrence-0.1.0/tests/test_cli.py +77 -0
  28. juniper_recurrence-0.1.0/tests/test_cli_train.py +66 -0
  29. juniper_recurrence-0.1.0/tests/test_data_adapter.py +100 -0
  30. juniper_recurrence-0.1.0/tests/test_routes.py +243 -0
  31. 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"]