juniper-recurrence 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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"]
@@ -0,0 +1,55 @@
1
+ """Shared router dependencies and error mapping for the juniper-recurrence API.
2
+
3
+ The app-state and settings dependencies read the per-app instances stashed on
4
+ ``app.state`` by :func:`juniper_recurrence.app.build_app`. :func:`map_data_error`
5
+ translates juniper-data-client failures into the appropriate HTTP status so the
6
+ train / predict data path returns ``404`` / ``422`` / ``502`` rather than a bare 500.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from fastapi import HTTPException, Request, status
12
+ from juniper_data_client import (
13
+ JuniperDataConfigurationError,
14
+ JuniperDataConnectionError,
15
+ JuniperDataNotFoundError,
16
+ JuniperDataTimeoutError,
17
+ JuniperDataValidationError,
18
+ )
19
+
20
+ from juniper_recurrence.settings import Settings
21
+ from juniper_recurrence.state import AppState
22
+
23
+ __all__ = ["get_state", "get_settings", "map_data_error"]
24
+
25
+
26
+ def get_state(request: Request) -> AppState:
27
+ """FastAPI dependency: the per-app :class:`AppState` (uvicorn ``workers=1``)."""
28
+ return request.app.state.app_state
29
+
30
+
31
+ def get_settings(request: Request) -> Settings:
32
+ """FastAPI dependency: the per-app :class:`Settings`."""
33
+ return request.app.state.settings
34
+
35
+
36
+ def map_data_error(exc: Exception) -> HTTPException:
37
+ """Translate a data-fetch failure into an :class:`HTTPException`.
38
+
39
+ * not-found → ``404``
40
+ * connection / timeout → ``502`` (upstream juniper-data unreachable)
41
+ * validation / contract (``ValueError``) → ``422``
42
+ * misconfiguration → ``500``
43
+ * anything else → ``502``
44
+ """
45
+ if isinstance(exc, JuniperDataNotFoundError):
46
+ return HTTPException(status.HTTP_404_NOT_FOUND, f"dataset not found: {exc}")
47
+ if isinstance(exc, (JuniperDataConnectionError, JuniperDataTimeoutError)):
48
+ return HTTPException(status.HTTP_502_BAD_GATEWAY, f"juniper-data unreachable: {exc}")
49
+ if isinstance(exc, (JuniperDataValidationError, ValueError)):
50
+ # 422 as an int literal: Starlette deprecated HTTP_422_UNPROCESSABLE_ENTITY and the
51
+ # renamed constant is absent on older fastapi>=0.110 resolutions; the literal is safe.
52
+ return HTTPException(422, f"invalid dataset: {exc}")
53
+ if isinstance(exc, JuniperDataConfigurationError):
54
+ return HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, f"data-client misconfigured: {exc}")
55
+ return HTTPException(status.HTTP_502_BAD_GATEWAY, f"data fetch failed: {exc}")
@@ -0,0 +1,22 @@
1
+ """Dataset route: ``GET /v1/dataset`` — descriptor of the last-loaded split (thin v1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, status
8
+
9
+ from juniper_recurrence.routers._common import get_state
10
+ from juniper_recurrence.schemas import DatasetDescriptor
11
+ from juniper_recurrence.state import AppState
12
+
13
+ router = APIRouter(tags=["dataset"])
14
+
15
+
16
+ @router.get("/v1/dataset", response_model=DatasetDescriptor)
17
+ def get_dataset(state: Annotated[AppState, Depends(get_state)]) -> DatasetDescriptor:
18
+ """Descriptor (name / split / shapes) of the dataset the current model trained on."""
19
+ descriptor = state.dataset
20
+ if descriptor is None:
21
+ raise HTTPException(status.HTTP_409_CONFLICT, "no dataset loaded; call POST /v1/train first")
22
+ return descriptor
@@ -0,0 +1,22 @@
1
+ """Model route: ``GET /v1/model`` — current topology + metrics. ``409`` if none."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, status
8
+
9
+ from juniper_recurrence.routers._common import get_state
10
+ from juniper_recurrence.schemas import ModelResponse
11
+ from juniper_recurrence.state import AppState
12
+
13
+ router = APIRouter(tags=["model"])
14
+
15
+
16
+ @router.get("/v1/model", response_model=ModelResponse)
17
+ def get_model(state: Annotated[AppState, Depends(get_state)]) -> ModelResponse:
18
+ """Topology (``describe_topology``) + regression metrics of the current model."""
19
+ model = state.model
20
+ if model is None:
21
+ raise HTTPException(status.HTTP_409_CONFLICT, "no trained model; call POST /v1/train first")
22
+ return ModelResponse(topology=dict(model.describe_topology()), metrics=model.metrics())
@@ -0,0 +1,67 @@
1
+ """Predict route: ``POST /v1/predict`` — continuous ``ŷ`` over the trained LMU.
2
+
3
+ Accepts inline arrays (``X`` + optional ``dt`` / ``target_dt`` / ``seq_lengths``) or a
4
+ dataset ref. Passes ``dt`` explicitly to engage the Δt path. Returns continuous
5
+ predictions — never an ``argmax`` collapse to labels (RK-6). ``409`` before any train.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Annotated, Any
11
+
12
+ import numpy as np
13
+ from fastapi import APIRouter, Depends, HTTPException, status
14
+ from juniper_data_client import JuniperDataClientError
15
+
16
+ from juniper_recurrence.data import load_sequence_data
17
+ from juniper_recurrence.routers._common import get_settings, get_state, map_data_error
18
+ from juniper_recurrence.schemas import PredictRequest, PredictResponse
19
+ from juniper_recurrence.settings import Settings
20
+ from juniper_recurrence.state import AppState
21
+
22
+ router = APIRouter(tags=["predict"])
23
+
24
+
25
+ @router.post("/v1/predict", response_model=PredictResponse)
26
+ def predict(
27
+ req: PredictRequest,
28
+ state: Annotated[AppState, Depends(get_state)],
29
+ settings: Annotated[Settings, Depends(get_settings)],
30
+ ) -> PredictResponse:
31
+ """Predict continuous targets for inline ``X`` or a dataset split."""
32
+ model = state.model
33
+ if model is None:
34
+ raise HTTPException(status.HTTP_409_CONFLICT, "no trained model; call POST /v1/train first")
35
+
36
+ if req.X is not None:
37
+ features = np.asarray(req.X, dtype=float)
38
+ kwargs: dict[str, Any] = {}
39
+ if req.dt is not None:
40
+ kwargs["dt"] = np.asarray(req.dt, dtype=float)
41
+ if req.target_dt is not None:
42
+ kwargs["target_dt"] = np.asarray(req.target_dt, dtype=float)
43
+ if req.seq_lengths is not None:
44
+ kwargs["seq_lengths"] = np.asarray(req.seq_lengths)
45
+ else:
46
+ try:
47
+ sequence, _ = load_sequence_data(
48
+ base_url=settings.juniper_data_url,
49
+ api_key=settings.juniper_data_api_key,
50
+ dataset_id=req.dataset.dataset_id,
51
+ name=req.dataset.name,
52
+ generator=req.dataset.generator,
53
+ params=req.dataset.params,
54
+ split=req.dataset.split,
55
+ )
56
+ except (JuniperDataClientError, ValueError) as exc:
57
+ raise map_data_error(exc) from exc
58
+ features = sequence.X
59
+ kwargs = sequence.fit_kwargs()
60
+
61
+ try:
62
+ predictions = model.predict(features, **kwargs)
63
+ except (ValueError, RuntimeError) as exc:
64
+ # 422 literal: avoids Starlette's deprecated HTTP_422_UNPROCESSABLE_ENTITY constant.
65
+ raise HTTPException(422, f"prediction failed: {exc}") from exc
66
+
67
+ return PredictResponse(predictions=predictions.tolist(), shape=list(predictions.shape))
@@ -0,0 +1,85 @@
1
+ """Training routes: ``POST /v1/train`` (synchronous) + ``GET /v1/training/status``.
2
+
3
+ D-WS4b-2 — training runs **inline**: load the 3-D NPZ, construct ``LMURegressor``,
4
+ drive ``TrainingLifecycle.run`` to completion on the request thread, store the model +
5
+ result + event buffer, and return the ``TrainResult`` in the response. No background
6
+ task, no WebSocket stream (deferred to WS-8). Correct for the µs one-shot ``lstsq``.
7
+
8
+ A non-blocking ``train_lock`` serialises runs — a second concurrent ``/v1/train`` gets
9
+ ``409`` rather than torn state. The data fetch happens inside the lock so the whole run
10
+ is serialised (the fetch dominates wall-clock, the solve is negligible).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Annotated
16
+
17
+ from fastapi import APIRouter, Depends, HTTPException, status
18
+ from juniper_data_client import JuniperDataClientError
19
+ from juniper_recurrence_model import LMURegressor
20
+ from juniper_service_core import TrainingLifecycle
21
+
22
+ from juniper_recurrence.data import load_sequence_data
23
+ from juniper_recurrence.events import EventSink
24
+ from juniper_recurrence.routers._common import get_settings, get_state, map_data_error
25
+ from juniper_recurrence.schemas import DatasetDescriptor, EventModel, StatusResponse, TrainRequest, TrainResponse
26
+ from juniper_recurrence.settings import Settings
27
+ from juniper_recurrence.state import AppState
28
+
29
+ router = APIRouter(tags=["training"])
30
+
31
+
32
+ @router.post("/v1/train", response_model=TrainResponse)
33
+ def train(
34
+ req: TrainRequest,
35
+ state: Annotated[AppState, Depends(get_state)],
36
+ settings: Annotated[Settings, Depends(get_settings)],
37
+ ) -> TrainResponse:
38
+ """Synchronously train the LMU on a dataset split and return the ``TrainResult``."""
39
+ if not state.train_lock.acquire(blocking=False):
40
+ raise HTTPException(status.HTTP_409_CONFLICT, "a training run is already in progress")
41
+ try:
42
+ try:
43
+ sequence, descriptor = load_sequence_data(
44
+ base_url=settings.juniper_data_url,
45
+ api_key=settings.juniper_data_api_key,
46
+ dataset_id=req.dataset.dataset_id,
47
+ name=req.dataset.name,
48
+ generator=req.dataset.generator,
49
+ params=req.dataset.params,
50
+ split=req.dataset.split,
51
+ )
52
+ except (JuniperDataClientError, ValueError) as exc:
53
+ raise map_data_error(exc) from exc
54
+
55
+ d = req.d if req.d is not None else settings.default_d
56
+ theta = req.theta if req.theta is not None else settings.default_theta
57
+ ridge = req.ridge if req.ridge is not None else settings.default_ridge
58
+
59
+ sink = EventSink()
60
+ model = LMURegressor(d=d, theta=theta, ridge=ridge)
61
+ lifecycle = TrainingLifecycle(model, on_event=sink)
62
+ result = lifecycle.run(sequence.X, sequence.y, **sequence.fit_kwargs())
63
+
64
+ dataset = DatasetDescriptor(**descriptor)
65
+ state.set_trained(model, result, sink, dataset)
66
+ return TrainResponse(
67
+ final_metrics=result.final_metrics,
68
+ n_epochs=result.n_epochs,
69
+ stopped_reason=result.stopped_reason,
70
+ dataset=dataset,
71
+ )
72
+ finally:
73
+ state.train_lock.release()
74
+
75
+
76
+ @router.get("/v1/training/status", response_model=StatusResponse)
77
+ def training_status(state: Annotated[AppState, Depends(get_state)]) -> StatusResponse:
78
+ """Last training status + ordered events from the in-memory sink (instant — sync)."""
79
+ state_name, result, events = state.status()
80
+ return StatusResponse(
81
+ state=state_name,
82
+ final_metrics=result.final_metrics if result is not None else None,
83
+ stopped_reason=result.stopped_reason if result is not None else None,
84
+ events=[EventModel(type=event.type, seq=event.seq, payload=event.payload) for event in events],
85
+ )
@@ -0,0 +1,135 @@
1
+ """Pydantic request / response models for the juniper-recurrence API (plan §6).
2
+
3
+ Regression-generic throughout (RK-6): predictions are continuous arrays, metrics are
4
+ the regression set (``mse`` / ``rmse`` / ``mae`` / ``r2`` / ``loss``) — never an
5
+ ``accuracy`` key and never an ``argmax`` collapse to class labels.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from pydantic import BaseModel, Field, model_validator
13
+
14
+ __all__ = [
15
+ "DatasetRef",
16
+ "DatasetDescriptor",
17
+ "TrainRequest",
18
+ "TrainResponse",
19
+ "EventModel",
20
+ "StatusResponse",
21
+ "PredictRequest",
22
+ "PredictResponse",
23
+ "ModelResponse",
24
+ ]
25
+
26
+
27
+ class DatasetRef(BaseModel):
28
+ """Reference to a 3-D sequence dataset to fetch via juniper-data-client.
29
+
30
+ Resolution precedence: ``dataset_id`` (direct) → ``name`` (latest version) →
31
+ ``generator`` + ``params`` (create on the fly). At least one must be supplied.
32
+ """
33
+
34
+ dataset_id: str | None = None
35
+ name: str | None = None
36
+ generator: str | None = None
37
+ params: dict[str, Any] = Field(default_factory=dict)
38
+ split: str = "train"
39
+
40
+ @model_validator(mode="after")
41
+ def _require_one_ref(self) -> DatasetRef:
42
+ if not (self.dataset_id or self.name or self.generator):
43
+ raise ValueError("dataset ref requires one of: dataset_id, name, generator")
44
+ return self
45
+
46
+
47
+ class DatasetDescriptor(BaseModel):
48
+ """Thin descriptor of a loaded dataset split (``GET /v1/dataset``)."""
49
+
50
+ dataset_id: str | None = None
51
+ name: str | None = None
52
+ split: str
53
+ n_windows: int
54
+ lookback: int
55
+ n_features: int
56
+ output_dim: int
57
+ has_target_dt: bool
58
+ has_seq_lengths: bool
59
+
60
+
61
+ class TrainRequest(BaseModel):
62
+ """Body for ``POST /v1/train``: a dataset ref plus optional LMU hyperparameters.
63
+
64
+ Unset hyperparameters fall back to the service defaults (``default_d`` /
65
+ ``default_theta`` / ``default_ridge``). ``theta=None`` is meaningful — it asks the
66
+ model to resolve θ data-drivenly from the per-window elapsed time. The irregular
67
+ forecast horizon (``target_dt``) is engaged automatically when the dataset carries
68
+ it (it is per-window data, not a hyperparameter).
69
+ """
70
+
71
+ dataset: DatasetRef
72
+ d: int | None = Field(default=None, ge=1)
73
+ theta: float | None = Field(default=None, gt=0)
74
+ ridge: float | None = Field(default=None, ge=0)
75
+
76
+
77
+ class TrainResponse(BaseModel):
78
+ """``POST /v1/train`` result: the ``TrainResult`` plus the dataset descriptor."""
79
+
80
+ final_metrics: dict[str, float]
81
+ n_epochs: int
82
+ stopped_reason: str | None = None
83
+ dataset: DatasetDescriptor
84
+
85
+
86
+ class EventModel(BaseModel):
87
+ """One serialised :class:`~juniper_model_core.TrainingEvent`."""
88
+
89
+ type: str
90
+ seq: int
91
+ payload: dict[str, Any]
92
+
93
+
94
+ class StatusResponse(BaseModel):
95
+ """``GET /v1/training/status``: synchronous, instant (no background job)."""
96
+
97
+ state: str # "idle" | "trained"
98
+ final_metrics: dict[str, float] | None = None
99
+ stopped_reason: str | None = None
100
+ events: list[EventModel] = Field(default_factory=list)
101
+
102
+
103
+ class PredictRequest(BaseModel):
104
+ """Body for ``POST /v1/predict``: inline arrays **or** a dataset ref.
105
+
106
+ ``X`` is ``(n, T, F)``; ``dt`` ``(n, T)`` engages the Δt path; ``target_dt`` ``(n,)``
107
+ supplies the irregular horizon; ``seq_lengths`` ``(n,)`` selects the many-to-one
108
+ readout step. Exactly one of ``X`` / ``dataset`` is required.
109
+ """
110
+
111
+ X: list | None = None
112
+ dt: list | None = None
113
+ target_dt: list | None = None
114
+ seq_lengths: list | None = None
115
+ dataset: DatasetRef | None = None
116
+
117
+ @model_validator(mode="after")
118
+ def _require_x_or_dataset(self) -> PredictRequest:
119
+ if self.X is None and self.dataset is None:
120
+ raise ValueError("predict requires either 'X' or 'dataset'")
121
+ return self
122
+
123
+
124
+ class PredictResponse(BaseModel):
125
+ """Continuous predictions ``ŷ`` and their shape (never argmax — RK-6)."""
126
+
127
+ predictions: list
128
+ shape: list[int]
129
+
130
+
131
+ class ModelResponse(BaseModel):
132
+ """``GET /v1/model``: current model topology + metrics."""
133
+
134
+ topology: dict[str, Any]
135
+ metrics: dict[str, float]
@@ -0,0 +1,118 @@
1
+ """Settings for the juniper-recurrence service.
2
+
3
+ Subclasses :class:`juniper_service_core.SettingsBase` (which supplies
4
+ ``service_name`` / ``host`` / ``port`` / ``log_level``) and reads the
5
+ ``JUNIPER_RECURRENCE_`` environment namespace.
6
+
7
+ Three hardening choices, each a recorded ecosystem incident (plan §7 / §15):
8
+
9
+ * **No ``env_file=``** — setting it is the pydantic-settings ``.env``-leak class
10
+ (cascor #309 / canopy #325 / data #153). Isolation relies on ``env_prefix`` +
11
+ ``extra="ignore"`` only.
12
+ * **Docker ``_FILE`` secret indirection** — ``api_keys`` and the outbound
13
+ ``juniper_data_api_key`` resolve through :func:`juniper_service_core.get_secret`,
14
+ which prefers ``<VAR>_FILE`` (a mounted path) over ``<VAR>`` (worker-secret
15
+ incident precedent).
16
+ * **``api_keys`` accepts CSV or JSON-array** — :data:`NoDecode` keeps
17
+ pydantic-settings from JSON-decoding the env value, so a plain secret-file
18
+ payload (``"k1,k2"``) never raises the JSON-list ``ValidationError`` (cascor
19
+ ``_parse_api_keys`` precedent / secrets.example incident).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from typing import Annotated, Any
26
+
27
+ from juniper_service_core import SettingsBase, get_secret
28
+ from pydantic import AliasChoices, Field, field_validator, model_validator
29
+ from pydantic_settings import NoDecode, SettingsConfigDict
30
+
31
+ __all__ = ["Settings"]
32
+
33
+
34
+ class Settings(SettingsBase):
35
+ """Runtime configuration for the juniper-recurrence app (env prefix ``JUNIPER_RECURRENCE_``)."""
36
+
37
+ model_config = SettingsConfigDict(env_prefix="JUNIPER_RECURRENCE_", extra="ignore")
38
+
39
+ # --- service identity / bind (override SettingsBase defaults) ---------------------
40
+ service_name: str = "juniper-recurrence"
41
+ # Container default binds all interfaces; for a local ``serve`` set
42
+ # ``JUNIPER_RECURRENCE_HOST=127.0.0.1`` (design §6.8). No flake8-bandit S-rule
43
+ # is enabled for this package, so no inline suppression is needed.
44
+ host: str = "0.0.0.0"
45
+ port: int = 8210 # container port; deploy maps host 8211 -> ctr 8210 (design §6.8)
46
+
47
+ # --- API-key auth + rate limiting -------------------------------------------------
48
+ api_keys: Annotated[list[str] | None, NoDecode] = Field(default=None)
49
+ rate_limit_enabled: bool = True
50
+ rate_limit_requests_per_minute: int = 60
51
+
52
+ # --- upstream juniper-data (outbound, consumed by the PR-2 data path) -------------
53
+ juniper_data_url: str = Field(
54
+ default="http://localhost:8100",
55
+ validation_alias=AliasChoices("juniper_data_url", "JUNIPER_DATA_URL", "JUNIPER_RECURRENCE_JUNIPER_DATA_URL"),
56
+ )
57
+ juniper_data_api_key: str | None = Field(default=None)
58
+
59
+ # --- LMU hyperparameter defaults (consumed by the PR-2 training path) -------------
60
+ default_d: int = 16
61
+ default_theta: float | None = None
62
+ default_ridge: float = 0.0
63
+
64
+ # --- secret resolution (honor Docker ``_FILE`` indirection) -----------------------
65
+ @model_validator(mode="before")
66
+ @classmethod
67
+ def _load_secrets_from_files(cls, data: Any) -> Any:
68
+ """Populate ``api_keys`` / ``juniper_data_api_key`` from ``*_FILE`` secrets.
69
+
70
+ ``get_secret`` checks ``<VAR>_FILE`` (a mounted path) before ``<VAR>`` so
71
+ Docker / Compose secrets resolve without code change. The outbound
72
+ juniper-data key reads the shared, unprefixed ``JUNIPER_DATA_API_KEY``
73
+ (and ``JUNIPER_DATA_API_KEY_FILE``) — the cross-service convention used by
74
+ cascor / canopy — falling back to the ``JUNIPER_RECURRENCE_``-prefixed form
75
+ via the field's own env binding when set.
76
+ """
77
+ if isinstance(data, dict):
78
+ if not data.get("api_keys"):
79
+ secret = get_secret("JUNIPER_RECURRENCE_API_KEYS")
80
+ if secret:
81
+ data["api_keys"] = secret
82
+ if not data.get("juniper_data_api_key"):
83
+ secret = get_secret("JUNIPER_DATA_API_KEY")
84
+ if secret:
85
+ data["juniper_data_api_key"] = secret
86
+ return data
87
+
88
+ @field_validator("api_keys", mode="before")
89
+ @classmethod
90
+ def _parse_api_keys(cls, value: Any) -> list[str] | None:
91
+ """Normalise ``api_keys`` to ``list[str] | None`` from CSV, JSON-array, or list.
92
+
93
+ Accepts a plain secret-file string (``"k1,k2"`` or ``'["k1","k2"]'``) without
94
+ the pydantic-settings JSON-list ``ValidationError``. Empty / whitespace-only
95
+ input collapses to ``None`` (auth disabled / open access).
96
+ """
97
+ if value is None:
98
+ return None
99
+ if isinstance(value, str):
100
+ text = value.strip()
101
+ if not text:
102
+ return None
103
+ if text.startswith("[") and text.endswith("]"):
104
+ try:
105
+ parsed = json.loads(text)
106
+ except (json.JSONDecodeError, ValueError):
107
+ parsed = None
108
+ if isinstance(parsed, list):
109
+ return [str(item).strip() for item in parsed if str(item).strip()]
110
+ return [item.strip() for item in text.split(",") if item.strip()]
111
+ if isinstance(value, (list, tuple)):
112
+ cleaned = [str(item).strip() for item in value if str(item).strip()]
113
+ return cleaned or None
114
+ return value
115
+
116
+ def resolve_api_keys(self) -> list[str]:
117
+ """The configured API keys as a plain list (empty ⇒ auth disabled / open access)."""
118
+ return list(self.api_keys or [])
@@ -0,0 +1,69 @@
1
+ """In-process application state for the juniper-recurrence service (plan §4).
2
+
3
+ A single in-memory holder for the current trained model, its last
4
+ :class:`~juniper_model_core.TrainResult`, the training event buffer, and a
5
+ descriptor of the dataset it was trained on. One instance lives per app (stored on
6
+ ``app.state.app_state`` by :func:`juniper_recurrence.app.build_app`) — so each
7
+ ``build_app`` gets isolated state, which keeps tests hermetic while remaining the
8
+ single in-process holder the plan calls for (uvicorn ``workers=1``; persistence and
9
+ scale-out are deferred to WS-8).
10
+
11
+ Concurrency: ``train_lock`` serialises training (a second concurrent ``/v1/train``
12
+ gets ``409`` via a non-blocking acquire). Readers (``predict`` / ``status`` /
13
+ ``model`` / ``dataset``) take no lock — :meth:`set_trained` publishes the model
14
+ reference **last**, so a reader that sees a non-``None`` model also sees a fully
15
+ populated result / events / descriptor (publish-the-pointer-last).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import threading
21
+ from typing import TYPE_CHECKING
22
+
23
+ if TYPE_CHECKING:
24
+ from juniper_model_core import TrainResult
25
+ from juniper_recurrence_model import LMURegressor
26
+
27
+ from juniper_recurrence.events import EventSink
28
+ from juniper_recurrence.schemas import DatasetDescriptor
29
+
30
+ __all__ = ["AppState"]
31
+
32
+
33
+ class AppState:
34
+ """Single in-process holder for the trained model + last run artifacts."""
35
+
36
+ def __init__(self) -> None:
37
+ self.train_lock = threading.Lock()
38
+ self._model: LMURegressor | None = None
39
+ self._result: TrainResult | None = None
40
+ self._events: EventSink | None = None
41
+ self._dataset: DatasetDescriptor | None = None
42
+
43
+ def set_trained(
44
+ self,
45
+ model: LMURegressor,
46
+ result: TrainResult,
47
+ events: EventSink,
48
+ dataset: DatasetDescriptor,
49
+ ) -> None:
50
+ """Publish a completed training run. Sets ``_model`` last (see module docstring)."""
51
+ self._result = result
52
+ self._events = events
53
+ self._dataset = dataset
54
+ self._model = model # published last
55
+
56
+ @property
57
+ def model(self) -> LMURegressor | None:
58
+ return self._model
59
+
60
+ @property
61
+ def dataset(self) -> DatasetDescriptor | None:
62
+ return self._dataset
63
+
64
+ def status(self) -> tuple[str, TrainResult | None, list]:
65
+ """``("idle"|"trained", last_result, ordered_events)`` for ``/v1/training/status``."""
66
+ if self._model is None:
67
+ return ("idle", None, [])
68
+ events = self._events.snapshot() if self._events is not None else []
69
+ return ("trained", self._result, events)
@@ -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,20 @@
1
+ juniper_recurrence/__init__.py,sha256=8LGdyZ0frXCtQrp9eC25IhhVe22VDOOMPLIJX_B_SRY,1075
2
+ juniper_recurrence/_version.py,sha256=CAV2FmQZmxEw246YVA-647fqxnRPBT2pn7oMrRIMGBc,414
3
+ juniper_recurrence/app.py,sha256=Uive-rqJVJBLqLgvhW7Uvz7gt0nANC6JdqywdUL5wRw,3458
4
+ juniper_recurrence/data.py,sha256=UkjJHQuICiPha_ZCqD-ioR3_mQNW4Sa0SyflMF6iHD4,4396
5
+ juniper_recurrence/events.py,sha256=Ed4Z2_GTxTSiC92dMqRfyWtPNsDi7KaNXcl9_MGR5IE,1291
6
+ juniper_recurrence/main.py,sha256=m2gbQdmK2RZ02F2OrRSMukVaipM0WS6CKOPiyQk8IvQ,4969
7
+ juniper_recurrence/schemas.py,sha256=SLcRDEn9jQvT7eNxp5gJqjBOhPgVA9cm4oATPssLmJ4,4151
8
+ juniper_recurrence/settings.py,sha256=GKly5PQEUetF0q6EQ0Ex2PFPZ3sESz00LP-mqJp4_nw,5460
9
+ juniper_recurrence/state.py,sha256=N3aZ6EWu1NAqQAOLbQFzFJh0_xzzuOklvYHMyHTDyB0,2633
10
+ juniper_recurrence/routers/__init__.py,sha256=uQ3A9RaAqgI8iEiiMP_OB6Q-WMfkgwTEFhPfP-vmCZw,686
11
+ juniper_recurrence/routers/_common.py,sha256=FNkY9M1MSKGxdPNVV3YU1msh-EDoKo95zc0NV0nyfEI,2394
12
+ juniper_recurrence/routers/dataset.py,sha256=FwK7FwtCOeWicem2m2ha-5fN9HWHh0AAVwESmpYXVcY,841
13
+ juniper_recurrence/routers/model.py,sha256=1-vtc5raII1myeLY3KuVV6olqjTC4wPTESm802iZXuc,870
14
+ juniper_recurrence/routers/predict.py,sha256=jX1I--5px6aNCJWB0-J0_bs7mex457YaCd_EWKxGmSo,2729
15
+ juniper_recurrence/routers/training.py,sha256=oLhOj-idiGxlMTy9qbdkwrGNh2fd1gSuC8NBkHSVdYc,3816
16
+ juniper_recurrence-0.1.0.dist-info/METADATA,sha256=VTbzFq7oqoq5OAI_PKvRw99UWjqxuidP70Rf0KZRhqM,5988
17
+ juniper_recurrence-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
18
+ juniper_recurrence-0.1.0.dist-info/entry_points.txt,sha256=xmowBXZoVgIFVz48OJn6BVdOcAtpCNIcOaql3APFmsY,68
19
+ juniper_recurrence-0.1.0.dist-info/top_level.txt,sha256=74teqCOSybK6MI9GaUfGe85sRjX7gedf4fHTphOEdrA,19
20
+ juniper_recurrence-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ juniper-recurrence = juniper_recurrence.main:main
@@ -0,0 +1 @@
1
+ juniper_recurrence