Metadata-Version: 2.4
Name: juniper-recurrence-model
Version: 0.2.0
Summary: Δt-native Legendre Memory Unit (Approach-C) and recurrent model core for the juniper-recurrence application
Author: Paul Calnon
License-Expression: MIT
Project-URL: Homepage, https://github.com/pcalnon/juniper-recurrence
Project-URL: Repository, https://github.com/pcalnon/juniper-recurrence
Project-URL: Issues, https://github.com/pcalnon/juniper-recurrence/issues
Keywords: juniper,recurrence,lmu,legendre,state-space,time-series,irregular-dt
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Typing :: Typed
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Requires-Dist: numpy>=1.24
Requires-Dist: juniper-model-core<0.4.0,>=0.2.0
Provides-Extra: test
Requires-Dist: pytest>=8.0; extra == "test"
Requires-Dist: pytest-cov>=5.0; extra == "test"
Requires-Dist: juniper-model-core[crossval]>=0.2.0; extra == "test"
Provides-Extra: torch
Requires-Dist: torch>=2.10.0; extra == "torch"

# juniper-recurrence-model

The model-specific core for the [juniper-recurrence](https://github.com/pcalnon/juniper-recurrence)
application — the selected model **P3-C (LMU + Approach-C)**.

This package ships the **Δt-native Legendre Memory Unit (Approach-C)** — a closed-form,
variable-step LMU discretisation that is the only first-principles-clean ("C1") option natively
handling irregularly-sampled time series — **and** `LMURegressor`, the recurrent model
implementing the shared [`juniper-model-core`](https://github.com/pcalnon/juniper-ml)
`TrainableModel` interface (now that that package has landed). The regressor keeps the LMU memory
**fixed** and trains only the readout — a **linear** closed-form least-squares fit by default (no
BPTT, fully deterministic), with an optional nonlinear **readout spectrum** (ridge/GCV, random
Fourier features, and a torch MLP — see [Readout spectrum](#readout-spectrum)). It passes
model-core's conformance kit unchanged, making it the WS-4 refactor template (a non-cascor model on
the shared model seam).

Design of record (in juniper-ml):
[`notes/JUNIPER_RECURRENCE_MODEL_DETAILED_DESIGN_2026-06-14.md`](https://github.com/pcalnon/juniper-ml/blob/main/notes/JUNIPER_2026-06-14_JUNIPER-RECURRENCE_MODEL-DETAILED-DESIGN.md).

## Why Approach-C

An LMU's linear memory obeys `theta * m'(t) = A·m(t) + B·u(t)` with **fixed, closed-form** matrices.
Because the system is linear, its *exact* discretisation is a matrix exponential — **no ODE solver,
no autodiff-through-solver**. For irregular sampling, the discrete update is simply evaluated at the
real per-step gap `dt`: the dataset's `dt` channel *is* the discretisation step. `A`/`B` are never
trained; only the read-in/readout are. That is the entire C1-clean, irregular-Δt-native story.

## Install

```bash
pip install juniper-recurrence-model          # once published
pip install -e ".[test]"                       # local development
```

numpy-only at the core (the memory is a fixed linear recurrence requiring no autodiff).

## Quick start

```python
import numpy as np
from juniper_recurrence_model import VariableStepLMUMemory

mem = VariableStepLMUMemory(d=16, theta=1.0)   # order 16, window 1.0 (same unit as dt)

# Irregularly-sampled input: u driven on a non-uniform time grid
t = np.cumsum(np.r_[0.0, np.random.default_rng(0).uniform(0.02, 0.08, 239)])
dt = np.empty_like(t); dt[0] = 0.0; dt[1:] = np.diff(t)
u = np.sin(2.0 * t)

m = mem.rollout(u, dt)                          # (240, 16) memory trajectory
w = mem.decode_weights(rho=1.0)                 # read the input one full window ago
reconstruction = m @ w
```

## Trainable model (`LMURegressor`)

The package also exposes `LMURegressor`, a `juniper-model-core` `TrainableModel`. The
LMU memory is fixed; the **readout** is fit — a linear closed-form least-squares solve by default
(no BPTT, fully deterministic), or a nonlinear readout from the [spectrum below](#readout-spectrum).
It is Δt-native: pass per-step gaps `dt` (`(n, T)`) and an optional `readout_mask`
to `fit` / `predict`; both default to uniform gaps and the final step, so the bare ABC
`predict(X)` works too. It reports canonical regression metrics (`mse`, `rmse`, `mae`, `r2`).

```python
import numpy as np
from juniper_recurrence_model import LMURegressor, LMUSerializer

n, T, F = 48, 6, 3
X = np.random.default_rng(0).normal(size=(n, T, F))
y = X.reshape(n, -1) @ np.random.default_rng(1).normal(size=(T * F, 1))
dt = np.zeros((n, T)); dt[:, 1:] = np.random.default_rng(2).integers(1, 4, size=(n, T - 1))

model = LMURegressor(d=6)             # theta resolved data-driven from dt at fit time
result = model.fit(X, y, dt=dt)                 # closed-form readout solve
preds = model.predict(X, dt=dt)                 # (n, 1)
print(result.final_metrics["r2"], model.describe_topology()["model_type"])

LMUSerializer().save(model, "/tmp/lmu")   # writes /tmp/lmu.npz (lossless round-trip)
```

`LMURegressor` passes model-core's conformance kit unchanged
(`tests/test_conformance.py`), proving the WS-4 refactor template.

## Readout spectrum

The LMU memory is always the fixed, closed-form Δt-native recurrence; only the **readout** on top of
the memory trajectory varies. Select it with the `readout=` constructor argument (DP-3):

| Rung | Spec | What it fits | Notes |
|---|---|---|---|
| 0 / 1 | `LinearReadoutSpec` (default) | linear least squares, optional L2 | `ridge=` a float, or `"gcv"` for closed-form generalized-cross-validation selection |
| 2a | `RFFReadoutSpec` | linear fit over random Fourier features of the memory | numpy-only; `n_features_out` / `gamma` (or `gamma="median"`) — adds nonlinear capacity |
| 2b | `MLPReadoutSpec` | a small torch MLP over the memory | needs the `[torch]` extra (`pip install juniper-recurrence-model[torch]`); deterministic CPU fit |

```python
from juniper_recurrence_model import LMURegressor, RFFReadoutSpec

model = LMURegressor(d=16, readout=RFFReadoutSpec(n_features_out=256, gamma="median"))
```

All three rungs keep the closed-form Δt memory and pass the conformance kit (including bit-exact
save/load). On the synthetic `delay_product` capacity benchmark the RFF readout measures **+0.83 r²**
over the linear readout; on the efficient-market equities target the spread is ≈0 (juniper-ml DP-3
findings). Default to `linear` + `ridge="gcv"`; reach for `rff` / `mlp` when the target has genuine
nonlinear structure.

## Verified behaviour

| Check | Result |
|---|---|
| `A` (d=16) max eigenvalue real part | **−6.49** (< 0 → stable) |
| Reconstruction RMSE `e_reg` (regular grid) | **≈ 0.035** (< 0.05) |
| Grid-invariance `e_irr` (irregular grid) | **≈ 0.039–0.043** (≈1.15× `e_reg`; < 3·`e_reg` + 0.02) |

Pinned by `tests/test_lmu_grid_invariance.py`. Numerics match the reference
`util/ad-hoc/verify_delta_t_reference_code.py` in juniper-ml.

## Numerical guardrails

- Keep `d ≲ 64` — the eigenvector matrix of `A` becomes ill-conditioned for large `d`
  (Padé scaling-and-squaring is the documented fallback for larger orders).
- Stability is automatic for `dt > 0` (`Re(λ) < 0 ⇒ |e^z| < 1`).
- `dt` may be quantised (e.g. integer calendar-day gaps) and `Abar`/`Bbar` cached per bucket.

## Versioning

PEP 440 + [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Consumers should pin
`juniper-recurrence-model>=A.B,<A+1`. See [`CHANGELOG.md`](./CHANGELOG.md).

## License

MIT — see [LICENSE](https://github.com/pcalnon/juniper-recurrence/blob/main/LICENSE).
