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.
- juniper_recurrence/__init__.py +21 -0
- juniper_recurrence/_version.py +9 -0
- juniper_recurrence/app.py +83 -0
- juniper_recurrence/data.py +105 -0
- juniper_recurrence/events.py +34 -0
- juniper_recurrence/main.py +116 -0
- juniper_recurrence/routers/__init__.py +13 -0
- juniper_recurrence/routers/_common.py +55 -0
- juniper_recurrence/routers/dataset.py +22 -0
- juniper_recurrence/routers/model.py +22 -0
- juniper_recurrence/routers/predict.py +67 -0
- juniper_recurrence/routers/training.py +85 -0
- juniper_recurrence/schemas.py +135 -0
- juniper_recurrence/settings.py +118 -0
- juniper_recurrence/state.py +69 -0
- juniper_recurrence-0.1.0.dist-info/METADATA +148 -0
- juniper_recurrence-0.1.0.dist-info/RECORD +20 -0
- juniper_recurrence-0.1.0.dist-info/WHEEL +5 -0
- juniper_recurrence-0.1.0.dist-info/entry_points.txt +2 -0
- juniper_recurrence-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
1
|
+
juniper_recurrence
|