diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b3e0976..a564f04 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,6 +10,7 @@ on:
- "examples/**"
- "docs/**"
- "README.md"
+ - "benchmarks/**"
- ".github/workflows/**"
- "pyproject.toml"
- "uv.lock"
@@ -32,6 +33,7 @@ jobs:
notebooks: ${{ steps.filter.outputs.notebooks }}
docs: ${{ steps.filter.outputs.docs }}
workflow: ${{ steps.filter.outputs.workflow }}
+ benchmark: ${{ steps.filter.outputs.benchmark }}
steps:
- uses: actions/checkout@v7
with:
@@ -101,12 +103,19 @@ jobs:
notebooks = hit("examples/", "src/", "pyproject.toml", "uv.lock")
docs = hit("docs/", "README.md", "src/", "pyproject.toml")
workflow = hit(".github/workflows/ci.yml")
+ benchmark = hit(
+ "benchmarks/",
+ "src/koopman_graph/benchmark/",
+ "src/koopman_graph/cli/",
+ "tests/benchmark/",
+ )
emit("lint", lint)
emit("test", test)
emit("notebooks", notebooks)
emit("docs", docs)
emit("workflow", workflow)
+ emit("benchmark", benchmark)
PY
lint:
@@ -117,7 +126,7 @@ jobs:
- uses: actions/checkout@v7
- name: Install uv
- uses: astral-sh/setup-uv@v9.0.0
+ uses: astral-sh/setup-uv@v10.0.1
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
@@ -149,7 +158,7 @@ jobs:
- uses: actions/checkout@v7
- name: Install uv
- uses: astral-sh/setup-uv@v9.0.0
+ uses: astral-sh/setup-uv@v10.0.1
with:
python-version: ${{ matrix.python-version }}
enable-cache: true
@@ -210,7 +219,7 @@ jobs:
- uses: actions/checkout@v7
- name: Install uv
- uses: astral-sh/setup-uv@v9.0.0
+ uses: astral-sh/setup-uv@v10.0.1
with:
python-version: "3.12"
enable-cache: true
@@ -268,6 +277,13 @@ jobs:
"examples/19_bilinear_control_koopman.ipynb",
"examples/22_gnn_forecaster_comparison.ipynb",
"examples/23_hierarchical_multiresolution.ipynb",
+ "examples/47_benchmark_manifest.ipynb",
+ "examples/48_identification_invariance.ipynb",
+ "examples/50_graph_state_closure.ipynb",
+ "examples/51_spectral_diagnostics.ipynb",
+ "examples/52_cochain_hodge_modes.ipynb",
+ "examples/53_latent_rank_selection.ipynb",
+ "examples/54_criticality_monitor.ipynb",
]
),
}
@@ -289,11 +305,12 @@ jobs:
),
},
{
- "shard": "27-46",
+ "shard": "27-54",
"pattern": (
"examples/2[7-9]_*.ipynb "
"examples/3[0-9]_*.ipynb "
- "examples/4[0-6]_*.ipynb"
+ "examples/4[0-9]_*.ipynb "
+ "examples/5[0-4]_*.ipynb"
),
},
]
@@ -316,7 +333,7 @@ jobs:
- uses: actions/checkout@v7
- name: Install uv
- uses: astral-sh/setup-uv@v9.0.0
+ uses: astral-sh/setup-uv@v10.0.1
with:
python-version: "3.12"
enable-cache: true
@@ -325,7 +342,7 @@ jobs:
- name: Install dependencies
run: >
uv sync --frozen
- --extra dev --extra rl --extra mpc --extra symmetry
+ --extra dev --extra rl --extra mpc --extra symmetry --extra cli
- name: Execute tutorial notebooks
env:
@@ -344,7 +361,7 @@ jobs:
- uses: actions/checkout@v7
- name: Install uv
- uses: astral-sh/setup-uv@v9.0.0
+ uses: astral-sh/setup-uv@v10.0.1
with:
python-version: "3.12"
enable-cache: true
@@ -356,11 +373,38 @@ jobs:
- name: Build Sphinx documentation
run: uv run --no-sync sphinx-build -b html -W --keep-going docs/source docs/build/html
+ benchmark-smoke:
+ # Identity-bound verify on hashed synthetics. Not METR-LA training.
+ name: benchmark-smoke
+ needs: changes
+ if: needs.changes.outputs.benchmark == 'true' || needs.changes.outputs.workflow == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v10.0.1
+ with:
+ python-version: "3.12"
+ enable-cache: true
+ cache-dependency-glob: "uv.lock"
+
+ - name: Install dependencies
+ run: uv sync --frozen --extra cli
+
+ - name: Verify smoke manifests
+ run: |
+ for track in smoke_telemetry smoke_multiphysics smoke_topology_transfer; do
+ uv run --no-sync koopman-graph benchmark verify \
+ --manifest "benchmarks/v0.15/${track}.yaml" \
+ --against "benchmarks/v0.15/summaries/${track}.json"
+ done
+
# Single required check for branch protection. Individual jobs may be skipped
# by path filters; this aggregator still reports success when skips are expected.
ci:
name: ci
- needs: [changes, lint, test, test-macos, notebook-plan, notebooks, docs]
+ needs: [changes, lint, test, test-macos, notebook-plan, notebooks, docs, benchmark-smoke]
if: always()
runs-on: ubuntu-latest
steps:
@@ -383,6 +427,7 @@ jobs:
"notebook-plan",
"notebooks",
"docs",
+ "benchmark-smoke",
):
result = needs[job]["result"]
if result not in ("success", "skipped"):
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index dd92e5f..0ee0654 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -12,7 +12,7 @@ jobs:
- uses: actions/checkout@v7
- name: Install uv
- uses: astral-sh/setup-uv@v9.0.0
+ uses: astral-sh/setup-uv@v10.0.1
with:
python-version: "3.12"
enable-cache: true
@@ -58,7 +58,7 @@ jobs:
- uses: actions/checkout@v7
- name: Install uv
- uses: astral-sh/setup-uv@v9.0.0
+ uses: astral-sh/setup-uv@v10.0.1
with:
python-version: "3.12"
enable-cache: true
diff --git a/.gitignore b/.gitignore
index f5de5c6..9514b32 100644
--- a/.gitignore
+++ b/.gitignore
@@ -257,6 +257,9 @@ docs/MATHEMATICS_VERIFICATION.md
.cursor-tmp/
docs/blueprint/
+# Local run artifacts (benchmark summaries, CLI checkpoints, tracking CSVs).
+artifacts/
+
# Large / non-shipped benchmark downloads (rebuild via scripts/download_*.py).
# Tracked teaching fixtures and source files under 50 MB may live under
# data/metr_la/, data/ieee118/, data/cylinder_wake/, data/pems_bay/,
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e3e87b6..492b1d8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,411 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.15.0] - 2026-08-19
+
+Integration increment on the 0.14 stack: opt-in closed-form
+identification, identity-bound benchmark manifests, polynomial graph
+filters, Nyquist / conditioning diagnostics, and labeled research MVPs
+(graph-state closure, cochain dynamics, matrix-free algebra,
+residual-tube MPC). Homogeneous scientific defaults and the linear
+latent operator contract are unchanged (`koopman=None` still selects
+`"pernode"`). `FORMAT_VERSION` stays 1.
+
+### Added
+
+- README Highlights for opt-in identification (closed-form $K$; default
+ Adam `fit` unchanged; rank grid is not Ray Tune) and identity-bound
+ benchmarks (`koopman-graph benchmark run` / `verify`; the runner does
+ not train or invent MAE / RMSE; not a LibCity / BasicTS host). Featured
+ tutorials list examples 47 and 48. Related software names PyDMD and
+ kooplearn as flat-vector peers.
+- `paper.bib` method keys for the post-0.14 catalog:
+ `Guo2025ModularEDMD`, `LibCity2021`, `BasicTS2024`, `kooplearn2026`,
+ `PyKoopman2024` (same work as `Pan2024`), and `PyDMD2018`. Existing
+ method keys remain cited in source or Sphinx pages. No invented
+ forecast numbers.
+- Sphinx guides for identity-bound benchmarks, opt-in identification,
+ spectral diagnostics, predicted graph dynamics, matrix-free linear
+ operators, the spectral-gap criticality monitor, and time /
+ parameter conditioning (`docs/source/benchmarks.rst`,
+ `identification.rst`, `spectral_diagnostics.rst`,
+ `graph_dynamics.rst`, `matrix_free.rst`, `criticality.rst`,
+ `time_conditioning.rst`). Narrative only; defaults and
+ `FORMAT_VERSION` stay 1. No telemetry download.
+- Examples 48 and 51–54: identification report plus residual-aware
+ reject of a polluted RMSE-only dictionary; non-normal / Nyquist
+ `SpectralDiagnostics` toys; cycle Hodge harmonic split; latent-rank
+ recovery on a linear Gaussian embedding; closing-gap criticality
+ score (not a certificate). Teaching-thin; no METR-LA download.
+- Optional `SpectralDiagnostics` on `KoopmanSpectrum` (`κ(V)` after
+ column normalization, per-mode Wilkinson `κ_i`, Frobenius departure
+ from normality and a scale-free relative variant, Nyquist frequency
+ `1/(2Δt)` in cycles per unit time on discrete spectra, per-mode
+ aliasing flags, and `sign(Re λ)`). Populated by `compute_spectrum` /
+ `compute_generator_spectrum` (`nyquist_frequency` is `None` on
+ generators). Discrete `compute_spectrum` emits one `UserWarning` when
+ any mode is Nyquist-adjacent. `implied_timescales` carries the same
+ flag without changing the `|λ|` validity window. Not a
+ finite-horizon stability certificate, not a sampling-theorem
+ identification result, and not on the root façade.
+ `mode_amplitudes` warns when `κ(V)` exceeds `CONDITION_WARN`
+ (`1e6`) and raises `DefectiveSpectrumError` (a `LinAlgError`) when
+ `V` is singular, with a Schur-subspace hint rather than a bare
+ `RuntimeError`. `matrix_log(..., defective="error")` (default) raises
+ the same error on Jordan / singular eigenbases instead of inverting
+ `V`; `defective="schur"` uses SciPy `logm` (CPU; Al-Mohy–Higham).
+ Neither path claims that defectivity marks every critical regime.
+ `monitor_critical_transition` scores sliding-window spectral-gap
+ closure and near-defectivity on a sequence of spectra (positive rate
+ means the gap shrank). Diagnostic only — not a Ghosh-grade
+ topology-criticality certificate, and not on the root façade.
+- Discrete `koopman="graph"` accepts `koopman_filter_degree` (default
+ `1`, the one-tap `I⊗K_self + Â⊗K_nbr` map). `P=0` is self only;
+ `P>1` is an opt-in monomial `sum_k Â^k ⊗ K_k` with globally shared
+ extra hops. Dual random-walk remains an extra direction, not an extra
+ hop. `P=1` checkpoints and forwards stay bit-identical. Block-diagonal
+ inverse and distributed inverse / Arnoldi require `P=1`; dense inverse
+ uses the assembled polynomial. Eligible discrete spectrum uses
+ Kronecker `eig(B(λ_i))` with `B(λ)=Σ_k λ^k K_k` for any `P>=0`
+ (not a sum of independent factor eigenvalues). Dual / orbit stay
+ dense; continuous-graph Kronecker remains one-tap. The helper raises
+ `ValueError` for dual adjacency; operator `.spectrum` still
+ dense-routes. Kronecker still does not invert or cheapen
+ eig-regularization. This does not
+ attribute the example-38 path-diffusion gap (hold-out MSE approximately
+ 0.71 versus 0.019) to hop order.
+- `GraphKoopmanModel.fit` warns
+ (`ReceptiveFieldMismatchWarning`, off the root façade) when a
+ topology-aware encoder mixes more hops than discrete graph
+ `filter_degree`. GCN/GAT/SAGE/Transformer stacks count one hop per
+ layer; DiffConv counts `num_layers * diffusion_steps`. Encoder
+ neighborhood mixing does not compensate for a one-hop Koopman
+ factor. The check is warn-only and skips operators without
+ `receptive_field_hops`.
+- Opt-in `GraphKoopmanModel.fit(..., batch_graphs=True)` collates
+ independent homogeneous trajectories into one PyG `Batch` so shared
+ `K` applies to the disconnected union with per-graph shifts.
+ Reconstruction (and forward consistency when weighted) match the mean
+ of the existing per-sequence `MultiTrajectory` loop on modest
+ batches. The default Python loop is unchanged. This is vectorization,
+ not a new multi-topology capability. Collate types stay off the root
+ façade.
+- Example 38 adds a hop-matched degree-`P` arm (`P` in `{0, 1, 2}`, 40
+ epochs) on the same path-diffusion surrogate as the historical one-tap
+ versus joint least-squares comparison (80 epochs, unchanged numbers).
+ Example 49 checks Kronecker polynomial spectrum against dense `K_eff`
+ on a tiny `P=2` path and exercises the hop-mismatch warning. Neither
+ notebook attributes the historical hold-out MSE gap (approximately
+ 0.71 versus 0.019) to hop order.
+- `koopman_graph.identification` closed-form ridge / TLS / constrained
+ least-squares solvers and opt-in
+ `GraphKoopmanModel.fit(..., identification=IdentificationConfig(...))`
+ (off the root façade). Default `identification=None` keeps the Adam
+ path and does not import the package. Alternating fit freezes the
+ encoder, writes dense per-node `K`, then takes encoder/decoder Adam
+ steps. Discrete dense `KoopmanOperator` only; graph / hetero /
+ continuous / controlled / delay / windowed / DDP layouts raise.
+ `solver="varpro"` raises `NotImplementedError`. After an
+ identification fit, `model.identification_report` holds one-step and
+ short-rollout latent mean squared error (MSE) plus `ρ(K)`. That
+ record is not a Haseli–Cortés, ResDMD, or Lyapunov certificate, and
+ is not ResKoopNet residual training.
+- Finite-sample subspace invariance leakage `η` via
+ `koopman_graph.identification.subspace_invariance_report` (off the
+ root façade) and opt-in `evaluate(..., include_invariance=True)` /
+ `GraphKoopmanModel.subspace_invariance_report`. Default evaluate
+ MAE / RMSE / MAPE are unchanged. The ratio is a truncated-SVD
+ projection residual on encoded samples, not a Haseli–Cortés
+ invariance-proximity certificate (`HaseliCortes2023`). Discrete
+ dense per-node `K` only; graph / hetero / continuous / delay /
+ controlled layouts raise. Closed-form `fit` still does not fill
+ `IdentificationReport.invariance`.
+- Residual-aware dictionary selection
+ (`koopman_graph.identification.select_resdmd_gated`, off the root
+ façade) and opt-in `ResDMDFitCallback(mode="gate")`. Lowest train
+ one-step mean squared error (MSE) wins unless the gate first drops
+ candidates whose max finite-dictionary ResDMD residual exceeds
+ `1e-2` (same default as `resdmd`).
+ `IdentificationConfig.gate_resdmd=True` fills
+ `IdentificationReport.spectral` on the final identification `fit`;
+ it does not abort training. Default callback mode remains
+ `"observe"`. This is not a certified infinite-dimensional residual
+ bound.
+- `MpEDMDBaseline` (off the root façade): measure-preserving EDMD via a
+ Gram-weighted Procrustes polar factor of the dictionary map
+ (`Colbrook2023mpEDMD`, doi:10.1137/22M1521407). Same dictionaries and
+ Data-only `predict` surface as `EDMDBaseline`. Unitarity is in the
+ Gram inner product; this does not obsolete Euclidean conditioning on
+ a general directed `K_eff`.
+- `GEDMDBaseline` (off the root façade): generator EDMD from supplied
+ dictionary derivatives (`Klus2020gEDMD`,
+ doi:10.1016/j.physd.2020.132416). `Data.dx_dt` or
+ `fit(..., derivatives=)`; polynomial dictionaries only. `K` stores
+ the generator `L`. Irregular timestamps do not create `L`. Distinct
+ from `identify_sparse_dynamics(..., mode="derivative")`.
+- `HankelDMDBaseline` and `HAVOKBaseline` (off the root façade):
+ teaching delay-row solvers on flattened snapshots
+ (`Arbabi2017HankelDMD`, doi:10.1137/17M1125236;
+ `Brunton2017HAVOK`, doi:10.1038/s41467-017-00030-8). Distinct from
+ `DelayEmbeddingEncoder`. Optional `history=` supplies older delays
+ (oldest → newest); omitted slots are zeros. HAVOK `predict` uses
+ forcing `u=0`. Default `n_delays=2`; HAVOK `havok_rank=3` must fit
+ inside `min(num_windows, n_delays * state_dim)` at fit.
+- `identify_sparse_graph_factors` (off the root façade): STLSQ or
+ teaching group-lasso on frozen `K_self` / `K_nbr`, then an
+ unpenalized refit (`Brunton2016SINDy`; related literature
+ `Pan2021SparseSubspace`, doi:10.1017/jfm.2021.271, not that paper's
+ multi-task EDMD pruning). Distinct from `identify_sparse_dynamics`
+ and `KoopmanSparsityLoss`, which still ship. Dual random-walk and
+ polynomial `P>1` maps are out of scope.
+- `select_latent_rank` (off the root façade): VAMP-2, ResDMD elbow, or
+ stability-penalized held-out MSE over a truncated-SVD candidate grid
+ on frozen encodings (`Wu2020VAMP`; `deeptime2021`,
+ doi:10.1088/2632-2153/ac3de0, optional `[msm]` cross-check). Distinct
+ from Ray Tune / `koopman_graph.tuning` HPO for encoder `latent_dim`.
+ Does not train a model per candidate and does not fill
+ `IdentificationReport.selected_rank` on `fit`.
+- `ExperimentManifest` (off the root façade): frozen
+ `benchmark_manifest_v1` records with dataset SHA-256, ≥3 seeds,
+ track-mandatory controls, and non-empty `deviations` for teaching GNN
+ methods. JSON always; YAML needs PyYAML (`[cli]`). Distinct from
+ LibCity / BasicTS leaderboard hosting. The `[benchmark]` extra is
+ empty.
+- `koopman-graph benchmark run` / `verify` (off the root façade):
+ identity-bound `summary.json` (`benchmark_summary_v1`) with a
+ canonical SHA-256. `run` requires `--data` and checks the dataset
+ payload digest. `verify` fails on a tampered summary hash. This
+ increment does not train models or invent RMSE. The CLI lazy-imports
+ `koopman_graph.benchmark` inside handlers only.
+- Tracked smoke fixtures under `benchmarks/v0.15/` (telemetry-like,
+ multiphysics, topology transfer): hashed UTF-8 stand-ins, FAIR
+ cards, and identity-bound summaries. CI job `benchmark-smoke` runs
+ `koopman-graph benchmark verify` on those stubs. Not METR-LA
+ training and not invented MAE / RMSE.
+- Example 47 (`examples/47_benchmark_manifest.ipynb`): CLI walkthrough
+ of identity-bound `benchmark run` / `verify` on tracked
+ `benchmarks/v0.15/` smoke fixtures. Does not download METR-LA and
+ does not invent MAE / RMSE.
+- Opt-in `GraphDynamicsConfig` / `GraphStateSnapshot` (package `data`,
+ off the root façade). Default `graph_dynamics=None` keeps 0.14 encode
+ / decode / advance. When a config is passed, `topology_head` defaults
+ to `sparse_candidate` (`candidate_k=8`) via
+ `SparseCandidateTopologyHead` (logits on at most `N k` candidate
+ pairs, not dense `N×N`). `dense_mlp` keeps `PredictedTopologyHead`
+ with an `N` ceiling of 64. Distinct from
+ `learn_topology="self_adaptive"` / `AdaptiveAdjacency`. Additive
+ format-1 key `graph_dynamics` (absent ⇒ `None`); `FORMAT_VERSION`
+ stays 1. When a head is attached and `recursive_training` is true,
+ `fit` / `predict` / `evaluate` / UQ use predicted
+ `Â_{t+1}=g_φ(z_t)` as sigmoid weights on the candidate COO unless
+ `future_topologies` or `topology_policy="hold_last"` is set. Evaluate
+ on dynamic sequences injects oracle futures only when that recursive
+ path is off. Presence BCE uses a linear per-node head when masks
+ exist. Topology / presence terms on `TrainingLossBreakdown` default
+ to zero when `graph_dynamics` is unset (`LossWeights` is unchanged).
+ `learn_topology="self_adaptive"` cannot be combined with a non-`none`
+ topology head. `GraphStateSnapshot` is a supervision record, not a
+ replacement for `GraphSnapshotSequence`.
+- `JointStateTopologyObserver` (package `adaptation`, off the root
+ façade): Kalman filter plus group-sparse `K_self` / `K_nbr` on the
+ observed COO, or dense RLS on per-node `K`.
+ `claim_homomorphism=True` raises unless the encoder is
+ `SeparableDictionaryEncoder` (`encoder_kind="separable"`) and the
+ operator is a one-tap graph Koopman operator (Peng, Shen & Zhu,
+ arXiv:2606.17797, `Peng2026KoopmanGKFA`). Default GNN encoders mix
+ neighbors and are not separable. The observer does not infer a new
+ edge set, does not run ADMM on `A`, and does not certify a three-term
+ MSE bound. Dual random-walk and `filter_degree != 1` graph maps raise. Class-only
+ encoder (no factory `encoder="separable"`). No checkpoint key;
+ `FORMAT_VERSION` stays 1. Importing `RecursiveKoopmanAdapter` /
+ `KoopmanObserver` does not load `identification`.
+- `EntityRemap` (package `data`, off the root façade): injective
+ placement of source nodes into a caller-chosen finite `N_max`.
+ `entity_ids` names union rows; `index` maps source rows onto that
+ union. `apply_snapshot` remaps `x` and `edge_index`.
+ `GraphSnapshotSequence` still refuses a changing node count;
+ the error names `EntityRemap` and unbounded growth. This is not
+ automatic entity resolution. Homogeneous `Data` only. No checkpoint
+ key; `FORMAT_VERSION` stays 1.
+- `estimate_graphon` / `GraphonEstimate` (package `operators`, off the
+ root façade): dense teaching fit of constant, product, or low-rank
+ kernels on aligned homogeneous graphs with a shared `N` (ceiling
+ 256). Constant recovery is the mean off-diagonal edge probability
+ (dimensionless). Product recovery uses degree scores
+ `û_i = 2 d_i / (N-1)`. Low-rank is a truncated SVD sketch without an
+ oracle-quality claim. Sparse-graph graphon theory is refused.
+ Recovering a teaching kernel is not a transferability certificate on
+ arbitrary sparse sensor graphs (`Ruiz2023Transferability`). Optional
+ `positions=` on `sample_graphon_adjacency` holds latents for the
+ product oracle. No checkpoint key; `FORMAT_VERSION` stays 1.
+- Example 50 (`examples/50_graph_state_closure.ipynb`): opt-in
+ predicted topology versus hold-last on a seeded six-node directed
+ $G(n,p)$ event.
+ A dense stub that scores the post-event graph beats hold-last when
+ the target is the model's own oracle one-step forecast. That is a
+ wiring check, not a learned-forecast claim. Dense logits permute
+ with node order; changing `N` still needs `EntityRemap`.
+- Homogeneous `parameter_trajectory` and frozen `ConditioningContext`
+ (package `data`, off the root façade): per-snapshot
+ `(μ, t, u)` records with caller-defined regime coordinates
+ (dimensionless if unspecified) and timestamps in the caller time
+ unit. `koopman="switched"` / `"mixture"` remain latent-gated or
+ piecewise LTI maps, not `K(μ)` (`Macesic2018Nonautonomous`).
+ Carrying parameters does not change the default `koopman=None`
+ operator. `FORMAT_VERSION` stays 1.
+- Discrete `koopman="parametric"` interpolant `K(μ)=Σ_j α_j(μ) K_j`
+ with RBF or simplex weights (`ParametricKoopmanOperator`, off the
+ root façade). Distinct from latent-gated switched / mixture. Convex
+ combinations preserve dense / row-stochastic / doubly-stochastic
+ factors; symplectic / ODO / Schur / dissipative / Lyapunov /
+ auxiliary-spectral mixes raise. Export refuses the interpolant.
+ Additive checkpoint type string; `FORMAT_VERSION` stays 1.
+ `leave_one_regime_out` compares the interpolant to pooled LTI on
+ latent pairs.
+- Time-of-day recipes `diurnal_control_features` and
+ `diurnal_phase_index` (package `data`, off the root façade): Fourier
+ sine/cosine columns for existing additive / bilinear control, and
+ integer phase bins for a per-step switched `phase_index` that does
+ not mutate `mode_index`. Not a calendar serializer and not a
+ checkpoint key. Discrete `fit` / `predict_at` still reject
+ non-uniform `Δt`; use `dynamics_mode="continuous"` or supply gEDMD
+ derivatives (`Klus2020gEDMD`). Irregular timestamps do not create
+ `L`.
+- Opt-in `DriftDiffusionKoopman` (package `operators`, off the root
+ façade): Euler–Maruyama or Yosida step for `dz = Lz dt + F dW`.
+ `forward` is the conditional-expectation semigroup; `advance`
+ samples a path. Not a factory kind.
+ `dynamics_mode="stochastic"` stays diagonal process noise after
+ discrete `K`. Not certified SDE theory and not SDMD
+ (`Xu2025StochasticSemigroup`, `Zhou2025Yosida`). No checkpoint key;
+ `FORMAT_VERSION` stays 1.
+- `JointCoverageSpec` (package `uq`, off the root façade) names the
+ conformal estimand. Default `target="per_node_marginal"`.
+ Simultaneous / event targets and `block!="none"` raise.
+ `ConformalKoopmanUQ.coverage` always names the target. Proper
+ scores `gaussian_crps`, `gaussian_nll`, and `energy_score` evaluate
+ forecasts and do not certify coverage (`Schlembach2025Conformal`).
+- `CochainState` and `CochainKoopmanOperator` (package `operators`,
+ off the root façade): degree-specific `k<=1` maps on a static
+ signed `B_1`. Not a factory kind; default `koopman=None` stays
+ `"pernode"`. Distinct from `koopman="hodge"`. Face latents are
+ stored, not evolved. `boundary_nilpotency` flags `B_1 B_2 ≈ 0` at
+ atol `1e-6` (`Lim2020Hodge`, `TopoX2024`). Feature-axis
+ intertwining is a loss. No checkpoint key; `FORMAT_VERSION` stays 1.
+- `markov_closure_report` and `FiniteMemoryKoopman` (package
+ `analysis`, off the root façade): Ljung–Box-style residual-energy
+ whiteness (lags in timesteps) and a convolution
+ `z[t+1] = z[t] @ Omega.T + sum_s z[t-s] @ K_s.T`. Not a factory
+ kind, not delay embedding, and not HAVOK (`Brunton2017HAVOK`). Not
+ Mori–Zwanzig identification (`Lin2021MoriZwanzig`, `Ljung1978Box`).
+ Memory-order selection is not implemented. No checkpoint key;
+ `FORMAT_VERSION` stays 1.
+- `hodge_decompose_modes` and `HodgeModeComponents` (package
+ `analysis`, off the root façade): combinatorial Hodge split of
+ stored eigenvector columns into gradient / curl / harmonic parts
+ on a static signed `B_1` (`k` in `{0, 1}`). Analysis-only — not a
+ factory kind, not physical circulation, not `koopman="hodge"`, and
+ not TopologicX / sheaf parity (`Lim2020Hodge`, `TopoX2024`). Face
+ curl is not shipped. `FORMAT_VERSION` stays 1.
+- `order2_cochain_teaching` and `bind_cochain_operator` (package `nn`,
+ off the root façade): order-2 filled triangle bound to
+ `CochainKoopmanOperator`. `k=2` stored, not evolved. Cell-complex
+ teaching ceiling remains `MAX_CELL_COMPLEX_DEGREE` (3). Sheaf
+ restriction maps stay learned-optional (default diagonal). Not
+ TopologicX / TDA ecosystem parity (`TopoX2024`).
+ `FORMAT_VERSION` stays 1.
+- `EquivariantKoopmanOperator` adds one `l=2` irrep (`n_tensors`;
+ `scale * I_5`) beside the existing `scale * I_3` vector blocks.
+ Not a factory kind. Default `E3EquivariantEncoder` still projects
+ to invariant scalars. The operator leaf does not import `e3nn`;
+ the rotation test uses `[equivariance]`. Not a molecular MD
+ production stack (`Thomas2018TFN`, `Geiger2022e3nn`).
+ `FORMAT_VERSION` stays 1.
+- `MassConservingDecoder`, `PositivityDecoder`, and
+ `LinearConservingDecoder` (package `nn`, off the root façade):
+ opt-in decoded-space softmax / affine mass, positivity, and
+ min-norm `C x = c_0` heads on named channels. Not a factory
+ kind and not a checkpoint type string. Latent symplectic `K`
+ alone does not conserve decoded mass (`Greydanus2019HNN`).
+ IEEE-118 `generate` / `generate_typed` remain Laplacian
+ diffusion plus a load ramp — not AC power flow.
+ `FORMAT_VERSION` stays 1.
+- `LinearOperatorProtocol` with
+ `PolynomialGraphLinearOperator` and
+ `MatrixFreeGraphLinearOperator` (package `operators`, off the
+ root façade): common `matvec` / `rmatvec` / `solve` /
+ `expm_action` / leading eigpairs / residual norms wrapping a
+ degree-`P>=2` graph polynomial and the existing one-tap
+ `matrix_free` path. Dense assembly is refused when `N·d`
+ exceeds `MAX_DENSE_LINEAR_OPERATOR_SIZE` (4096). Trainer DDP
+ does not shrink that representation. Kronecker exact spectrum
+ remains a special case. Not a factory kind.
+ `FORMAT_VERSION` stays 1.
+- `TubeKoopmanMPC` and `TubeMPCReport` (package `mpc`, off the
+ root façade): residual-tube tightening from conformal quantiles
+ or ensemble residual radii on additive discrete plants.
+ Closed-loop `evaluate` reports constraint-violation rate,
+ feasibility rate, and quadratic stage cost against the nominal
+ output boxes — not tracking MSE alone. Local decoder
+ linearization is unchanged. Not a chance-constraint solver,
+ not a recursive-feasibility or Lyapunov closed-loop
+ certificate, and not Zhang et al. r-KMPC with an offline
+ ancillary law (`Zhang2022TubeMPC`). Bilinear / networked /
+ continuous plants raise. Not a factory kind.
+ `FORMAT_VERSION` stays 1.
+- Labeled synthetic SCM interventions (package `analysis`, off the
+ root façade): `teaching_three_node_scm` and
+ `recover_synthetic_interventional_edges` recover a known
+ do-edge `0 → 1` on a three-node linear Gaussian fixture.
+ `granger_latent_influence` remains **non-interventional**.
+ Not a factory kind and not field-data causal discovery.
+ `FORMAT_VERSION` stays 1.
+
+### Changed
+
+- Example 22 discussion, gallery caption, and `limitations.rst` cite the
+ saved GraphKoopman-leading aggregate RMSE (0.6551 vs 0.7076 / 1.0754 /
+ 0.9036 for STGCN / DCRNN / Graph WaveNet) with unequal-budget and
+ teaching-baseline caveats. Examples 37/38 keep negative transfer and
+ the one-tap versus joint-LS gap as allowed outcomes, not SOTA.
+- `limitations.rst` re-homes the post-0.14 honesty ceilings on one
+ page: one-hop default versus polynomial opt-in; Nyquist and
+ non-normal diagnostics; node versus cochain; uniform discrete
+ `Δt`; identification versus Adam; graph-state fixed-union;
+ mpEDMD versus `SpectralDiagnostics`; decoded constraint heads;
+ matrix-free eligibility; criticality as a non-certificate;
+ example 22 ranking; and the example-38 joint-LS gap not
+ attributed to hop order. Neighbor-factor isotypic tying remains
+ shipped. Negative transfer and null ablations stay allowed.
+- CI and release workflows pin `astral-sh/setup-uv` 10.0.1 (v10
+ disables the default cache on `release` events).
+
+### Security
+
+- Locked `aiohttp` 3.14.2 → 3.14.3 (indirect extra;
+ `GHSA-cq5v-8q36-5273`, out-of-bounds heap read on a malformed
+ chunked HTTP response).
+
+### Fixed
+
+- `markov_closure_report` imports SciPy inside the χ² evaluation so
+ `import koopman_graph` no longer requires the `[dev]` / `[mpc]`
+ extra. That unblocks the CLI-only benchmark-smoke job and the
+ docs extra Sphinx build.
+- `LinearConservingDecoder` projects onto `Cx = c_0` through the
+ Gram system `C C^T` instead of a fat `lstsq`. On Linux CPU GELS
+ the old path could leave an O(1) residual on later rows while
+ the first equation looked exact.
+- Example 48 compares rotation leakage to 1 within `1e-8` rather
+ than requiring a bitwise `1.0`.
+
+### Notes
+
+- Factory defaults are unchanged vs 0.14.0 (`koopman=None` →
+ `"pernode"`; `sparsity="dense"`; AMP off). `FORMAT_VERSION` stays 1.
+- The Zenodo DOI remains `10.5281/zenodo.21926723` until a new version
+ DOI is minted at the 0.15 tag.
+
## [0.14.0] - 2026-08-13
Remaining-limits close-out on the 0.13 stack: opt-in operator families,
@@ -213,7 +618,8 @@ operator contract are unchanged.
(`koopman_symmetry="isotypic"`; `compute_isotypic_decomposition`;
textbook \(K_n\) / \(C_n\) oracles — `Serre1977LinearRepresentations`).
Tutorial `examples/45_isotypic_symmetry.ipynb`. Neighbor-factor
- (\(K_{\mathrm{nbr}}\)) isotypic tying is **not** shipped.
+ (\(K_{\mathrm{nbr}}\)) isotypic tying was not included in 0.11.0; it
+ shipped in 0.14.0.
- Sphinx: rewritten `limitations.rst` / `capabilities.rst`, architecture
v0.11 map, FAQ (Ray Train choice, transfer honesty, teaching vs
leaderboard), API / installation extras, molecular dataset card and
@@ -732,6 +1138,7 @@ claimed wall-time percentages.
- Built-in benchmarks: synthetic diffusion, 2D grid, IEEE 118-bus, and METR-LA traffic loaders
- Sphinx documentation, Jupyter tutorials, pytest suite with CI, and Apache-2.0 packaging for PyPI
+[0.15.0]: https://github.com/tjkessler/KoopmanGraph/compare/0.14.0...0.15.0
[0.14.0]: https://github.com/tjkessler/KoopmanGraph/compare/0.13.0...0.14.0
[0.13.0]: https://github.com/tjkessler/KoopmanGraph/compare/0.12.0...0.13.0
[0.12.0]: https://github.com/tjkessler/KoopmanGraph/compare/0.11.0...0.12.0
diff --git a/CITATION.cff b/CITATION.cff
index 5ae52d7..90304ad 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -13,8 +13,8 @@ authors:
repository-code: "https://github.com/tjkessler/KoopmanGraph"
url: "https://koopmangraph.readthedocs.io/"
license: Apache-2.0
-version: 0.14.0
-date-released: 2026-08-13
+version: 0.15.0
+date-released: 2026-08-19
doi: 10.5281/zenodo.21926723
identifiers:
- type: doi
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b34b74b..5af3491 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -245,8 +245,8 @@ Mark the aggregator check from [`ci.yml`](.github/workflows/ci.yml) as
**required** before merge:
- `ci` — succeeds when path-selected jobs (`lint`, `test`, `test-macos`,
- `notebooks`, `docs`) succeed or are intentionally skipped; fails if any
- selected job fails
+ `notebooks`, `docs`, `benchmark-smoke`) succeed or are intentionally
+ skipped; fails if any selected job fails
Individual jobs still appear on the workflow run for diagnosis:
@@ -260,10 +260,15 @@ Individual jobs still appear on the workflow run for diagnosis:
`-m "not slow and not distributed and not ray"` (hard-fail; no Ray/Dask
extras). Shares the same path filter as Ubuntu `test` (not the notebooks
filter). Windows is not in CI (best-effort community).
-- `notebooks (smoke)` on pull requests — 10 representative tutorials; on pushes
- to `main`, `notebooks (01-13)` / `notebooks (14-26)` / `notebooks (27-46)`
+- `notebooks (smoke)` on pull requests — representative tutorials; on pushes
+ to `main`, `notebooks (01-13)` / `notebooks (14-26)` / `notebooks (27-54)`
run the full suite on **Ubuntu only** (macOS does not run nbmake)
- `docs` — Sphinx documentation build (`sphinx-build -W`, warnings as errors)
+- `benchmark-smoke` — `koopman-graph benchmark verify` on the three
+ hashed stand-ins under `benchmarks/v0.15/` (YAML needs `[cli]`).
+ Identity-bound only: not METR-LA training and not invented MAE / RMSE.
+ Path filter: `benchmarks/`, `src/koopman_graph/benchmark/`,
+ `src/koopman_graph/cli/`, `tests/benchmark/`.
The **Draft paper** workflow ([`draft-pdf.yml`](.github/workflows/draft-pdf.yml))
compiles `paper.md` on changes; it is informational and need not block merges.
@@ -308,16 +313,12 @@ document them in [CHANGELOG.md](CHANGELOG.md) and in GitHub Release notes.
### Version source of truth
-Bump the version in a single place:
-
-```python
-# src/koopman_graph/__init__.py
-__version__ = "0.6.0"
-```
-
-`pyproject.toml` reads this value dynamically at build time via
-`[tool.setuptools.dynamic]`. Do not add a separate static `version` field to
-`pyproject.toml`.
+The package version is the `__version__` assignment in
+`src/koopman_graph/__init__.py`. `pyproject.toml` reads that string
+dynamically at build time via `[tool.setuptools.dynamic]`. Do not add a
+separate static `version` field to `pyproject.toml`, and do not copy the
+current version number into this guide (it would go stale). Bump that
+assignment when cutting a release (see the checklist below).
### Checkpoint format (current baseline)
@@ -338,7 +339,7 @@ Future incompatible schema changes must bump ``FORMAT_VERSION``, extend
are therefore outside the checkpoint path.
Do not confuse checkpoint ``format_version`` with the package
-``__version__`` (for example ``0.6.0``).
+``__version__`` (read the latter from `src/koopman_graph/__init__.py`).
### Maintainer release checklist
diff --git a/README.md b/README.md
index f3720be..32a9aa2 100644
--- a/README.md
+++ b/README.md
@@ -46,9 +46,11 @@ The library sits in the consistent Koopman autoencoder lineage and is **not clai
- **Topology-aware learning** — GCN/GAT/hypergraph encoders and decoders, delay embeddings, dynamic topology, optional self-adaptive edges, sheaf / cell / simplicial lifts, and a predicted-topology head (distinct from static AdaptiveAdjacency)
- **Flexible dynamics** — discrete, continuous-time, networked (`koopman="graph"`), hypergraph, multiplex hetero, global/local, Hodge-structured, switched, and mixture operators, with soft, structural, stochastic, or symplectic parameterizations
- **Forecasting stack** — multi-step rollout, consistency losses, temporal evaluation metrics, checkpointing, and restricted `torch.export` / TorchScript (fixed-topology homogeneous MVP)
-- **Spectral analysis** — eigendecomposition, mode shapes, finite ResDMD on `evaluate`, Kronecker dispersion, dynamical similarity, anomaly helpers, and optional 0-d TDA extras
-- **Control and adaptation** — additive/bilinear control, iterated-QP Koopman-MPC (`[mpc]`), online RLS adaptation, Kalman observation, and a Gymnasium RL wrapper
-- **Research tooling** — classical DMD-family baselines, teaching GNN ports plus LibCity/BasicTS leaderboard adapters, GraphVAMP / alanine-dipeptide teaching fetch, conformal UQ, and a $K^2$ VAE MVP
+- **Spectral analysis** — eigendecomposition, mode shapes, finite ResDMD on `evaluate`, optional `SpectralDiagnostics` (Nyquist $1/(2\Delta t)$ in cycles per unit time, $\kappa(V)$, aliasing flags), Kronecker dispersion, dynamical similarity, anomaly helpers, and optional 0-d TDA extras
+- **Identification (opt-in)** — closed-form ridge / TLS / constrained least squares for discrete dense per-node $K$; default `fit` remains Adam; residual-aware dictionary gating and a frozen-encoding rank grid (not Ray Tune for `latent_dim`)
+- **Identity-bound benchmarks** — frozen manifests and `koopman-graph benchmark run` / `verify` on hashed smoke fixtures; the runner does not train a model or invent MAE / RMSE, and it is not a LibCity / BasicTS host
+- **Control and adaptation** — additive/bilinear control, iterated-QP Koopman-MPC (`[mpc]`), residual-tube tightening, online RLS adaptation, Kalman observation, and a Gymnasium RL wrapper
+- **Research tooling** — classical DMD-family baselines, teaching GNN ports plus LibCity/BasicTS protocol adapters, GraphVAMP / alanine-dipeptide teaching fetch, conformal UQ, and a $K^2$ VAE MVP
- **Optional distributed trainers** — native DDP / `torchrun`, Lightning Fabric, Ray ensemble helpers, opt-in multi-node Ray recipe (`KOOPMAN_GRAPH_MULTINODE=1`), and in-tree FedAvg (`[federated]`)
Full inventory: [Capabilities](https://koopmangraph.readthedocs.io/en/latest/capabilities.html) · [Architecture](https://koopmangraph.readthedocs.io/en/latest/architecture.html)
@@ -136,9 +138,9 @@ More detail: [Quickstart guide](https://koopmangraph.readthedocs.io/en/latest/qu
-METR-LA aggregate RMSE vs in-repo STGCN / DCRNN / Graph WaveNet teaching baselines (not dedicated-library SOTA) from examples/22_gnn_forecaster_comparison.ipynb .
+METR-LA aggregate RMSE: GraphKoopman leads (0.6551 vs 0.7076 / 1.0754 / 0.9036) against in-repo STGCN / DCRNN / Graph WaveNet teaching baselines (unequal budgets; not dedicated-library SOTA) from examples/22_gnn_forecaster_comparison.ipynb .
-Featured tutorials: [01 synthetic](https://github.com/tjkessler/KoopmanGraph/blob/main/examples/01_synthetic_graph.ipynb) · [03 traffic](https://github.com/tjkessler/KoopmanGraph/blob/main/examples/03_traffic_network.ipynb) · [06 epidemic](https://github.com/tjkessler/KoopmanGraph/blob/main/examples/06_epidemic_ring.ipynb) · [22 GNN baselines](https://github.com/tjkessler/KoopmanGraph/blob/main/examples/22_gnn_forecaster_comparison.ipynb) · [37 topology transfer](https://github.com/tjkessler/KoopmanGraph/blob/main/examples/37_cross_topology_transfer.ipynb) · [39 hetero RelGraph](https://github.com/tjkessler/KoopmanGraph/blob/main/examples/39_heterogeneous_relational_koopman.ipynb) · [42 teaching baselines](https://github.com/tjkessler/KoopmanGraph/blob/main/examples/42_traffic_teaching_baselines.ipynb) · [full gallery](https://koopmangraph.readthedocs.io/en/latest/tutorials.html)
+Featured tutorials: [01 synthetic](https://github.com/tjkessler/KoopmanGraph/blob/main/examples/01_synthetic_graph.ipynb) · [03 traffic](https://github.com/tjkessler/KoopmanGraph/blob/main/examples/03_traffic_network.ipynb) · [06 epidemic](https://github.com/tjkessler/KoopmanGraph/blob/main/examples/06_epidemic_ring.ipynb) · [22 GNN baselines](https://github.com/tjkessler/KoopmanGraph/blob/main/examples/22_gnn_forecaster_comparison.ipynb) · [37 topology transfer](https://github.com/tjkessler/KoopmanGraph/blob/main/examples/37_cross_topology_transfer.ipynb) · [39 hetero RelGraph](https://github.com/tjkessler/KoopmanGraph/blob/main/examples/39_heterogeneous_relational_koopman.ipynb) · [42 teaching baselines](https://github.com/tjkessler/KoopmanGraph/blob/main/examples/42_traffic_teaching_baselines.ipynb) · [47 benchmark manifests](https://github.com/tjkessler/KoopmanGraph/blob/main/examples/47_benchmark_manifest.ipynb) · [48 identification](https://github.com/tjkessler/KoopmanGraph/blob/main/examples/48_identification_invariance.ipynb) · [full gallery](https://koopmangraph.readthedocs.io/en/latest/tutorials.html)
## Learn more
@@ -146,18 +148,20 @@ Featured tutorials: [01 synthetic](https://github.com/tjkessler/KoopmanGraph/blo
- [Capabilities](https://koopmangraph.readthedocs.io/en/latest/capabilities.html) — feature inventory and datasets
- [Scope and limitations](https://koopmangraph.readthedocs.io/en/latest/limitations.html) — when not to use; measured boundaries
- [Architecture](https://koopmangraph.readthedocs.io/en/latest/architecture.html) — public vs power-user API layers
+- [Benchmarks](https://koopmangraph.readthedocs.io/en/latest/benchmarks.html) — identity-bound manifests (not trained scores)
+- [Identification](https://koopmangraph.readthedocs.io/en/latest/identification.html) — opt-in closed-form $K$ versus Adam
- [FAQ / troubleshooting](https://koopmangraph.readthedocs.io/en/latest/faq.html) — install, imports, checkpoints
- [Installation](https://koopmangraph.readthedocs.io/en/latest/installation.html) — dependencies, install paths, and CI platforms
-- [CLI](https://koopmangraph.readthedocs.io/en/latest/cli.html) — `koopman-graph train` / `predict` config workflow
+- [CLI](https://koopmangraph.readthedocs.io/en/latest/cli.html) — `koopman-graph train` / `predict` / `benchmark` config workflow
- [SECURITY.md](SECURITY.md) — supported versions and checkpoint trust boundaries
-- What’s new in 0.14.0: opt-in stochastic / symplectic $K$, switched /
- mixture / Hodge operators, equivariant block $K$, leaderboard adapters,
- restricted `torch.export` / TorchScript, wired finite ResDMD, and TDA /
- federated extras — defaults unchanged vs 0.13.0; see [CHANGELOG.md](CHANGELOG.md).
+- What’s new in 0.15.0: opt-in closed-form identification, identity-bound
+ benchmark manifests, polynomial graph filters, Nyquist / conditioning
+ diagnostics, and graph-state / cochain / matrix-free MVPs — defaults
+ unchanged vs 0.14.0; see [CHANGELOG.md](CHANGELOG.md).
## Related software
-- [PyKoopman](https://pykoopman.readthedocs.io/) and [DLKoopman](https://github.com/GaloisInc/dlkoopman) target vector-valued Koopman / deep-Koopman workflows; they treat the state as a flat vector rather than propagating information along graph edges.
+- [PyKoopman](https://pykoopman.readthedocs.io/), [PyDMD](https://pydmd.github.io/PyDMD/), [kooplearn](https://kooplearn.readthedocs.io/), and [DLKoopman](https://github.com/GaloisInc/dlkoopman) target vector-valued Koopman / DMD / operator-learning workflows; they treat the state as a flat vector rather than propagating information along graph edges.
- [PyTorch Geometric](https://pytorch-geometric.readthedocs.io/) provides mature GNN infrastructure on irregular graphs; KoopmanGraph adds an explicit linear latent operator, consistency losses, and a documented `fit` / `predict` forecasting stack on that substrate.
- Spatiotemporal GNN forecasters such as STGCN, DCRNN, and Graph WaveNet typically learn nonlinear convolutional or recurrent maps on graphs; KoopmanGraph instead advances an inspectable linear Koopman matrix **K** (see in-repo teaching baselines in [examples/22](https://github.com/tjkessler/KoopmanGraph/blob/main/examples/22_gnn_forecaster_comparison.ipynb)).
@@ -178,7 +182,7 @@ If you use KoopmanGraph in research, please cite:
publisher = {Zenodo},
doi = {10.5281/zenodo.21926723},
url = {https://github.com/tjkessler/KoopmanGraph},
- version = {0.14.0},
+ version = {0.15.0},
}
```
diff --git a/SECURITY.md b/SECURITY.md
index 9f37729..526384c 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -7,7 +7,7 @@ Security updates are provided for the **latest published release** of
| Version lineage | Security updates |
| --------------- | ---------------- |
-| Latest PyPI release (currently the 0.14.x line) | Supported |
+| Latest PyPI release (currently the 0.15.x line) | Supported |
| Older published majors/minors | Not regularly backported |
Check the installed version with:
diff --git a/benchmarks/v0.15/README.md b/benchmarks/v0.15/README.md
new file mode 100644
index 0000000..dfe45e7
--- /dev/null
+++ b/benchmarks/v0.15/README.md
@@ -0,0 +1,44 @@
+# v0.15 benchmark smoke fixtures
+
+Tracked YAML manifests, hashed UTF-8 stand-ins, FAIR cards, and
+identity-bound `summary.json` files for the three evidence tracks
+(telemetry-like, multiphysics, topology transfer).
+
+These files are **not** METR-LA / PEMS HDF5, not LibCity / BasicTS
+leaderboards, and not trained forecast metrics. `koopman-graph benchmark
+run` / `verify` check dataset SHA-256 and a canonical summary digest
+(`executed=False`).
+
+## Layout
+
+| Path | Role |
+|------|------|
+| `smoke_*.yaml` | `benchmark_manifest_v1` (PyYAML / `[cli]`) |
+| `data/smoke_*.txt` | Payload whose SHA-256 is `dataset.sha256` |
+| `cards/smoke_*.md` | FAIR cards referenced by `dataset.card` |
+| `summaries/smoke_*.json` | Checked-in `benchmark_summary_v1` |
+
+Controls: telemetry / multiphysics list `pernode` and/or `joint_ls`;
+topology transfer lists `hold_last`, `pernode`, and `joint_ls`. Methods
+are `graph_koopman` plus named `control` roles (no teaching GNN ports).
+
+## Verify
+
+With `pip install "koopman-graph[cli]"` (PyYAML):
+
+```bash
+koopman-graph benchmark verify \
+ --manifest benchmarks/v0.15/smoke_telemetry.yaml \
+ --against benchmarks/v0.15/summaries/smoke_telemetry.json
+```
+
+Repeat for `smoke_multiphysics` and `smoke_topology_transfer`. `run`
+needs `--data` pointing at the matching `data/smoke_*.txt` file.
+
+## Regenerating summaries
+
+After changing a manifest or payload, recompute `dataset.sha256` from
+the payload bytes and rewrite `summaries/smoke_*.json` with
+`run_manifest` (or `build_summary`) so `summary_sha256` matches.
+`verify` does not require `package_version` to equal the live
+`koopman_graph.__version__`.
diff --git a/benchmarks/v0.15/cards/smoke_multiphysics.md b/benchmarks/v0.15/cards/smoke_multiphysics.md
new file mode 100644
index 0000000..63bd2dd
--- /dev/null
+++ b/benchmarks/v0.15/cards/smoke_multiphysics.md
@@ -0,0 +1,13 @@
+# Smoke multiphysics stand-in
+
+* **Scope:** Protocol-locked networked diffusion stand-in (path-graph
+ Laplacian step) for CI `benchmark verify`
+* **Size:** 148-byte UTF-8 payload (not a PDE field)
+* **Format:** Tracked text file `benchmarks/v0.15/data/smoke_multiphysics.txt`
+* **Source:** Generated in-tree for KoopmanGraph smoke CI
+* **License:** Apache-2.0 (same as the package)
+* **Limitations:** This is **not** a measured continuum field and does
+ not execute irregular \(\Delta t\). Default CI hashes protocol
+ identity (`executed=False`); it does not train models or invent
+ MAE / RMSE
+* **Version:** `smoke-multiphysics` / `1`
diff --git a/benchmarks/v0.15/cards/smoke_telemetry.md b/benchmarks/v0.15/cards/smoke_telemetry.md
new file mode 100644
index 0000000..973aaef
--- /dev/null
+++ b/benchmarks/v0.15/cards/smoke_telemetry.md
@@ -0,0 +1,12 @@
+# Smoke telemetry stand-in
+
+* **Scope:** Protocol-locked METR-LA / PEMS-style split (12-step history,
+ 0.7 / 0.1 / 0.2, z-score) for CI `benchmark verify`
+* **Size:** 149-byte UTF-8 payload (not a traffic tensor)
+* **Format:** Tracked text file `benchmarks/v0.15/data/smoke_telemetry.txt`
+* **Source:** Generated in-tree for KoopmanGraph smoke CI
+* **License:** Apache-2.0 (same as the package)
+* **Limitations:** This is **not** METR-LA HDF5, not a LibCity download,
+ and not a forecast result. Default CI hashes protocol identity
+ (`executed=False`); it does not train models or invent MAE / RMSE
+* **Version:** `smoke-telemetry` / `1`
diff --git a/benchmarks/v0.15/cards/smoke_topology_transfer.md b/benchmarks/v0.15/cards/smoke_topology_transfer.md
new file mode 100644
index 0000000..faea408
--- /dev/null
+++ b/benchmarks/v0.15/cards/smoke_topology_transfer.md
@@ -0,0 +1,13 @@
+# Smoke topology-transfer stand-in
+
+* **Scope:** Protocol-locked unseen-\(N\) / rewiring stand-in with
+ mandatory `hold_last`, `pernode`, and `joint_ls` controls
+* **Size:** 177-byte UTF-8 payload (not a multi-graph corpus)
+* **Format:** Tracked text file
+ `benchmarks/v0.15/data/smoke_topology_transfer.txt`
+* **Source:** Generated in-tree for KoopmanGraph smoke CI
+* **License:** Apache-2.0 (same as the package)
+* **Limitations:** This is **not** a transfer experiment result.
+ Negative transfer remains allowed when a later runner executes the
+ protocol. Default CI hashes protocol identity (`executed=False`)
+* **Version:** `smoke-topology-transfer` / `1`
diff --git a/benchmarks/v0.15/data/smoke_multiphysics.txt b/benchmarks/v0.15/data/smoke_multiphysics.txt
new file mode 100644
index 0000000..66f1096
--- /dev/null
+++ b/benchmarks/v0.15/data/smoke_multiphysics.txt
@@ -0,0 +1,3 @@
+KoopmanGraph smoke-multiphysics v1
+protocol: path-graph Laplacian diffusion stand-in
+this file is a hashed stand-in; it is not a measured PDE field
diff --git a/benchmarks/v0.15/data/smoke_telemetry.txt b/benchmarks/v0.15/data/smoke_telemetry.txt
new file mode 100644
index 0000000..ecebbb9
--- /dev/null
+++ b/benchmarks/v0.15/data/smoke_telemetry.txt
@@ -0,0 +1,3 @@
+KoopmanGraph smoke-telemetry v1
+protocol: metr-la-style history_len=12 split=0.7/0.1/0.2 zscore
+this file is a hashed stand-in; it is not metr-la.h5
diff --git a/benchmarks/v0.15/data/smoke_topology_transfer.txt b/benchmarks/v0.15/data/smoke_topology_transfer.txt
new file mode 100644
index 0000000..34065ff
--- /dev/null
+++ b/benchmarks/v0.15/data/smoke_topology_transfer.txt
@@ -0,0 +1,3 @@
+KoopmanGraph smoke-topology-transfer v1
+protocol: unseen-N / rewire stand-in with hold_last, pernode, joint_ls
+this file is a hashed stand-in; negative transfer remains allowed
diff --git a/benchmarks/v0.15/smoke_multiphysics.yaml b/benchmarks/v0.15/smoke_multiphysics.yaml
new file mode 100644
index 0000000..e57afb7
--- /dev/null
+++ b/benchmarks/v0.15/smoke_multiphysics.yaml
@@ -0,0 +1,29 @@
+manifest_id: smoke-multiphysics
+schema_version: benchmark_manifest_v1
+track: multiphysics
+dataset:
+ name: smoke-multiphysics
+ version: "1"
+ sha256: ede4f9e03583be708bdb82d322980d588eff8b49e00ce3f6c8fd6bb6c299d922
+ card: benchmarks/v0.15/cards/smoke_multiphysics.md
+split:
+ train_ratio: 0.7
+ val_ratio: 0.1
+ test_ratio: 0.2
+ history_len: 8
+preprocessing:
+ zscore: false
+ notes:
+ - Path-graph Laplacian diffusion stand-in; not a measured PDE field
+methods:
+ - name: graph_koopman
+ role: koopman
+ - name: joint_ls
+ role: control
+seeds: [0, 1, 2]
+horizons: [1, 4, 8]
+metrics: [mae, rmse]
+compute_budget:
+ max_epochs: 2
+ device: cpu
+controls: [joint_ls]
diff --git a/benchmarks/v0.15/smoke_telemetry.yaml b/benchmarks/v0.15/smoke_telemetry.yaml
new file mode 100644
index 0000000..5b85760
--- /dev/null
+++ b/benchmarks/v0.15/smoke_telemetry.yaml
@@ -0,0 +1,29 @@
+manifest_id: smoke-telemetry
+schema_version: benchmark_manifest_v1
+track: telemetry
+dataset:
+ name: smoke-telemetry
+ version: "1"
+ sha256: bcb2f305aaa35fbb8a5f0b2caff14192a2d66613c5970e214b0127175b1b6aba
+ card: benchmarks/v0.15/cards/smoke_telemetry.md
+split:
+ train_ratio: 0.7
+ val_ratio: 0.1
+ test_ratio: 0.2
+ history_len: 12
+preprocessing:
+ zscore: true
+ notes:
+ - METR-LA/PEMS-style split stand-in; not metr-la.h5
+methods:
+ - name: graph_koopman
+ role: koopman
+ - name: pernode_ls
+ role: control
+seeds: [0, 1, 2]
+horizons: [3, 6, 12]
+metrics: [mae, rmse]
+compute_budget:
+ max_epochs: 2
+ device: cpu
+controls: [pernode]
diff --git a/benchmarks/v0.15/smoke_topology_transfer.yaml b/benchmarks/v0.15/smoke_topology_transfer.yaml
new file mode 100644
index 0000000..80efe91
--- /dev/null
+++ b/benchmarks/v0.15/smoke_topology_transfer.yaml
@@ -0,0 +1,40 @@
+manifest_id: smoke-topology-transfer
+schema_version: benchmark_manifest_v1
+track: topology_transfer
+dataset:
+ name: smoke-topology-transfer
+ version: "1"
+ sha256: 5731a6bd2bc4200468a842c98816e9b2798acdfc312da0a1f57a0d4644e3fe24
+ card: benchmarks/v0.15/cards/smoke_topology_transfer.md
+split:
+ train_ratio: 0.7
+ val_ratio: 0.1
+ test_ratio: 0.2
+ history_len: 4
+preprocessing:
+ zscore: false
+ notes:
+ - Unseen-N / rewire protocol stand-in; negative transfer remains allowed
+methods:
+ - name: graph_koopman
+ role: koopman
+ - name: hold_last
+ role: control
+ - name: pernode_ls
+ role: control
+ - name: joint_ls
+ role: control
+seeds: [0, 1, 2]
+horizons: [1, 2, 4]
+metrics: [mae, rmse]
+ood_shifts:
+ - name: unseen_n
+ kind: unseen_n
+ description: Unseen node-count stand-in; not a measured transfer result.
+ - name: rewire
+ kind: rewire
+ description: Rewiring stand-in; negative transfer remains allowed.
+compute_budget:
+ max_epochs: 2
+ device: cpu
+controls: [hold_last, pernode, joint_ls]
diff --git a/benchmarks/v0.15/summaries/smoke_multiphysics.json b/benchmarks/v0.15/summaries/smoke_multiphysics.json
new file mode 100644
index 0000000..3328e08
--- /dev/null
+++ b/benchmarks/v0.15/summaries/smoke_multiphysics.json
@@ -0,0 +1,37 @@
+{
+ "schema_version": "benchmark_summary_v1",
+ "manifest_id": "smoke-multiphysics",
+ "manifest_sha256": "176b242147902fdf5f4dcfdf8cdedc91c5030319f0b5fb272a570b3bc79bf56e",
+ "dataset_sha256": "ede4f9e03583be708bdb82d322980d588eff8b49e00ce3f6c8fd6bb6c299d922",
+ "track": "multiphysics",
+ "methods": [
+ {
+ "name": "graph_koopman",
+ "role": "koopman"
+ },
+ {
+ "name": "joint_ls",
+ "role": "control"
+ }
+ ],
+ "seeds": [
+ 0,
+ 1,
+ 2
+ ],
+ "horizons": [
+ 1,
+ 4,
+ 8
+ ],
+ "metrics": [
+ "mae",
+ "rmse"
+ ],
+ "controls": [
+ "joint_ls"
+ ],
+ "package_version": "0.14.0",
+ "executed": false,
+ "summary_sha256": "01d05d9d192df7af10cb2a0f1cbb341055d2cdb9fb523df5c81c18745b7da344"
+}
diff --git a/benchmarks/v0.15/summaries/smoke_telemetry.json b/benchmarks/v0.15/summaries/smoke_telemetry.json
new file mode 100644
index 0000000..e03ab18
--- /dev/null
+++ b/benchmarks/v0.15/summaries/smoke_telemetry.json
@@ -0,0 +1,37 @@
+{
+ "schema_version": "benchmark_summary_v1",
+ "manifest_id": "smoke-telemetry",
+ "manifest_sha256": "0b8da52e31d006b1bdef764dc3a4302414e118cd13c0b2695d2dde5a12d03560",
+ "dataset_sha256": "bcb2f305aaa35fbb8a5f0b2caff14192a2d66613c5970e214b0127175b1b6aba",
+ "track": "telemetry",
+ "methods": [
+ {
+ "name": "graph_koopman",
+ "role": "koopman"
+ },
+ {
+ "name": "pernode_ls",
+ "role": "control"
+ }
+ ],
+ "seeds": [
+ 0,
+ 1,
+ 2
+ ],
+ "horizons": [
+ 3,
+ 6,
+ 12
+ ],
+ "metrics": [
+ "mae",
+ "rmse"
+ ],
+ "controls": [
+ "pernode"
+ ],
+ "package_version": "0.14.0",
+ "executed": false,
+ "summary_sha256": "0b6c25dfe9139c84fcf69f3032bc969f47ea55a7dd02b5249480864c3101d31d"
+}
diff --git a/benchmarks/v0.15/summaries/smoke_topology_transfer.json b/benchmarks/v0.15/summaries/smoke_topology_transfer.json
new file mode 100644
index 0000000..4b3a05b
--- /dev/null
+++ b/benchmarks/v0.15/summaries/smoke_topology_transfer.json
@@ -0,0 +1,47 @@
+{
+ "schema_version": "benchmark_summary_v1",
+ "manifest_id": "smoke-topology-transfer",
+ "manifest_sha256": "bf019b7eaa3f455f87076df0ab7780142324a28d065378e1770cb9fb470d7f76",
+ "dataset_sha256": "5731a6bd2bc4200468a842c98816e9b2798acdfc312da0a1f57a0d4644e3fe24",
+ "track": "topology_transfer",
+ "methods": [
+ {
+ "name": "graph_koopman",
+ "role": "koopman"
+ },
+ {
+ "name": "hold_last",
+ "role": "control"
+ },
+ {
+ "name": "pernode_ls",
+ "role": "control"
+ },
+ {
+ "name": "joint_ls",
+ "role": "control"
+ }
+ ],
+ "seeds": [
+ 0,
+ 1,
+ 2
+ ],
+ "horizons": [
+ 1,
+ 2,
+ 4
+ ],
+ "metrics": [
+ "mae",
+ "rmse"
+ ],
+ "controls": [
+ "hold_last",
+ "pernode",
+ "joint_ls"
+ ],
+ "package_version": "0.14.0",
+ "executed": false,
+ "summary_sha256": "27ebe7ba9454e52770524b06af513bbfed2330e989c12cb856341150fda861e8"
+}
diff --git a/docs/Makefile b/docs/Makefile
index ef7d955..68df878 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -1,6 +1,6 @@
# Minimal Makefile for Sphinx documentation.
-SPHINXOPTS ?=
+SPHINXOPTS ?= -W --keep-going
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
@@ -8,7 +8,10 @@ BUILDDIR = build
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
-.PHONY: help Makefile
+html:
+ @$(SPHINXBUILD) -M html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help html Makefile
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/source/api.rst b/docs/source/api.rst
index f89ba83..7222cf0 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -8,7 +8,10 @@ helpers live in capability modules such as :mod:`koopman_graph.metrics`,
:mod:`koopman_graph.adaptation`, and :mod:`koopman_graph.observables`.
For the public vs power-user vs private layering contract, device conventions,
-and design philosophy, see :doc:`architecture`.
+and design philosophy, see :doc:`architecture`. Narrative guides for
+new surfaces: :doc:`benchmarks`, :doc:`identification`,
+:doc:`spectral_diagnostics`, :doc:`graph_dynamics`, :doc:`matrix_free`,
+:doc:`criticality`, and :doc:`time_conditioning`.
Package
-------
@@ -54,7 +57,9 @@ Hankel / delay-coordinate lifting wraps a sized base encoder.
``__all__`` member (also importable from :mod:`koopman_graph.nn`). Prefer
``from koopman_graph import DelayEmbeddingEncoder`` or pass ``n_delays`` to
:class:`~koopman_graph.model.GraphKoopmanModel`. This is Takens-style channel
-stacking, not a full HAVOK / Hankel-DMD solver. Pure snapshot/tensor window
+stacking, not
+:class:`~koopman_graph.baselines.HankelDMDBaseline` or
+:class:`~koopman_graph.baselines.HAVOKBaseline`. Pure snapshot/tensor window
helpers are owned by :mod:`koopman_graph.data.delay_windows` and
thin-re-exported from this module for compatibility.
@@ -146,7 +151,11 @@ Cell-complex peers
importable from :mod:`koopman_graph.nn`. Prefer
``from koopman_graph import CellComplexGNNEncoder, CellComplexGNNDecoder``
or factory ``encoder="cell_complex"`` (``Data.face_index`` required).
-In-repo MVP — **not** a full cellular TDL stack.
+:func:`~koopman_graph.nn.order2_cochain_teaching` binds
+:class:`~koopman_graph.operators.CochainKoopmanOperator` to a filled
+triangle (order-2 teaching; :math:`k=2` not evolved).
+:data:`~koopman_graph.nn.MAX_CELL_COMPLEX_DEGREE` (3) is the
+teaching ceiling — **not** a full cellular TDL stack.
.. automodule:: koopman_graph.nn.cell_complex
:members:
@@ -162,7 +171,8 @@ Tier B :class:`~koopman_graph.nn.E3EquivariantEncoder` (``e3nn``,
``E3EquivariantEncoder``. Default encode still projects to invariant
scalars. Pass ``project_invariants=False`` with
:class:`~koopman_graph.operators.EquivariantKoopmanOperator` for a block
-:math:`K` (vector channels are multiples of :math:`I_3`).
+:math:`K` (vector channels are multiples of :math:`I_3`; optional
+:math:`l=2` channels are multiples of :math:`I_5`).
.. automodule:: koopman_graph.nn.equivariant
:members:
@@ -171,6 +181,18 @@ scalars. Pass ``project_invariants=False`` with
Predicted topology and neural-operator lifts
--------------------------------------------
+Default graph-state scoring is
+:class:`~koopman_graph.nn.SparseCandidateTopologyHead` (at most
+``candidate_k`` destinations per node). Dense
+:class:`~koopman_graph.nn.PredictedTopologyHead` is the ``dense_mlp``
+power-user path with an :math:`N` ceiling. Both are distinct from
+:class:`~koopman_graph.nn.AdaptiveAdjacency`. When a head is attached and
+``recursive_training`` is true, ``fit`` / ``predict`` / ``evaluate`` /
+UQ consume predicted :math:`\hat A_{t+1}` as sigmoid weights unless
+``future_topologies`` or ``topology_policy="hold_last"`` is set.
+:class:`~koopman_graph.nn.PresenceHead` is a linear per-node logit used
+when presence masks exist.
+
.. automodule:: koopman_graph.nn.predicted_topology
:members:
:show-inheritance:
@@ -186,6 +208,22 @@ Decoder
:members:
:show-inheritance:
+Constraint-preserving decoder heads
+-----------------------------------
+
+Opt-in wrappers
+(:class:`~koopman_graph.nn.MassConservingDecoder`,
+:class:`~koopman_graph.nn.PositivityDecoder`,
+:class:`~koopman_graph.nn.LinearConservingDecoder`) project named
+decoded channels onto a simplex, a linear conservation law, or a
+positivity map. They are not a factory kind and have no checkpoint
+type string. Latent symplectic :math:`K` alone does not conserve
+decoded mass.
+
+.. automodule:: koopman_graph.nn.constraint_decoders
+ :members:
+ :show-inheritance:
+
Shared GNN Primitives (power-user)
----------------------------------
@@ -201,6 +239,17 @@ Prefer the public encoder/decoder classes for application code. See
:members:
:show-inheritance:
+Encoder / operator receptive field (power-user)
+-----------------------------------------------
+
+Warn-only hop comparison for discrete ``koopman="graph"``. Prefer
+:meth:`~koopman_graph.model.GraphKoopmanModel.fit`, which calls the
+helper at fit start. Not in root ``__all__``.
+
+.. automodule:: koopman_graph.nn.receptive_field
+ :members:
+ :show-inheritance:
+
Physics-Informed Observables
----------------------------
@@ -220,19 +269,51 @@ Built-in operators live in :mod:`koopman_graph.operators` (``contract``,
``control``, ``discrete``, ``discrete_parameterizations``,
``discrete_propagation``, ``continuous``, ``continuous_van_loan``,
``continuous_parameterizations``, ``continuous_propagation``,
-``auxiliary_spectral``, ``graph``, ``hypergraph``, ``heterogeneous``,
+``auxiliary_spectral``, ``graph``, ``polynomial_graph``, ``linear``, ``hypergraph``, ``heterogeneous``,
``global_local``, ``continuous_graph``, ``switched``, ``mixture``,
-``hodge``, ``equivariant``, ``graphon``). Prefer
+``parametric``,
+``hodge``, ``cochain``, ``equivariant``, ``graphon``, ``stochastic``,
+``stochastic_sde``). Prefer
``from koopman_graph import KoopmanOperator, ContinuousKoopmanOperator,
GraphKoopmanOperator, HypergraphKoopmanOperator, GlobalLocalKoopmanOperator,
ContinuousGraphKoopmanOperator`` (root-stable ``__all__`` members) or
``from koopman_graph.operators import …`` (includes
:class:`~koopman_graph.operators.HeteroGraphKoopmanOperator` for
-``koopman="hetero_graph"``). Former deep imports
+``koopman="hetero_graph"`` and
+:func:`~koopman_graph.operators.estimate_graphon` /
+:class:`~koopman_graph.operators.GraphonEstimate` for dense teaching
+kernels). Former deep imports
``koopman_graph.operator`` / ``koopman_graph.continuous`` were removed in
v0.3.0.
+:class:`~koopman_graph.operators.CochainKoopmanOperator` advances
+node / edge latents on a static signed :math:`B_1` — **not** a factory
+kind, **not** ``koopman="hodge"``, and **not** TopologicX parity.
+:class:`~koopman_graph.operators.EquivariantKoopmanOperator` is a
+block MVP (scalars, ``scale * I_3`` vectors, optional
+:math:`l=2` ``scale * I_5`` tensors) — **not** a factory kind
+and **not** a molecular MD stack.
.. automodule:: koopman_graph.operators
+ :members:
+ :exclude-members: CochainState, CochainKoopmanOperator, BoundaryNilpotencyReport, boundary_nilpotency, DEFAULT_NILPOTENCY_ATOL, LinearOperatorProtocol, PolynomialGraphLinearOperator, MatrixFreeGraphLinearOperator, MemoryEstimate, EigResult, MAX_DENSE_LINEAR_OPERATOR_SIZE
+ :show-inheritance:
+
+.. automodule:: koopman_graph.operators.cochain
+ :members:
+ :show-inheritance:
+
+Matrix-free linear operators
+----------------------------
+
+:class:`~koopman_graph.operators.LinearOperatorProtocol` is the common
+``matvec`` / ``rmatvec`` / ``solve`` / ``expm_action`` / leading-eigpair
+surface. Wrappers cover a monomial graph polynomial and the existing
+one-tap ``matrix_free`` path. Dense assembly is refused above
+:data:`~koopman_graph.operators.MAX_DENSE_LINEAR_OPERATOR_SIZE`.
+Trainer DDP does not shrink that representation. Not a factory kind.
+See :doc:`matrix_free`.
+
+.. automodule:: koopman_graph.operators.linear
:members:
:show-inheritance:
@@ -254,16 +335,23 @@ the root façade. Specialized helpers (``compute_generator_spectrum``,
``estimate_coupling_from_snapshots``, ``CouplingEstimate``,
``attribute_mode_energy``, ``ModeEnergyAttribution``,
``evaluate_topology_transfer``, ``TopologyTransferReport``,
-``explain_representation``, ``RepresentationExplanation``) are imported
+``explain_representation``, ``RepresentationExplanation``,
+``SpectralDiagnostics``, ``compute_spectral_diagnostics``,
+``DefectiveSpectrumError``, ``CONDITION_WARN``,
+``monitor_critical_transition``, ``CriticalityReport``) are imported
from :mod:`koopman_graph.analysis` only. The helpers live in the
-``spectrum`` / ``similarity`` / ``anomaly`` / ``plotting`` / ``residuals`` /
-``sindy`` / ``clustering`` / ``topology_estimation`` / ``transfer`` /
-``explain`` submodules.
+``spectrum`` / ``conditioning`` / ``criticality`` / ``similarity`` / ``anomaly`` /
+``plotting`` / ``residuals`` / ``sindy`` / ``clustering`` /
+``topology_estimation`` / ``transfer`` / ``explain`` submodules.
:func:`~koopman_graph.analysis.graph_dispersion`,
:func:`~koopman_graph.analysis.granger_latent_influence`,
+:func:`~koopman_graph.analysis.recover_synthetic_interventional_edges`,
:func:`~koopman_graph.analysis.persistence_diagram_0d`, and
:func:`~koopman_graph.analysis.discrete_lyapunov_lmi` are additional
package helpers (finite ResDMD may attach to ``evaluate``).
+:func:`~koopman_graph.analysis.granger_latent_influence` is
+**non-interventional**. The synthetic SCM helper recovers a known
+do-edge on a teaching fixture only.
``attribute_mode_energy`` is an interpretive diagnostic on assembled
:math:`K_{\mathrm{eff}}` (not a causal claim; not a ResDMD residual).
:func:`~koopman_graph.analysis.explain_representation` /
@@ -282,6 +370,19 @@ cross-topology transfer (mandatory ``pernode`` control; negative
advantage allowed) — see :doc:`limitations`.
``plot_spectrum`` requires Matplotlib (``pip install matplotlib`` or the
``[dev]`` extra).
+:func:`~koopman_graph.analysis.monitor_critical_transition` is a
+heuristic sliding-window spectral-gap / near-defectivity score — **not**
+a topology-criticality certificate (see :doc:`limitations` and
+:doc:`criticality`). Conditioning and Nyquist fields are documented
+in :doc:`spectral_diagnostics`.
+:func:`~koopman_graph.analysis.markov_closure_report` is a Ljung–Box-style
+residual-energy flag; :class:`~koopman_graph.analysis.FiniteMemoryKoopman`
+is a convolution MVP — **not** Mori–Zwanzig identification, **not**
+HAVOK, and **not** :class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder`.
+:func:`~koopman_graph.analysis.hodge_decompose_modes` is a
+combinatorial Hodge split of stored eigenvector columns — **not**
+physical circulation, **not** ``koopman="hodge"``, and **not**
+TopologicX / sheaf parity.
.. automodule:: koopman_graph.spectrum_types
:members:
@@ -289,7 +390,7 @@ advantage allowed) — see :doc:`limitations`.
.. automodule:: koopman_graph.analysis
:members:
- :exclude-members: KoopmanSpectrum, SpectralResidualReport, spectral_residuals, ResDMDReport, resdmd, ResolventNormGrid, resolvent_norm_grid, TopologyTransferReport, evaluate_topology_transfer, RepresentationExplanation, explain_representation, PersistenceDiagram, persistence_diagram_0d, betti_curve, DispersionRelation, graph_dispersion, CausalInfluenceReport, granger_latent_influence, LyapunovLMIResult, discrete_lyapunov_lmi
+ :exclude-members: KoopmanSpectrum, SpectralDiagnostics, compute_spectral_diagnostics, DefectiveSpectrumError, CONDITION_WARN, CriticalityReport, monitor_critical_transition, SpectralResidualReport, spectral_residuals, ResDMDReport, resdmd, ResolventNormGrid, resolvent_norm_grid, TopologyTransferReport, evaluate_topology_transfer, RepresentationExplanation, explain_representation, PersistenceDiagram, persistence_diagram_0d, betti_curve, DispersionRelation, graph_dispersion, CausalInfluenceReport, granger_latent_influence, SyntheticSCM, SyntheticInterventionReport, recover_synthetic_interventional_edges, sample_synthetic_intervention, sample_synthetic_observational, teaching_three_node_scm, LyapunovLMIResult, discrete_lyapunov_lmi, FiniteMemoryKoopman, MarkovClosureReport, markov_closure_report, HodgeModeComponents, hodge_decompose_modes
:show-inheritance:
.. automodule:: koopman_graph.analysis.residuals
@@ -324,10 +425,92 @@ advantage allowed) — see :doc:`limitations`.
:members:
:show-inheritance:
+Synthetic SCM interventions
+---------------------------
+
+Labeled linear Gaussian teaching SCM and paired do-interventions.
+:func:`~koopman_graph.analysis.granger_latent_influence` remains
+**non-interventional**. Not a factory kind; off root ``__all__``.
+
+.. automodule:: koopman_graph.analysis.causal_intervention
+ :members:
+ :show-inheritance:
+
.. automodule:: koopman_graph.analysis.lmi
:members:
:show-inheritance:
+.. automodule:: koopman_graph.analysis.criticality
+ :members:
+ :show-inheritance:
+
+.. automodule:: koopman_graph.analysis.memory
+ :members:
+ :show-inheritance:
+
+.. automodule:: koopman_graph.analysis.hodge_modes
+ :members:
+ :show-inheritance:
+
+Identification (opt-in solvers)
+-------------------------------
+
+Frozen config / report types, a
+:class:`~koopman_graph.identification.IdentificationBackend` protocol,
+and closed-form :func:`~koopman_graph.identification.identify_operator`
+(ridge, TLS, constrained least squares) live under
+:mod:`koopman_graph.identification` (not on root ``__all__``). Prefer
+``from koopman_graph.identification import IdentificationConfig,
+IdentificationReport, identify_operator``. Pass
+``fit(..., identification=IdentificationConfig(...))`` to bind solvers;
+default ``identification=None`` is the Adam path. Discrete dense
+per-node operators only. Finite-sample subspace leakage
+(:func:`~koopman_graph.identification.subspace_invariance_report`)
+attaches to ``evaluate(..., include_invariance=True)``; it is not a
+Haseli–Cortés certificate.
+:func:`~koopman_graph.identification.select_resdmd_gated` is residual-aware
+dictionary selection on a finite dictionary (not a ResDMD certificate).
+``gate_resdmd=True`` fills ``IdentificationReport.spectral`` on the
+final identification ``fit``; it does not abort training.
+:func:`~koopman_graph.identification.identify_sparse_graph_factors`
+identifies sparse graph factors on frozen encodings (STLSQ or
+teaching group-lasso with unpenalized refit). It is **not**
+:func:`~koopman_graph.analysis.identify_sparse_dynamics` and **not**
+:class:`~koopman_graph.losses.KoopmanSparsityLoss`; those paths still
+ship. Dual random-walk and polynomial :math:`P>1` maps are out of
+scope. See :doc:`architecture`.
+:func:`~koopman_graph.identification.select_latent_rank` selects a
+numerical rank of frozen encodings over a candidate grid (in-tree
+VAMP-2, ResDMD elbow, or stability-penalized held-out MSE). It is
+**not** Ray Tune AutoML for encoder ``latent_dim``.
+See :doc:`identification`.
+
+.. automodule:: koopman_graph.identification
+ :members:
+ :imported-members:
+ :show-inheritance:
+
+Benchmark manifests and identity-bound summaries
+------------------------------------------------
+
+Frozen :class:`~koopman_graph.benchmark.ExperimentManifest` records
+(schema ``benchmark_manifest_v1``) live under
+:mod:`koopman_graph.benchmark` (not on root ``__all__``). Prefer
+``from koopman_graph.benchmark import ExperimentManifest, load_manifest,
+run_manifest, verify_summary``. JSON always; YAML requires PyYAML
+(``pip install "koopman-graph[cli]"``). Teaching GNN methods require
+non-empty ``deviations``. Dataset SHA-256 mismatch is rejected.
+``run_manifest`` writes a canonical ``summary.json``
+(``benchmark_summary_v1``) with ``executed=False``; it does **not**
+train a model or invent forecast metrics. This package is **not** a
+LibCity / BasicTS leaderboard host. The console script is
+``koopman-graph benchmark run`` / ``verify`` (see :doc:`cli`).
+
+.. automodule:: koopman_graph.benchmark
+ :members:
+ :imported-members:
+ :show-inheritance:
+
Long-horizon statistics (power-user)
------------------------------------
@@ -358,6 +541,18 @@ decoder) and supports ``dictionary`` in ``{"polynomial", "rbf", "kernel"}``
(Williams2015 polynomial / RBF EDMD; kernel sections following
Williams2015KernelDMD / Klus2018TransferOperator).
Additional classical peers include
+:class:`~koopman_graph.baselines.MpEDMDBaseline` (measure-preserving EDMD;
+Gram-weighted Procrustes polar factor, ``Colbrook2023mpEDMD`` — not a
+Euclidean :math:`K_{\\mathrm{eff}}` certificate),
+:class:`~koopman_graph.baselines.GEDMDBaseline` (generator EDMD from
+supplied dictionary derivatives, ``Klus2020gEDMD`` — not derivative-mode
+SINDy; irregular timestamps do not create :math:`L`),
+:class:`~koopman_graph.baselines.HankelDMDBaseline` (Hankel-DMD on
+delay-embedded flattened states, ``Arbabi2017HankelDMD`` — not
+:class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder`),
+:class:`~koopman_graph.baselines.HAVOKBaseline` (teaching HAVOK on the
+same delay rows, ``Brunton2017HAVOK``; autonomous ``predict`` uses
+forcing :math:`u=0`),
:class:`~koopman_graph.baselines.FBDMDBaseline`,
:class:`~koopman_graph.baselines.TLSDMDBaseline`,
:class:`~koopman_graph.baselines.OptDMDBaseline`,
@@ -437,17 +632,51 @@ remain on the root façade. Split / sampling helpers
(``TemporalSplit``, ``temporal_split``, ``WindowSampler``) and
``as_multi_trajectory`` are imported from :mod:`koopman_graph.data`
only. The package peers are ``containers`` / ``construction`` /
-``validation`` / ``trajectories`` / ``delay_windows`` / ``sampling`` /
+``validation`` / ``trajectories`` / ``batching`` / ``graph_state`` /
+``conditioning`` / ``calendar`` /
+``delay_windows`` /
+``sampling`` /
``splits`` / ``rollout``; prefer ``from koopman_graph.data import …``.
Array→snapshot builders are power-user imports from
:mod:`koopman_graph.data.construction`. Delay-window stack/flatten helpers
are power-user imports from :mod:`koopman_graph.data.delay_windows` (also
-re-exported by :mod:`koopman_graph.nn.delay`).
+re-exported by :mod:`koopman_graph.nn.delay`). Opt-in multi-graph collate
+(``BatchedGraphTrajectory``, ``collate_graph_trajectories``) is a
+package export used by ``fit(..., batch_graphs=True)``. Opt-in graph-state
+closure uses :class:`~koopman_graph.data.GraphDynamicsConfig` and
+:class:`~koopman_graph.data.GraphStateSnapshot` (not a replacement for
+:class:`~koopman_graph.data.GraphSnapshotSequence`; not on the root façade).
+:class:`~koopman_graph.data.EntityRemap` is the injective union-placement
+hook (finite :math:`N_{\max}`; not entity resolution).
+:class:`~koopman_graph.data.ConditioningContext` records per-snapshot
+:math:`(\\mu, t, u)` from optional homogeneous ``parameter_trajectory``
+(not itself :math:`K(\\mu)`; not on the root façade).
+``koopman="parametric"`` evaluates the interpolant from those
+coordinates. :func:`~koopman_graph.data.diurnal_control_features` and
+:func:`~koopman_graph.data.diurnal_phase_index` are time-of-day
+recipes for existing control / switched ``phase_index``, not a
+calendar serializer.
.. automodule:: koopman_graph.data
:members:
:show-inheritance:
+.. automodule:: koopman_graph.data.batching
+ :members:
+ :show-inheritance:
+
+.. automodule:: koopman_graph.data.graph_state
+ :members:
+ :show-inheritance:
+
+.. automodule:: koopman_graph.data.conditioning
+ :members:
+ :show-inheritance:
+
+.. automodule:: koopman_graph.data.calendar
+ :members:
+ :show-inheritance:
+
.. automodule:: koopman_graph.data.construction
:members:
:show-inheritance:
@@ -518,7 +747,8 @@ Capability peers under :mod:`koopman_graph.training` include
``pair_objectives`` (reconstruction / consistency composition),
``extra_objectives`` (Lie / PDE / sparsity / worst-case),
``objectives`` (``compute_training_loss`` orchestrator plus eigenvalue /
-rollout), ``epochs`` (single-epoch train / eval helpers), ``inputs``
+rollout), ``batched_objectives`` (opt-in collated multi-graph
+reconstruction / forward), ``epochs`` (single-epoch train / eval helpers), ``inputs``
(multi-trajectory resolve), and ``loop`` (``run_fit_loop`` plus
early-stop / scheduler helpers). Prefer
``from koopman_graph.training import …``. The frozen internal
@@ -604,11 +834,16 @@ Online Adaptation
-----------------
``RecursiveKoopmanAdapter``, ``AdaptationStepResult``, ``KoopmanObserver``,
-and ``FilterResult`` are imported from :mod:`koopman_graph.adaptation`
+``FilterResult``, ``JointStateTopologyObserver``, and
+``JointObserverResult`` are imported from :mod:`koopman_graph.adaptation`
only. Shallow peers
``kalman`` (reference filter / RTS) and ``impute``
(``graph_diffuse_impute``) are power-user deep imports under the same
-package.
+package. ``joint_observer`` lazily loads
+:mod:`koopman_graph.identification`.
+:class:`~koopman_graph.nn.SeparableDictionaryEncoder` /
+:class:`~koopman_graph.nn.SeparableDictionaryDecoder` live under
+:mod:`koopman_graph.nn` (not on the root façade).
.. automodule:: koopman_graph.adaptation
:members:
@@ -630,7 +865,14 @@ latent under the linear Koopman map with optional Kalman refinement
(ACI) conformal prediction intervals with the shared
:class:`~koopman_graph.uq.PredictionInterval` type; marginal coverage
``≥ 1 − α`` assumes exchangeability and is approximate under temporal
-dependence (prefer ACI under drift). Nonconformity ``score`` modes are
+dependence (prefer ACI under drift).
+:attr:`~koopman_graph.uq.ConformalKoopmanUQ.coverage` names
+:class:`~koopman_graph.uq.JointCoverageSpec`
+``target="per_node_marginal"``; simultaneous and event targets raise.
+Proper scores :func:`~koopman_graph.uq.gaussian_crps`,
+:func:`~koopman_graph.uq.gaussian_nll`, and
+:func:`~koopman_graph.uq.energy_score` evaluate forecasts and do not
+certify coverage. Nonconformity ``score`` modes are
``"aggregate"``, legacy ``"per_node"`` (max-pool over nodes into a scalar),
and ``"node_wise"`` (per-node marginal half-widths; optional
``neighbor_smoothing``). Calibration persistence uses kind
@@ -685,6 +927,23 @@ bilinear control uses sequential linearization (iterated QP). Optional
``ConformalKoopmanUQ`` to shrink output boxes by per-horizon half-widths.
.. automodule:: koopman_graph.mpc
+ :members:
+ :exclude-members: TubeKoopmanMPC, TubeMPCReport, ensemble_residual_radii
+ :show-inheritance:
+
+Tube Koopman-MPC
+----------------
+
+:class:`~koopman_graph.mpc.TubeKoopmanMPC` erodes nominal output boxes
+by conformal quantiles or ensemble residual radii on **additive
+discrete** plants. :class:`~koopman_graph.mpc.TubeMPCReport` records
+constraint-violation rate, feasibility rate, and quadratic stage cost.
+Local decoder linearization remains. This is not a chance-constraint
+solver and not a recursive-feasibility or Lyapunov closed-loop
+certificate (``Zhang2022TubeMPC``). Not a factory kind; off root
+``__all__``.
+
+.. automodule:: koopman_graph.mpc.tube
:members:
:show-inheritance:
diff --git a/docs/source/architecture.rst b/docs/source/architecture.rst
index d76c062..4946ddb 100644
--- a/docs/source/architecture.rst
+++ b/docs/source/architecture.rst
@@ -105,6 +105,11 @@ GraphVAMP / MD teaching path (synthetic contact-graph oracle; optional
* ``kalman`` — ``FilterResult``, ``reference_kalman_filter``, ``rts_smooth``
* ``impute`` — ``graph_diffuse_impute`` heuristic warm-start
* ``observer`` — ``KoopmanObserver`` façade (filter / smooth / impute)
+* ``joint_observer`` — ``JointStateTopologyObserver`` (Kalman filter plus
+ group-sparse :math:`K_{\mathrm{self}}` / :math:`K_{\mathrm{nbr}}` or
+ dense RLS). Homomorphism claims require
+ ``encoder_kind="separable"``. Lazy package export: importing RLS /
+ ``KoopmanObserver`` does not load ``identification``.
``koopman_graph.graph_utils`` package layout (topology vs propagation peers):
@@ -308,6 +313,10 @@ power-user losses stay package imports outside root ``__all__``.
symbols across ``data/`` peers.
* ``trajectories`` — ``MultiTrajectory``, ``as_multi_trajectory``,
``resolve_sequence``, ``resolve_pair_delta_t``
+* ``batching`` — ``BatchedGraphTrajectory`` and
+ ``collate_graph_trajectories`` (opt-in PyG ``Batch`` of independent
+ homogeneous trajectories; package export, not root ``__all__``).
+ :mod:`koopman_graph.data` must **not** import :mod:`koopman_graph.nn`
* ``delay_windows`` — non-private Hankel-style delay helpers
(``stack_delay_features``, ``flatten_delay_window``,
``history_from_snapshots``, ``apply_observation_mask_to_features``).
@@ -330,6 +339,23 @@ power-user losses stay package imports outside root ``__all__``.
training approximation only — package export, not root ``__all__``)
* ``splits`` — ``TemporalSplit``, ``temporal_split``
* ``rollout`` — ``resolve_rollout_start_indices`` and related type aliases
+* ``remap`` — :class:`~koopman_graph.data.EntityRemap` and
+ :func:`~koopman_graph.data.remap_node_features` (injective placement
+ into a finite :math:`N_{\\max}`; not entity resolution; unbounded
+ growth refused). Off root ``__all__``.
+* ``conditioning`` — :class:`~koopman_graph.data.ConditioningContext` and
+ :func:`~koopman_graph.data.conditioning_at` (optional homogeneous
+ ``parameter_trajectory``). Off root ``__all__``. Not
+ ``koopman="switched"`` / ``"mixture"``. Opt-in
+ ``koopman="parametric"`` evaluates :math:`K(\\mu)` from the stored
+ coordinates. Optional ``period`` / ``num_phases`` fill
+ ``phase_index`` from timestamps.
+* ``calendar`` — :func:`~koopman_graph.data.diurnal_control_features`
+ and :func:`~koopman_graph.data.diurnal_phase_index` (Fourier control
+ columns and integer phase bins). Off root ``__all__``. Recipe for
+ existing additive / bilinear control and switched ``phase_index``,
+ not a calendar serializer. Discrete uniform-:math:`\\Delta t`
+ validation is unchanged. Must not import ``nn``.
* ``sequence_types`` — internal ``SnapshotSequenceLike`` Protocol used by
``construction`` / ``delay_windows`` so those peers stay acyclic with
``containers``. Not package-exported; do not promote to
@@ -378,7 +404,10 @@ power-user losses stay package imports outside root ``__all__``.
``continuous_parameterizations``).
* ``continuous_van_loan`` — cohesive matrix-log / Van Loan factor
construction (``matrix_log``, ``van_loan_factors``,
- ``van_loan_generator_from_discrete``). Continuous-surface owned via
+ ``van_loan_generator_from_discrete``). ``matrix_log`` defaults to
+ ``defective="error"`` (:class:`~koopman_graph.spectrum_types.DefectiveSpectrumError`
+ on singular :math:`V`); ``defective="schur"`` is a SciPy CPU
+ ceiling. Continuous-surface owned via
``continuous`` / package re-exports; do not nest under
``operators/continuous/``.
* ``continuous_parameterizations`` — non-private ODO / Schur / dissipative /
@@ -404,9 +433,20 @@ power-user losses stay package imports outside root ``__all__``.
* ``graph_inverse`` — block-diagonal / Jacobi approximate inverse helpers for
``GraphKoopmanOperator`` / ``HypergraphKoopmanOperator`` with
``sparsity="block_diagonal"`` (no ``operators/graph/`` subtree)
+* ``linear`` —
+ :class:`~koopman_graph.operators.LinearOperatorProtocol` with
+ :class:`~koopman_graph.operators.PolynomialGraphLinearOperator`
+ and :class:`~koopman_graph.operators.MatrixFreeGraphLinearOperator`
+ wrappers (off root ``__all__``). Dense assembly is refused when
+ :math:`N\cdot d` exceeds
+ :data:`~koopman_graph.operators.MAX_DENSE_LINEAR_OPERATOR_SIZE`.
+ Trainer DDP does **not** shrink that representation. Exact
+ Kronecker spectrum remains a special case.
* ``kronecker_spectrum`` — exact Kronecker-sum spectrum helpers for eligible
discrete / continuous graph operators (power-user; not in package
- ``__all__``; auto-routed from ``.spectrum`` — see spectrum routing below)
+ ``__all__``; auto-routed from ``.spectrum`` — see spectrum routing below).
+ Discrete graph uses the polynomial pencil
+ :math:`B(\lambda)=\sum_k\lambda^k K_k`; continuous graph stays one-tap
* ``continuous_graph`` —
:class:`~koopman_graph.operators.continuous_graph.ContinuousGraphKoopmanOperator`
(continuous networked generator ``I⊗L_self + Â⊗L_nbr``; select via
@@ -457,12 +497,44 @@ power-user losses stay package imports outside root ``__all__``.
* ``switched`` / ``mixture`` — piecewise-linear or convex-combination
discrete maps (``koopman="switched"`` / ``"mixture"``). Default
per-node advance remains linear time-invariant; these kinds are opt-in.
+ They are latent-gated or piecewise LTI banks, not a parameter
+ interpolant :math:`K(\\mu)` (``Macesic2018Nonautonomous``). Store
+ :math:`\\mu_t` on
+ :attr:`~koopman_graph.data.GraphSnapshotSequence.parameter_trajectory`.
+ Switched ``advance`` accepts optional ``phase_index`` without
+ writing ``mode_index``.
+* ``parametric`` — discrete interpolant
+ :math:`K(\\mu)=\\sum_j \\alpha_j(\\mu) K_j` with RBF or simplex weights
+ (``koopman="parametric"``). Distinct from switched / mixture. Convex
+ combinations preserve dense / row-stochastic / doubly-stochastic
+ factors; symplectic and other structural mixes raise. Export refuses
+ the interpolant. Additive checkpoint type string;
+ ``FORMAT_VERSION`` remains 1.
* ``hodge`` — Laplacian-structured networked generator
(``koopman="hodge"``).
-* ``equivariant`` — block scalar/vector latent operator under
- ``[equivariance]`` (not the factory default).
-* ``graphon`` — graphon-sampled adjacency helper for transfer experiments
- (power-user).
+* ``cochain`` —
+ :class:`~koopman_graph.operators.CochainState` and
+ :class:`~koopman_graph.operators.CochainKoopmanOperator`
+ (static signed :math:`B_1`; degree-specific :math:`k\\le 1` maps).
+ Not a factory kind; default ``koopman=None`` stays ``"pernode"``.
+ Distinct from ``koopman="hodge"``. Face latents are stored, not
+ evolved. :func:`~koopman_graph.operators.boundary_nilpotency`
+ scores :math:`B_1 B_2\\approx 0` (``Lim2020Hodge``). Not
+ TopologicX parity (``TopoX2024``).
+* ``equivariant`` — block scalar / vector / :math:`l=2` tensor
+ latent operator (not the factory default). Vector blocks are
+ ``scale * I_3``; the additional irrep is ``scale * I_5``.
+ Default encoders may still project to invariants. Not a molecular
+ MD stack (``Thomas2018TFN``, ``Geiger2022e3nn``).
+* ``graphon`` — sampled adjacency and dense teaching estimator
+ (:func:`~koopman_graph.operators.estimate_graphon`) for transfer
+ experiments (power-user). Sparse-graph graphon theory is refused.
+* ``stochastic_sde`` —
+ :class:`~koopman_graph.operators.DriftDiffusionKoopman` (opt-in
+ Euler–Maruyama / Yosida stepper). Not a factory kind.
+ ``dynamics_mode="stochastic"`` remains diagonal process noise after
+ discrete :math:`K`. Not certified SDE theory
+ (``Xu2025StochasticSemigroup``, ``Zhou2025Yosida``).
Prefer ``from koopman_graph.operators import …`` (or the root façade for public
operator classes). Former root modules ``koopman_graph.operator`` and
@@ -484,6 +556,14 @@ PyG-style ``nn`` capability package, no ``conv/`` subtree):
:class:`~koopman_graph.nn.decoder.SAGEDecoder` /
:class:`~koopman_graph.nn.decoder.DiffConvDecoder` /
:class:`~koopman_graph.nn.decoder.GraphTransformerDecoder`
+* ``constraint_decoders`` —
+ :class:`~koopman_graph.nn.MassConservingDecoder` /
+ :class:`~koopman_graph.nn.PositivityDecoder` /
+ :class:`~koopman_graph.nn.LinearConservingDecoder`
+ (opt-in decoded-space simplex / positivity / :math:`Cx=c_0`
+ heads; not a factory kind). Must not import ``model``. Latent
+ symplectic :math:`K` alone does not conserve decoded mass
+ (``Greydanus2019HNN``).
* ``hypergraph`` — :class:`~koopman_graph.nn.hypergraph.HypergraphEncoder` /
:class:`~koopman_graph.nn.hypergraph.HypergraphDecoder`
(PyG ``HypergraphConv``; static ``hyperedge_index`` / ``hyperedge_weight``)
@@ -509,7 +589,25 @@ PyG-style ``nn`` capability package, no ``conv/`` subtree):
(``stack_delay_features``, ``flatten_delay_window``,
``history_from_snapshots``, ``apply_observation_mask_to_features``) are
owned by :mod:`koopman_graph.data.delay_windows` and thin-re-exported
- here for stable ``nn.delay`` import paths.
+ here for stable ``nn.delay`` import paths. Distinct from
+ :class:`~koopman_graph.baselines.HankelDMDBaseline` and
+ :class:`~koopman_graph.baselines.HAVOKBaseline`.
+* ``receptive_field`` — warn-only encoder vs discrete-graph-operator hop
+ check
+ (:func:`~koopman_graph.nn.receptive_field.check_encoder_operator_receptive_field`;
+ not in root ``__all__``). ``GraphKoopmanModel.fit`` emits
+ :class:`~koopman_graph.nn.ReceptiveFieldMismatchWarning` when encoder
+ hops exceed ``filter_degree``. Encoder mixing does not compensate for
+ a one-hop :math:`K`. Operators without ``receptive_field_hops`` are
+ skipped.
+* ``separable`` —
+ :class:`~koopman_graph.nn.SeparableDictionaryEncoder` /
+ :class:`~koopman_graph.nn.SeparableDictionaryDecoder`
+ (node-wise dictionary; ``encoder_kind="separable"``; zero graph hops;
+ class-only — no factory ``encoder="separable"``; off root
+ ``__all__``). Homomorphism precondition
+ (``Peng2026KoopmanGKFA``); neighbor-mixing GNN encoders are not
+ separable.
* ``adaptive_topology`` —
:class:`~koopman_graph.nn.adaptive_topology.AdaptiveAdjacency`
(Graph WaveNet self-adaptive adjacency ``softmax(ReLU(E₁E₂ᵀ))``; power-user
@@ -559,14 +657,49 @@ classes). Former root modules ``koopman_graph.encoder``,
Encoder and decoder remain peers: both import from ``nn.gnn``;
neither imports the other.
-``koopman_graph.analysis`` package layout (spectrum / similarity / anomaly /
-plotting / residuals / topology estimation / SINDy / clustering / explain):
+``koopman_graph.analysis`` package layout (spectrum / conditioning /
+criticality / memory / hodge_modes / similarity / anomaly / plotting / residuals / topology estimation / SINDy /
+clustering / explain):
* ``spectrum`` — re-exports neutral-leaf ``compute_spectrum``,
``compute_generator_spectrum``, and ``discrete_spectrum_at_delta_t``, plus
analysis-owned ``decode_mode_shapes`` and
:func:`~koopman_graph.analysis.implied_timescales`
(:class:`~koopman_graph.analysis.ImpliedTimescales`)
+* ``conditioning`` — re-exports
+ :class:`~koopman_graph.spectrum_types.SpectralDiagnostics` and
+ :func:`~koopman_graph.spectrum_types.compute_spectral_diagnostics`
+ (compute stays on the neutral leaf). Discrete Nyquist
+ :math:`1/(2\Delta t)` is in cycles per unit time (``None`` on
+ generator spectra). ``mode_amplitudes`` warns when
+ :math:`\kappa(V)` exceeds ``CONDITION_WARN`` and raises
+ :class:`~koopman_graph.spectrum_types.DefectiveSpectrumError` when
+ :math:`V` is singular. Not a finite-horizon
+ :math:`\|K^{k}\|` bound; resolvent grids remain in ``pseudospectra``.
+* ``criticality`` —
+ :func:`~koopman_graph.analysis.monitor_critical_transition` and frozen
+ :class:`~koopman_graph.analysis.CriticalityReport` (sliding-window
+ spectral-gap / near-defectivity heuristic on a sequence of spectra;
+ **not** a Ghosh-grade topology-criticality certificate; off root
+ ``__all__``).
+* ``memory`` —
+ :func:`~koopman_graph.analysis.markov_closure_report` and frozen
+ :class:`~koopman_graph.analysis.MarkovClosureReport` (Ljung–Box-style
+ residual-energy whiteness; lags in timesteps) plus
+ :class:`~koopman_graph.analysis.FiniteMemoryKoopman` (convolution
+ :math:`z_{t+1}=z_t\\Omega^{\\top}+\\sum_s z_{t-s}K_s^{\\top}`).
+ Not Mori–Zwanzig identification (``Lin2021MoriZwanzig``), not
+ :class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder`, and not
+ :class:`~koopman_graph.baselines.HAVOKBaseline`
+ (``Brunton2017HAVOK``). Not a factory kind; off root ``__all__``.
+* ``hodge_modes`` —
+ :func:`~koopman_graph.analysis.hodge_decompose_modes` and frozen
+ :class:`~koopman_graph.analysis.HodgeModeComponents` (combinatorial
+ Hodge split of stored eigenvector columns on a static signed
+ :math:`B_1`; :math:`k\\in\\{0,1\\}`). Analysis-only — **not**
+ physical circulation, **not** ``koopman="hodge"``, and **not**
+ TopologicX / sheaf parity (``Lim2020Hodge``, ``TopoX2024``). Off
+ root ``__all__``.
* ``similarity`` — ``spectrum_distance``, ``koopman_std``, ``resolve_spectrum``,
``dynamical_similarity``
* ``anomaly`` — ``AnomalyDetectionResult``, ``calibrate_anomaly_threshold``,
@@ -623,12 +756,16 @@ plotting / residuals / topology estimation / SINDy / clustering / explain):
:mod:`koopman_graph.protocols`, :mod:`koopman_graph.operators`, and
:mod:`koopman_graph.model` never import heavy analysis code for spectrum
assembly. It hosts :class:`~koopman_graph.spectrum_types.KoopmanSpectrum`,
+:class:`~koopman_graph.spectrum_types.SpectralDiagnostics`,
+:class:`~koopman_graph.spectrum_types.DefectiveSpectrumError`,
+``CONDITION_WARN``,
discrete :func:`~koopman_graph.spectrum_types.compute_spectrum`, continuous
:func:`~koopman_graph.spectrum_types.compute_generator_spectrum`, and
:func:`~koopman_graph.spectrum_types.discrete_spectrum_at_delta_t`.
These are re-exported from :mod:`koopman_graph.analysis` (and
``KoopmanSpectrum`` / ``compute_spectrum`` from the package root) for the
-public API.
+public API. ``SpectralDiagnostics``, ``DefectiveSpectrumError``, and
+``CONDITION_WARN`` are **not** on root ``__all__``.
``koopman_graph.baselines`` package layout (peer DMD-family methods behind
:class:`~koopman_graph.baselines.ClassicalBaseline`, plus GNN forecasters):
@@ -644,6 +781,22 @@ public API.
* ``dmd`` — :class:`~koopman_graph.baselines.DMDBaseline`
* ``dmdc`` — :class:`~koopman_graph.baselines.DMDcBaseline`
* ``edmd`` — :class:`~koopman_graph.baselines.EDMDBaseline`
+* ``mpedmd`` — :class:`~koopman_graph.baselines.MpEDMDBaseline`
+ (Gram-weighted Procrustes polar factor; measure-preserving in the
+ dictionary Gram inner product, not a Euclidean
+ :math:`K_{\\mathrm{eff}}` certificate)
+* ``gedmd`` — :class:`~koopman_graph.baselines.GEDMDBaseline`
+ (generator EDMD from supplied dictionary derivatives,
+ ``Klus2020gEDMD``; not derivative-mode SINDy; irregular
+ :math:`\Delta t` does not create :math:`L`)
+* ``hankel_dmd`` — :class:`~koopman_graph.baselines.HankelDMDBaseline`
+ (Hankel-DMD on delay-embedded flattened states,
+ ``Arbabi2017HankelDMD``; not
+ :class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder`)
+* ``havok`` — :class:`~koopman_graph.baselines.HAVOKBaseline`
+ (teaching HAVOK on the same delay rows, ``Brunton2017HAVOK``;
+ delay-plus-forcing, not Mori–Zwanzig, and not
+ :class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder`)
* Classical peers — ``FBDMDBaseline``, ``TLSDMDBaseline``,
``OptDMDBaseline``, ``StreamingDMDBaseline``, ``MRDMDBaseline``,
``UlamTransferOperatorBaseline`` (package ``__all__``; import from
@@ -889,7 +1042,8 @@ Dataset generators remain via :mod:`koopman_graph.datasets` (not root
* :mod:`koopman_graph.training` — ``FitHistory``, ``LossWeights``
(``FitCallback`` / ``NoOpFitCallback`` stay on root ``__all__`` as well)
* :mod:`koopman_graph.adaptation` — ``RecursiveKoopmanAdapter``,
- ``AdaptationStepResult``, ``KoopmanObserver``, ``FilterResult``
+ ``AdaptationStepResult``, ``KoopmanObserver``, ``FilterResult``,
+ ``JointStateTopologyObserver``, ``JointObserverResult``
* :mod:`koopman_graph.env` — ``GraphKoopmanEnv``
* :mod:`koopman_graph.data` — ``TemporalSplit``, ``temporal_split``,
``WindowSampler``, ``as_multi_trajectory``
@@ -949,9 +1103,11 @@ Examples:
Off root ``__all__``. May import ``training`` types; ``training`` must
not import ``tuning``. Importing the package does not import Ray
* :mod:`koopman_graph.cli` — config-driven ``koopman-graph`` console script
- (``train`` / ``predict``; JSON in core, YAML via ``[cli]``). Off root
- ``__all__``. Leaf façade: may import ``model`` / ``serialization`` /
- ``data``; other library packages must **not** import ``cli``
+ (``train`` / ``predict`` / ``benchmark``; JSON in core, YAML via
+ ``[cli]``). Off root ``__all__``. Leaf façade: may import ``model`` /
+ ``serialization`` / ``data`` and lazy-import ``benchmark`` inside
+ ``benchmark`` handlers; other library packages must **not** import
+ ``cli``
* :mod:`koopman_graph.distributed` — optional DDP / Fabric trainer
adapters, ``KoopmanLightningModule`` Trainer sugar,
``run_ray_train_fit_loop``, ``fit_ensemble_with_ray``, rank helpers, and
@@ -1000,12 +1156,15 @@ Examples:
encoder/decoder/delay *classes* remain in ``__all__``. Peers import from
``nn.gnn`` only (no encoder↔decoder inversion).
* :mod:`koopman_graph.spectrum_types` — neutral
- :class:`~koopman_graph.spectrum_types.KoopmanSpectrum` value type plus
+ :class:`~koopman_graph.spectrum_types.KoopmanSpectrum` value type,
+ :class:`~koopman_graph.spectrum_types.SpectralDiagnostics`,
+ :class:`~koopman_graph.spectrum_types.DefectiveSpectrumError`, and
+ ``CONDITION_WARN``, plus
discrete / generator / ``Δt`` spectrum assembly helpers
(``compute_spectrum``, ``compute_generator_spectrum``,
``discrete_spectrum_at_delta_t``; re-exported from
:mod:`koopman_graph.analysis` and, for the type / discrete helper, the
- package root)
+ package root). Diagnostics are off root ``__all__``.
* :class:`~koopman_graph.operators.KoopmanOperatorContract` — shared Protocol for
discrete and continuous operators (``matrix``, ``advance``,
``inverse_advance``, ``bound_metric``); importable from
@@ -1206,9 +1365,14 @@ other consumers reject it:
- Supported via ``allow_dynamic_topology=True`` /
:meth:`~koopman_graph.data.GraphSnapshotSequence.from_dynamic_arrays`
* - Model ``predict`` / inference rollout
- - Supported: hold-last topology (optional ``future_topologies`` schedule)
+ - Supported: hold-last topology (optional ``future_topologies``
+ schedule). When ``graph_dynamics`` attaches a recursive topology
+ head, omitted futures use predicted
+ :math:`\hat A_{t+1}=g_\phi(z_t)` (sigmoid weights on a candidate
+ COO); ``topology_policy="hold_last"`` restores the 0.14 control.
* - Training losses (``rollout_sequence_loss``)
- - Supported: teacher-target topology from observed snapshots
+ - Supported: teacher-target topology from observed snapshots, or
+ predicted topology when recursive graph-state training is on
* - :class:`~koopman_graph.env.GraphKoopmanEnv`
- **Rejected** at construction (episode topology frozen from reset)
* - Classical baselines (DMD / EDMD / DMDc)
@@ -1345,6 +1509,19 @@ spectral analysis uses the explicit helpers ``effective_matrix`` /
- ``"dual_random_walk"``:
:math:`I_N \otimes K_{\mathrm{self}} + \widehat{A}_f \otimes K_{\mathrm{fwd}} + \widehat{A}_b \otimes K_{\mathrm{bwd}}`
(``K_fwd`` aliases ``K_nbr``)
+
+ Default ``koopman_filter_degree`` is ``1`` (the one-tap forms above).
+ Setting :math:`P>1` extends the map by
+ :math:`\sum_{k=2}^{P} \widehat{A}^{k} \otimes K_{k}` (and matching
+ backward powers in dual mode). Extra hop factors (:math:`k\ge 2`) are
+ globally shared. ``block_diagonal`` inverse and ``distributed`` inverse
+ / Arnoldi require :math:`P=1`; dense inverse uses the assembled
+ polynomial. Eligible discrete ``.spectrum`` uses the Kronecker pencil
+ :math:`B(\lambda)=\sum_k\lambda^k K_k` (not a sum of independent factor
+ eigenvalues); dual / orbit paths stay dense.
+ ``GraphKoopmanModel.fit`` warns when encoder hops exceed
+ ``filter_degree``; encoder neighborhood mixing does not compensate for
+ a one-hop :math:`K`.
* Continuous graph — assemble ``L_eff`` with the same ``adjacency`` modes as
discrete ``GraphKoopmanOperator`` (``symmetric`` / ``random_walk`` /
``dual_random_walk``); advance applies ``exp(L_eff Δt)``. Dense realization
@@ -1432,9 +1609,12 @@ auto-route (no path-selection kwarg):
2. Else if Kronecker-sum eligible — shared self factor,
``adjacency`` in ``{"symmetric", "random_walk"}``,
``sparsity`` in ``{"dense", "block_diagonal"}`` — exact reduction via
- :mod:`koopman_graph.operators.kronecker_spectrum`
- (:math:`I\otimes M_{\mathrm{self}} + \widehat{A}\otimes M_{\mathrm{nbr}}`
- for discrete :math:`K` or continuous generator :math:`L`).
+ :mod:`koopman_graph.operators.kronecker_spectrum`. Discrete
+ :math:`K_{\mathrm{eff}}=\sum_k\widehat{A}^k\otimes K_k` uses
+ :math:`B(\lambda_i)=\sum_k\lambda_i^k K_k` (any :math:`P\ge 0`; not a
+ sum of independent factor eigenvalues). Continuous generator
+ :math:`L` stays the one-tap form
+ :math:`I\otimes L_{\mathrm{self}} + \widehat{A}\otimes L_{\mathrm{nbr}}`.
3. Else — dense assembled eigendecomposition
(``dual_random_walk``, discrete orbit / isotypic self banks, helper
fall-back, hetero / hypergraph as applicable).
@@ -1468,14 +1648,19 @@ Checkpoint field ``config.adjacency`` is required for those kinds and is
migrated). Hypergraph operators stay Zhou-symmetric and do not expose
``adjacency``.
+Factory keyword ``koopman_filter_degree`` selects the monomial hop degree
+for discrete ``koopman="graph"`` (default ``1``; rejected for other kinds
+and for continuous graph). Checkpoint field ``config.filter_degree`` is
+additive (absent ⇒ ``1``; format 1).
+
**Symmetry-adapted self and neighbor factors (orbit ties).** Opt-in inductive
bias for discrete ``koopman="graph"`` / ``"hypergraph"`` / multiplex and
shared-d typed ``"hetero_graph"``: nodes in the same orbit share a
``K_self`` factor. Discrete graph / hypergraph / Hodge operators also
allocate a per-orbit neighbor / hyperedge bank
(``K_nbr`` / ``K_fwd`` / ``K_hedge``) applied after the adjacency
-matvec. Dual backward ``K_bwd`` and hetero relation factors ``K_r`` stay
-globally shared. Multiplex hetero orbits use the
+matvec. Dual backward ``K_bwd``, hop factors with :math:`k\ge 2`, and
+hetero relation factors ``K_r`` stay globally shared. Multiplex hetero orbits use the
**union** of relation banks on the stacked ``N`` nodes; typed hetero
orbits are computed independently inside each type block from intra-type
banks (orbits must not mix types). Rectangular hetero orbits are
@@ -1504,7 +1689,11 @@ Dynamics mode and stability-bound vocabulary
``Literal["discrete", "continuous", "stochastic"]`` type, defined next to
:data:`~koopman_graph.operators.Parameterization` in
:mod:`koopman_graph.operators` and re-exported from
-:mod:`koopman_graph.protocols`. ``GraphKoopmanModel`` imports it from
+:mod:`koopman_graph.protocols`. ``"stochastic"`` is discrete linear
+advance plus learned diagonal process noise. The opt-in
+:class:`~koopman_graph.operators.DriftDiffusionKoopman` stepper is a
+separate Euler–Maruyama / Yosida MVP, not that factory string.
+``GraphKoopmanModel`` imports the type from
protocols; :data:`~koopman_graph.adaptation.AdaptationMode` is an alias of
the same type for the RLS adapter API. Do not redefine the literals in call
sites.
@@ -1644,8 +1833,9 @@ package:
``pynauty`` at call time and raise ``ImportError`` with install guidance
when absent. Core imports stay clean without the extra.
* **Call-site import (``[mpc]`` / ``osqp``)** —
- :class:`~koopman_graph.mpc.KoopmanMPC` imports ``osqp`` at solve time.
- Missing extras raise ``ImportError`` with
+ :class:`~koopman_graph.mpc.KoopmanMPC` and
+ :class:`~koopman_graph.mpc.TubeKoopmanMPC` import ``osqp`` at solve
+ time. Missing extras raise ``ImportError`` with
``pip install 'koopman-graph[mpc]'`` guidance. Core imports stay clean
without the extra.
* **Call-site import (e.g. ``h5py`` for METR-LA / PEMS-BAY)** —
@@ -1726,6 +1916,17 @@ Validation input follows the same rules; a multi-trajectory validation
container must match the training trajectory count, while a single validation
sequence is reused for every training trajectory.
+``fit(..., batch_graphs=True)`` collates homogeneous trajectories into one
+PyG ``Batch`` so shared :math:`K_{\mathrm{self}}` / :math:`K_k` apply to
+the disconnected union with per-graph shifts. Reconstruction (and forward
+consistency when weighted) match the mean of the existing per-sequence
+loop on modest batches. The default ``MultiTrajectory`` path is unchanged.
+This is vectorization of a loop that already trained multiple topologies;
+it is not a new multi-graph capability. Hetero, hypergraph, continuous,
+controlled, delay, orbit-tied, learned-topology, windowed, and DDP
+requests raise. Collate lives in :mod:`koopman_graph.data` (off the root
+façade).
+
Device and tensor conventions
-----------------------------
@@ -1827,11 +2028,33 @@ handling is **not** an exact KF in feature space:
Import the observer from :mod:`koopman_graph.adaptation` (power-user; not on
the root façade). See ``examples/25_kalman_koopman_state_estimation.ipynb``.
+:class:`~koopman_graph.adaptation.JointStateTopologyObserver` composes
+that Kalman filter with either
+:func:`~koopman_graph.identification.identify_sparse_graph_factors`
+(one-tap dense ``koopman="graph"``; writes :math:`K_{\mathrm{self}}` /
+:math:`K_{\mathrm{nbr}}` on the **observed** COO) or
+:class:`~koopman_graph.adaptation.RecursiveKoopmanAdapter` (dense
+per-node :math:`K`). Set ``claim_homomorphism=True`` only when the
+encoder is :class:`~koopman_graph.nn.SeparableDictionaryEncoder` and
+the operator is a one-tap graph Koopman operator; GNN encoders and
+dense per-node :math:`K` raise (Peng, Shen & Zhu, arXiv:2606.17797,
+``Peng2026KoopmanGKFA``). The observer does **not** infer a new edge
+set, does not run ADMM on :math:`A`, and does not certify a
+three-term MSE bound. Dual random-walk and :math:`P\neq 1` graph maps
+raise. Wrapper-local: no checkpoint key; ``FORMAT_VERSION`` remains 1.
+Pass the separable encoder/decoder classes into
+:class:`~koopman_graph.model.GraphKoopmanModel` (no factory
+``encoder="separable"``).
+
Classical baselines and datasets
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
DMD-family baselines (:class:`~koopman_graph.baselines.DMDBaseline`,
:class:`~koopman_graph.baselines.EDMDBaseline`,
+:class:`~koopman_graph.baselines.MpEDMDBaseline`,
+:class:`~koopman_graph.baselines.GEDMDBaseline`,
+:class:`~koopman_graph.baselines.HankelDMDBaseline`,
+:class:`~koopman_graph.baselines.HAVOKBaseline`,
:class:`~koopman_graph.baselines.DMDcBaseline`) share
:class:`~koopman_graph.baselines.ClassicalBaseline` for ``time_step`` /
``rank``, fitted metadata, and ``_check_fitted`` scaffolding, plus the
@@ -1845,6 +2068,31 @@ topology copy, and initial-graph checks). They remain siblings under that ABC
``dictionary`` in ``{"polynomial", "rbf", "kernel"}``. Kernel dictionaries
with full-data centers are :math:`O(T^2)` and documented as small/medium-``T``
only; the linear kernel uses the identity feature map (DMD reduction).
+:class:`~koopman_graph.baselines.MpEDMDBaseline` reuses that lift and
+replaces unconstrained least squares with a Gram-weighted Procrustes
+polar factor (``Colbrook2023mpEDMD``). Unitarity is in the dictionary
+Gram inner product; it does not obsolete Euclidean conditioning on a
+general directed :math:`K_{\mathrm{eff}}`.
+:class:`~koopman_graph.baselines.GEDMDBaseline` fits a generator
+:math:`L` by least squares on polynomial dictionary samples and
+supplied :math:`dx/dt` (``Data.dx_dt`` or
+``fit(..., derivatives=)``; ``Klus2020gEDMD``). ``predict`` advances
+by :math:`\exp(L\,\Delta t)` with fitted ``time_step``. Irregular
+timestamps do not create :math:`L`; this is not
+:func:`~koopman_graph.analysis.identify_sparse_dynamics`.
+:class:`~koopman_graph.baselines.HankelDMDBaseline` fits DMD on
+delay-embedded flattened states (``Arbabi2017HankelDMD``).
+:class:`~koopman_graph.baselines.HAVOKBaseline` fits a teaching HAVOK
+map on the same delay rows (``Brunton2017HAVOK``; autonomous
+``predict`` uses forcing :math:`u=0`). Neither is
+:class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder`.
+``predict`` may take optional ``history`` (oldest → newest); without
+it, older delays are zero-padded. The Data-only call
+``predict(data, steps)`` remains valid but is not a faithful delay
+initial condition when ``n_delays > 1``.
+:func:`~koopman_graph.metrics.evaluate_forecast` uses the same
+Data-only ``predict(data, steps)`` call as EDMD; for Hankel-DMD and
+HAVOK that omits ``history`` and therefore zero-pads older delays.
Dataset / benchmark generators and these baselines default to **CPU**
``float32`` tensors unless a docstring or parameter explicitly says otherwise.
@@ -1893,6 +2141,30 @@ must call ``predict(data, steps)`` portably.
- None (uncontrolled peer)
- None
- ``self`` (sklearn chaining)
+ * - :class:`~koopman_graph.baselines.MpEDMDBaseline`
+ - ``Data`` only
+ - None (uncontrolled peer)
+ - None
+ - ``self`` (sklearn chaining)
+ * - :class:`~koopman_graph.baselines.GEDMDBaseline`
+ - ``Data`` only
+ - None (uncontrolled peer)
+ - None
+ - ``self`` (sklearn chaining)
+ * - :class:`~koopman_graph.baselines.HankelDMDBaseline`
+ - ``Data`` only
+ - Optional ``history`` (oldest → newest); omitted slots
+ zero-pad. Not a faithful delay initial condition when
+ ``n_delays > 1`` without ``history``
+ - None
+ - ``self`` (sklearn chaining)
+ * - :class:`~koopman_graph.baselines.HAVOKBaseline`
+ - ``Data`` only
+ - Optional ``history``; omitted slots zero-pad; autonomous
+ forcing ``u=0``. Same delay-initial-condition caveat as
+ Hankel-DMD
+ - Spectrum of autonomous ``A``, not the full delay map
+ - ``self`` (sklearn chaining)
* - :class:`~koopman_graph.baselines.DMDcBaseline`
- ``Data`` only
- **Required** ``controls`` (global ``(C,)`` only; not an uncontrolled
@@ -2250,13 +2522,15 @@ sklearn-like façade, functional helpers, string-mode configuration). Do
* - :mod:`koopman_graph.mpc` (capability package)
- Power-user
- :class:`~koopman_graph.mpc.KoopmanMPC` composes a fitted model;
- additive discrete per-node operators only (bilinear / networked /
- continuous rejected); plant state is mean latent; OSQP behind
+ additive discrete per-node operators (bilinear sequential
+ linearization is opt-in on that class); networked /
+ continuous rejected; plant state is mean latent; OSQP behind
``[mpc]``; output constraints via local decoder Jacobian (local
guarantee); optional ``constraint_tightening=`` with calibrated
:class:`~koopman_graph.uq.ConformalKoopmanUQ` shrinks output boxes
only (inputs exact; calibrated margins, not a closed-loop
- guarantee); off root ``__all__``
+ guarantee); :class:`~koopman_graph.mpc.TubeKoopmanMPC` is
+ additive-only residual-tube tightening (off root ``__all__``)
**UQ and optional uncertainty**
@@ -2278,7 +2552,11 @@ latent-Gaussian, and conformal peers must import those non-private symbols
the shared interval type.
:class:`~koopman_graph.uq.ConformalKoopmanUQ` documents frequentist
marginal coverage under exchangeability and recommends adaptive conformal
-inference (ACI) when residuals drift. ``score="node_wise"`` stores
+inference (ACI) when residuals drift. :attr:`~koopman_graph.uq.ConformalKoopmanUQ.coverage`
+always names :class:`~koopman_graph.uq.JointCoverageSpec`
+``target="per_node_marginal"``; simultaneous and event
+targets are named but not implemented (``Schlembach2025Conformal``).
+``score="node_wise"`` stores
per-node quantiles and returns per-node marginal half-widths (not joint
coverage); legacy ``"per_node"`` still max-pools over nodes.
``PredictionInterval.mean`` / ``.lower`` / ``.upper`` are
@@ -2367,6 +2645,13 @@ shrinks output boxes by per-horizon half-widths (stage ``0`` unshrunk;
stages ``1..H`` use ``quantiles[h-1]``; input bounds unchanged). Margins
inherit conformal exchangeability caveats plus local linearization — they
are statistically motivated, not a formal closed-loop guarantee.
+:class:`~koopman_graph.mpc.TubeKoopmanMPC` (``mpc/tube``; off root
+``__all__``) applies the same erosion from conformal quantiles or
+ensemble residual radii on **additive discrete** plants and reports
+constraint-violation rate, feasibility rate, and quadratic stage cost
+against the nominal boxes. It is not a chance-constraint solver and
+not a recursive-feasibility or Lyapunov closed-loop certificate
+(Zhang et al., *Automatica* 137:110114, 2022).
**Current package boundaries**
@@ -2424,7 +2709,8 @@ new operator classes are the 0.6.0 root additions).
``node_wise`` / ``neighbor_smoothing``)
* Classical DMD ``rank="auto"`` (Gavish–Donoho)
* :class:`~koopman_graph.mpc.KoopmanMPC` (``[mpc]`` / OSQP; optional conformal
- tightening)
+ tightening) and :class:`~koopman_graph.mpc.TubeKoopmanMPC` (additive
+ residual tube; violation rate, not a closed-loop proof)
* Datasets: PEMS variants and SocioPatterns contact-epidemic FAIR cards
* Tutorials ``27``–``38`` covering the surfaces above (including residual /
transfer / factorization notebooks)
@@ -2489,7 +2775,8 @@ new operator classes are the 0.6.0 root additions).
**Optional extras inventory**
-* ``[mpc]`` — OSQP for :class:`~koopman_graph.mpc.KoopmanMPC`
+* ``[mpc]`` — OSQP for :class:`~koopman_graph.mpc.KoopmanMPC` /
+ :class:`~koopman_graph.mpc.TubeKoopmanMPC`
* ``[symmetry]`` — ``networkx`` for approximate node-orbit partitions
* ``[rl]`` — Gymnasium / Stable-Baselines3 (unchanged)
* ``[lightning]`` — Lightning for
@@ -2680,7 +2967,7 @@ TDL parity, PyEMMA-scale MD, guaranteed sample-efficiency) live in
:mod:`koopman_graph.datasets.molecular`, :mod:`koopman_graph.interop`
* ``koopman_symmetry="isotypic"`` +
:func:`~koopman_graph.graph_utils.compute_isotypic_decomposition`
- (self-block MVP; neighbor-factor tying not shipped)
+ (self-block MVP in 0.11; neighbor-factor tying added in 0.14)
**Capability and API-tier map (0.11 peers)**
@@ -2798,7 +3085,10 @@ piecewise linear (each mode remains a linear map).
:mod:`koopman_graph.analysis.tda` 0-dimensional persistence
* Matrix-free hypergraph / continuous-graph distributed path; partitioned
subgraph sampler; growable :math:`N_{\max}` remap
-* Optional ResDMD attachment on ``evaluate``; Kronecker dispersion helper;
+* Optional ResDMD attachment on ``evaluate``; optional subspace
+ invariance leakage :math:`\eta` on ``evaluate(...,
+ include_invariance=True)`` (finite-sample truncated-SVD residual,
+ not a Haseli–Cortés certificate); Kronecker dispersion helper;
optional ``[control-lmi]``; Granger-style causal MVP
* :class:`~koopman_graph.baselines.gnn.LeaderboardProtocol` (distinct from
teaching :class:`~koopman_graph.baselines.gnn.ForecasterProtocol`)
@@ -2819,7 +3109,7 @@ piecewise linear (each mode remains a linear map).
- Conserves parameterized :math:`K`, not decoded :math:`x`
* - Switched / mixture operators
- Package / factory kinds
- - Opt-in; default LTI per-node unchanged
+ - Opt-in; default LTI per-node unchanged. Not :math:`K(\\mu)`
* - Predicted topology head
- Package
- Distinct from static AdaptiveAdjacency
@@ -2839,13 +3129,497 @@ piecewise linear (each mode remains a linear map).
- Power-user extras
- FedAvg is not DP; FDI is data-integrity; VAE is not Laplace UQ
+v0.15.0 capability architecture
+-------------------------------
+
+Evidence-to-closure surfaces keep homogeneous **defaults** unchanged
+(``koopman=None`` → ``"pernode"``, ``sparsity="dense"``, AMP off, linear
+time-invariant per-node advance). ``FORMAT_VERSION`` remains 1. The four
+styles (composition, sklearn-like façade, functional helpers, string-mode
+configuration) are unchanged; do not introduce a fifth. Root ``__all__``
+is not grown. Identification, polynomial graph filters of degree greater
+than one, and the benchmark schema are **opt-in**.
+
+The capability map below records homes and acyclic import rules. Status
+**Current** means the named surface is importable. The CLI
+``benchmark run`` / ``verify`` commands write and check identity-bound
+summaries; they do not train models or invent forecast metrics.
+
+**Defaults (unchanged vs 0.14)**
+
+* Factory ``koopman=None`` still selects ``"pernode"``.
+* ``sparsity="dense"``; AMP off; discrete advance remains linear
+ time-invariant per node.
+* Opt-in identification does not replace the default Adam ``fit`` path.
+* Discrete ``koopman="graph"`` defaults to a single graph-filter tap
+ (``koopman_filter_degree`` default ``1``) so existing checkpoints
+ round-trip. Degree :math:`P>1` is opt-in.
+* New root-façade symbols are refused by default (deep imports).
+
+**Import rules**
+
+* :mod:`koopman_graph.benchmark` — frozen
+ :class:`~koopman_graph.benchmark.ExperimentManifest` (schema
+ ``benchmark_manifest_v1``) plus JSON/YAML load/dump, dataset
+ SHA-256 checks, and identity-bound ``run_manifest`` /
+ ``verify_summary`` (off root ``__all__``). May import ``baselines``,
+ ``datasets``, ``metrics``, and ``model``.
+ No other ``koopman_graph`` package imports ``benchmark``
+ at module load; ``cli`` lazy-imports it inside ``benchmark``
+ handlers. Teaching GNN methods require non-empty ``deviations``.
+ The runner is identity-bound (dataset SHA-256 plus a canonical
+ summary digest). Tracked smoke YAML under ``benchmarks/v0.15/`` is
+ what default CI verifies. It is not a LibCity / BasicTS leaderboard
+ host and does not fit :class:`~koopman_graph.model.GraphKoopmanModel`.
+* :mod:`koopman_graph.identification` — frozen
+ :class:`~koopman_graph.identification.IdentificationConfig` /
+ :class:`~koopman_graph.identification.IdentificationReport` and
+ :class:`~koopman_graph.identification.IdentificationBackend`
+ (off root ``__all__``). May import ``analysis``, ``losses``,
+ ``operators``, and ``graph_utils``. ``training`` must not import
+ ``identification`` at module load (lazy bind inside
+ :func:`~koopman_graph.training.run_fit_loop`).
+ ``fit(..., identification=IdentificationConfig(...))`` is opt-in;
+ default ``None`` is the Adam path. Closed-form ridge / TLS /
+ constrained least-squares solvers write dense per-node ``K``.
+ ``evaluate(..., include_invariance=True)`` lazily imports
+ identification from :mod:`koopman_graph.metrics` (not at module
+ load) and attaches finite-sample :math:`\eta`; not a Haseli–Cortés
+ certificate. Discrete dense per-node ``K`` only.
+ Opt-in finite-dictionary ResDMD gating is available via
+ :func:`~koopman_graph.identification.select_resdmd_gated`,
+ ``IdentificationConfig.gate_resdmd=True`` (fills the final report
+ ``spectral`` block; does not abort ``fit``), and
+ :class:`~koopman_graph.training.ResDMDFitCallback` ``mode="gate"``.
+ :func:`~koopman_graph.identification.identify_sparse_graph_factors`
+ fits sparse :math:`K_{\\mathrm{self}}` / :math:`K_{\\mathrm{nbr}}` on
+ frozen encodings (STLSQ or teaching group-lasso, then unpenalized
+ refit). It does not replace
+ :func:`~koopman_graph.analysis.identify_sparse_dynamics` or
+ :class:`~koopman_graph.losses.KoopmanSparsityLoss`. VarPro remains a
+ later increment.
+ :func:`~koopman_graph.identification.select_latent_rank` scores
+ truncated-SVD ranks of frozen encodings (VAMP-2, ResDMD elbow, or
+ stability-penalized held-out MSE). It is not Ray Tune AutoML for
+ encoder ``latent_dim``.
+ ``identification`` must not import ``adaptation``.
+* :mod:`koopman_graph.adaptation` —
+ :class:`~koopman_graph.adaptation.JointStateTopologyObserver` may
+ import ``identification`` (lazy package export). Importing
+ :class:`~koopman_graph.adaptation.RecursiveKoopmanAdapter` /
+ :class:`~koopman_graph.adaptation.KoopmanObserver` does not load
+ ``identification``. Homomorphism claims require
+ ``encoder_kind="separable"`` and a one-tap graph operator
+ (``Peng2026KoopmanGKFA``). The observer updates
+ :math:`K_{\mathrm{self}}` / :math:`K_{\mathrm{nbr}}` on the observed
+ COO; it does not infer a new edge set and is not Koopman-GKFA.
+* :mod:`koopman_graph.data` —
+ :class:`~koopman_graph.data.EntityRemap` places source nodes into a
+ finite :math:`N_{\max}`. Changing node count without that remap
+ raises. Homogeneous :class:`~koopman_graph.data.ConditioningContext`
+ records :math:`(\\mu, t, u)` from optional
+ ``parameter_trajectory`` (off root ``__all__``).
+ :func:`~koopman_graph.data.diurnal_control_features` is a Fourier
+ recipe for existing control, not a calendar serializer. Must not
+ import ``nn``. ``koopman="parametric"`` is the interpolant factory
+ kind.
+* :mod:`koopman_graph.operators.graphon` must not import
+ :mod:`koopman_graph.data` (homogeneous ``Data`` only).
+* :mod:`koopman_graph.operators.stochastic_sde` must not import
+ ``model``, ``data``, or ``uq``.
+* :mod:`koopman_graph.operators.cochain` must not import ``model``,
+ ``data``, or ``nn``. The order-2 teaching hook
+ (:func:`~koopman_graph.nn.order2_cochain_teaching`,
+ :func:`~koopman_graph.nn.bind_cochain_operator`) lives in
+ :mod:`koopman_graph.nn.cell_complex` so the operator leaf stays
+ nn-free. Cell-complex teaching ceiling is
+ :data:`~koopman_graph.nn.MAX_CELL_COMPLEX_DEGREE` (3).
+* :mod:`koopman_graph.analysis.memory` must not import ``model``.
+* :mod:`koopman_graph.analysis.hodge_modes` must not import ``model``.
+* :mod:`koopman_graph.nn.constraint_decoders` must not import
+ ``model``. :class:`~koopman_graph.nn.MassConservingDecoder` /
+ :class:`~koopman_graph.nn.PositivityDecoder` /
+ :class:`~koopman_graph.nn.LinearConservingDecoder` wrap an inner
+ decoder; they are not a factory kind and have no checkpoint type
+ string. ``FORMAT_VERSION`` remains 1.
+* :mod:`koopman_graph.operators.linear` must not import ``model``.
+ :class:`~koopman_graph.operators.LinearOperatorProtocol` is
+ implemented by
+ :class:`~koopman_graph.operators.PolynomialGraphLinearOperator`
+ and :class:`~koopman_graph.operators.MatrixFreeGraphLinearOperator`.
+ Dense assembly is refused above
+ :data:`~koopman_graph.operators.MAX_DENSE_LINEAR_OPERATOR_SIZE`.
+ Trainer DDP is not a smaller operator representation.
+ ``FORMAT_VERSION`` remains 1.
+* :mod:`koopman_graph.mpc.tube` must not import
+ :mod:`koopman_graph.identification`.
+ :class:`~koopman_graph.mpc.TubeKoopmanMPC` /
+ :class:`~koopman_graph.mpc.TubeMPCReport` are additive discrete
+ only (bilinear / networked / continuous refused). Residual
+ tightening is not a factory kind and has no checkpoint type
+ string. ``FORMAT_VERSION`` remains 1.
+* :mod:`koopman_graph.analysis.causal_intervention` must not import
+ ``model``. :class:`~koopman_graph.analysis.SyntheticSCM` /
+ :func:`~koopman_graph.analysis.recover_synthetic_interventional_edges`
+ are a labeled synthetic do-protocol (teaching three-node SCM).
+ :func:`~koopman_graph.analysis.granger_latent_influence` remains
+ **non-interventional**. Not a factory kind.
+ ``FORMAT_VERSION`` remains 1.
+* ``analysis/`` must not import ``model``. ``interop/`` remains a sink:
+ nothing else imports it.
+
+**Planned layer order (acyclic)**
+
+.. code-block:: text
+
+ L6 benchmark / cli (manifest runner; no inbound imports)
+ L5 mpc / uq / export (action and uncertainty; export still restricted)
+ L4 model / training / adaptation (fit, evaluate, identification bind)
+ L3 identification / nn (solvers, topology head, constraint decoders)
+ L2 operators / analysis (polynomial graph, linear protocol, cochain, spectrum, memory, Hodge modes)
+ L1 data / graph_utils (sequences, batching, graph-state, incidence)
+ L0 spectrum_types / protocols / observables
+
+**Capability and API-tier map (0.15 contract)**
+
+.. list-table::
+ :header-rows: 1
+ :widths: 26 12 18 44
+
+ * - Capability home
+ - Status
+ - API tier
+ - Contracts to preserve
+ * - Default factory / discrete advance
+ - Current
+ - Package
+ - ``koopman=None`` → ``"pernode"``; linear time-invariant per-node;
+ dense sparsity; AMP off
+ * - Root ``__all__``
+ - Current
+ - Public façade
+ - Unchanged vs 0.14; no new root symbols in this contract
+ * - :mod:`koopman_graph.benchmark`
+ - Current
+ - Power-user / CLI
+ - Frozen :class:`~koopman_graph.benchmark.ExperimentManifest`
+ (``benchmark_manifest_v1``) and identity-bound
+ ``summary.json`` (``benchmark_summary_v1``). May import
+ ``baselines``, ``datasets``, ``metrics``, ``model``;
+ no other package imports ``benchmark`` at module load
+ (``cli`` lazy-imports handlers). Teaching GNN methods
+ require non-empty ``deviations``. Dataset SHA-256 mismatch is
+ rejected. ``run`` / ``verify`` do not train and are not SOTA.
+ Default CI verifies ``benchmarks/v0.15/`` stand-ins.
+ * - ``koopman_graph.identification``
+ - Current
+ - Package (off root)
+ - Frozen config / report / backend protocol and closed-form
+ ridge / TLS / constrained-LS solvers. May import
+ ``analysis``, ``losses``, ``operators``, and ``graph_utils``.
+ ``training`` must not
+ import ``identification`` at module load; default Adam path
+ unchanged. ``fit(..., identification=...)`` is opt-in (discrete
+ dense per-node ``K`` only). ``evaluate(...,
+ include_invariance=True)`` attaches finite-sample :math:`\eta`
+ (not a Haseli–Cortés certificate; same operator family).
+ Opt-in ``gate_resdmd`` fills finite-dictionary
+ ``spectral.residual_max``; ``select_resdmd_gated`` and
+ ``ResDMDFitCallback(mode="gate")`` may reject polluted
+ RMSE-only winners (not a certified ResDMD bound).
+ :func:`~koopman_graph.identification.identify_sparse_graph_factors`
+ is closed-form sparse :math:`K_{\\mathrm{self}}` /
+ :math:`K_{\\mathrm{nbr}}` on frozen encodings (not latent SINDy,
+ not :class:`~koopman_graph.losses.KoopmanSparsityLoss`).
+ :func:`~koopman_graph.identification.select_latent_rank` is a
+ frozen-encoding rank grid (not Ray Tune for ``latent_dim``).
+ Must not import ``adaptation``.
+ * - :class:`~koopman_graph.adaptation.JointStateTopologyObserver`
+ - Current
+ - ``adaptation`` (off root)
+ - Kalman filter plus group-sparse graph factors or dense RLS.
+ ``claim_homomorphism=True`` raises unless the encoder is
+ separable **and** the operator is one-tap dense graph
+ (not dual random-walk, not :math:`P\neq 1`). Does not infer a
+ new edge set, does not run ADMM on :math:`A`, and does not
+ certify a three-term MSE bound (``Peng2026KoopmanGKFA``
+ precondition only). Lazy identification import; no checkpoint
+ key; ``FORMAT_VERSION`` remains 1. Class-only separable
+ encoder (no factory ``encoder="separable"``).
+ * - ``koopman="graph"`` + ``koopman_filter_degree``
+ - Current
+ - Factory kwarg
+ - Default ``1`` (current one-tap); :math:`P>1` opt-in; extra hops
+ (:math:`k\ge 2`) are globally shared. Eligible discrete spectrum
+ uses exact Kronecker :math:`\mathrm{eig}(B(\lambda_i))` with
+ :math:`B(\lambda)=\sum_k\lambda^k K_k` (not a sum of independent
+ factor eigenvalues). Dual / orbit stay dense; distributed Arnoldi
+ still requires :math:`P=1`. Inverse of :math:`P\neq 1` remains
+ dense assembly
+ * - Encoder / operator receptive-field check
+ - Current
+ - ``nn`` (off root)
+ - ``fit`` warns when encoder hops exceed discrete graph
+ ``filter_degree``; DiffConv radius is
+ ``num_layers * diffusion_steps``; encoder mixing does not
+ compensate for one-hop :math:`K`; skipped when the operator has
+ no ``receptive_field_hops``
+ * - Multi-graph ``Batch`` collate (``batch_graphs=True``)
+ - Current
+ - ``data`` / ``fit`` (off root)
+ - Opt-in vectorization of the existing ``MultiTrajectory`` loop;
+ batched reconstruction matches the per-sequence mean on modest
+ graphs; default loop unchanged. Not a new multi-topology
+ capability
+ * - :class:`~koopman_graph.spectrum_types.SpectralDiagnostics`
+ - Current
+ - Package (``analysis.__all__``; off root)
+ - Optional on :class:`~koopman_graph.spectrum_types.KoopmanSpectrum`;
+ discrete Nyquist :math:`1/(2\Delta t)` in cycles per unit time
+ (``None`` on generator spectra); per-mode aliasing flag and
+ :math:`\operatorname{sign}(\operatorname{Re}\lambda)`;
+ ``mode_amplitudes`` warns when :math:`\kappa(V)` exceeds
+ ``CONDITION_WARN`` and raises
+ :class:`~koopman_graph.spectrum_types.DefectiveSpectrumError`
+ when :math:`V` is singular; not a finite-horizon
+ :math:`\|K^{k}\|` bound; Kronecker paths may omit ``diagnostics``
+ * - ``matrix_log(..., defective=...)``
+ - Current
+ - Package (``operators``; off root)
+ - Default ``"error"`` raises
+ :class:`~koopman_graph.spectrum_types.DefectiveSpectrumError`
+ on Jordan / singular :math:`V`; ``"schur"`` is SciPy ``logm``
+ on CPU (Al-Mohy–Higham); not a topology-criticality certificate
+ * - :func:`~koopman_graph.analysis.monitor_critical_transition`
+ - Current
+ - Package (``analysis.__all__``; off root)
+ - Sliding-window spectral-gap / near-defectivity heuristic;
+ positive rate means the gap shrank; not a Ghosh-grade
+ topology-criticality certificate
+ * - :class:`~koopman_graph.data.GraphDynamicsConfig`
+ - Current
+ - ``data`` / model kwarg (off root)
+ - Opt-in; default ``graph_dynamics=None`` is the 0.14 path. When a
+ config is passed, ``topology_head`` defaults to
+ ``sparse_candidate`` (at most ``candidate_k`` destinations per
+ node; default 8). ``dense_mlp`` keeps
+ :class:`~koopman_graph.nn.PredictedTopologyHead` with an
+ :math:`N` ceiling of 64. Distinct from
+ ``learn_topology="self_adaptive"`` /
+ :class:`~koopman_graph.nn.AdaptiveAdjacency` (mutually exclusive
+ with a non-``none`` topology head). Additive checkpoint key;
+ ``FORMAT_VERSION`` remains 1. When a head is attached and
+ ``recursive_training`` is true, ``fit`` / ``predict`` /
+ ``evaluate`` / UQ use predicted
+ :math:`\hat A_{t+1}=g_\phi(z_t)` unless
+ ``future_topologies`` or ``topology_policy="hold_last"`` is set.
+ Evaluate on dynamic sequences injects oracle futures only when
+ that recursive path is off.
+ :class:`~koopman_graph.data.GraphStateSnapshot` is a supervision
+ record, not a replacement for
+ :class:`~koopman_graph.data.GraphSnapshotSequence`
+ * - :class:`~koopman_graph.data.EntityRemap`
+ - Current
+ - ``data`` (off root)
+ - Injective placement into a caller-chosen finite
+ :math:`N_{\\max}`. ``entity_ids`` names union rows;
+ ``index`` maps source rows. Sequences with changing
+ :math:`N` raise unless remapped first. Not automatic
+ entity resolution; no unbounded growth; no checkpoint
+ key; ``FORMAT_VERSION`` remains 1. Homogeneous ``Data``
+ only.
+ * - :func:`~koopman_graph.operators.estimate_graphon`
+ - Current
+ - ``operators`` (off root)
+ - Dense teaching fit of constant / product / low-rank
+ kernels on aligned graphs with a shared :math:`N`.
+ Ceiling :math:`N \le 256`. Not a unique graphon
+ identifier, not sparse-graph graphon theory, and not a
+ transferability certificate
+ (``Ruiz2023Transferability``). No checkpoint key;
+ ``FORMAT_VERSION`` remains 1.
+ * - :class:`~koopman_graph.data.ConditioningContext`
+ - Current
+ - ``data`` (off root)
+ - Homogeneous ``parameter_trajectory`` shape
+ :math:`(T, d_\\mu)` plus per-index
+ :math:`(\\mu, t, u)`. Optional ``period`` /
+ ``num_phases`` fill ``phase_index``.
+ :func:`~koopman_graph.data.diurnal_control_features`
+ writes Fourier columns onto existing control; it is
+ not a calendar serializer. Data record only; default
+ operator is unchanged. ``koopman="switched"`` /
+ ``"mixture"`` are latent-gated or piecewise LTI maps,
+ not :math:`K(\\mu)` (``Macesic2018Nonautonomous``).
+ Discrete uniform-:math:`\\Delta t` validation is
+ unchanged; use continuous ``predict_at`` or supplied
+ gEDMD derivatives for irregular sampling.
+ * - :class:`~koopman_graph.operators.ParametricKoopmanOperator`
+ - Current
+ - ``operators`` (off root)
+ - Discrete interpolant :math:`K(\\mu)=\\sum_j \\alpha_j(\\mu) K_j`
+ with RBF or simplex weights (``koopman="parametric"``). Distinct
+ from switched / mixture. Convex combinations preserve dense /
+ row-stochastic / doubly-stochastic factors; symplectic / ODO /
+ Schur / dissipative / Lyapunov / auxiliary-spectral mixes raise.
+ Export refuses the interpolant. Additive checkpoint type string;
+ ``FORMAT_VERSION`` remains 1. Leave-one-regime-out helper
+ :func:`~koopman_graph.operators.leave_one_regime_out` compares
+ the interpolant to pooled LTI on latent pairs.
+ * - :class:`~koopman_graph.operators.CochainState` /
+ :class:`~koopman_graph.operators.CochainKoopmanOperator`
+ - Current
+ - ``operators`` (off root)
+ - Degree-specific :math:`k\\le 1` maps on a static signed
+ :math:`B_1`. Not a factory kind; default
+ ``koopman=None`` stays ``"pernode"``. Distinct from
+ ``koopman="hodge"``. Face latents are stored, not evolved.
+ :func:`~koopman_graph.operators.boundary_nilpotency` flags
+ :math:`B_1 B_2\\approx 0` at atol ``1e-6``. Not TopologicX
+ / sheaf parity (``Lim2020Hodge``, ``TopoX2024``). No
+ checkpoint key; ``FORMAT_VERSION`` remains 1
+ * - :func:`~koopman_graph.nn.order2_cochain_teaching` /
+ :func:`~koopman_graph.nn.bind_cochain_operator`
+ - Current
+ - ``nn`` (off root)
+ - Order-2 filled triangle bound to
+ :class:`~koopman_graph.operators.CochainKoopmanOperator`.
+ :math:`k=2` stored, not evolved. Cell-complex ceiling
+ :data:`~koopman_graph.nn.MAX_CELL_COMPLEX_DEGREE` (3).
+ Sheaf restriction maps stay learned-optional (default
+ diagonal). Not TopologicX / TDA ecosystem parity
+ (``TopoX2024``). ``FORMAT_VERSION`` remains 1
+ * - :class:`~koopman_graph.operators.DriftDiffusionKoopman`
+ - Current
+ - ``operators`` (off root)
+ - Opt-in :math:`dz=Lz\\,dt+F\\,dW` stepper with Euler–Maruyama or
+ Yosida increments. ``forward`` is the conditional-expectation
+ semigroup; ``advance`` samples a path. Not a factory kind;
+ ``dynamics_mode="stochastic"`` stays diagonal process noise.
+ Not certified SDE theory and not SDMD
+ (``Xu2025StochasticSemigroup``, ``Zhou2025Yosida``). No
+ checkpoint key; ``FORMAT_VERSION`` remains 1.
+ * - :class:`~koopman_graph.uq.JointCoverageSpec`
+ - Current
+ - ``uq`` (off root)
+ - Named coverage estimand. Default
+ ``target="per_node_marginal"``. Simultaneous / event targets
+ and ``block!="none"`` raise. Conformal
+ :attr:`~koopman_graph.uq.ConformalKoopmanUQ.coverage` always
+ names the target. Proper scores
+ :func:`~koopman_graph.uq.gaussian_crps`,
+ :func:`~koopman_graph.uq.gaussian_nll`, and
+ :func:`~koopman_graph.uq.energy_score` are not coverage
+ certificates (``Schlembach2025Conformal``).
+ * - :func:`~koopman_graph.analysis.markov_closure_report`
+ - Current
+ - Package (``analysis.__all__``; off root)
+ - Ljung–Box-style flag on residual-energy autocorrelation
+ (lags in timesteps). Scalar portmanteau, not a matrix-variate
+ test and not Mori–Zwanzig identification
+ (``Lin2021MoriZwanzig``, ``Ljung1978Box``)
+ * - :class:`~koopman_graph.analysis.FiniteMemoryKoopman`
+ - Current
+ - Package (``analysis.__all__``; off root)
+ - Convolution
+ :math:`z_{t+1}=z_t\\Omega^{\\top}+\\sum_{s=1}^{M}z_{t-s}K_s^{\\top}`.
+ Not a factory kind; not
+ :class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder`; not
+ HAVOK (``Brunton2017HAVOK``). Memory-order selection is not
+ implemented. No checkpoint key; ``FORMAT_VERSION`` remains 1
+ * - :func:`~koopman_graph.analysis.hodge_decompose_modes` /
+ :class:`~koopman_graph.analysis.HodgeModeComponents`
+ - Current
+ - Package (``analysis.__all__``; off root)
+ - Combinatorial Hodge split of stored eigenvector columns
+ (gradient / curl / harmonic) on a static signed
+ :math:`B_1`. :math:`k\\in\\{0,1\\}`; face curl is not
+ shipped. Analysis-only — not a factory kind, not physical
+ circulation, not ``koopman="hodge"``, and not TopologicX /
+ sheaf parity (``Lim2020Hodge``, ``TopoX2024``).
+ ``FORMAT_VERSION`` remains 1
+ * - :class:`~koopman_graph.operators.EquivariantKoopmanOperator`
+ - Current
+ - ``operators`` (off root)
+ - Scalar block plus ``scale * I_3`` vectors and one
+ additional :math:`l=2` irrep (``scale * I_5``;
+ ``n_tensors``). Not a factory kind; default encode may still
+ project to invariants. Rotation test uses ``e3nn``
+ (``[equivariance]``); the operator does not import ``e3nn``.
+ Not a molecular MD production stack (``Thomas2018TFN``,
+ ``Geiger2022e3nn``). ``FORMAT_VERSION`` remains 1
+ * - :class:`~koopman_graph.operators.LinearOperatorProtocol` /
+ :class:`~koopman_graph.operators.PolynomialGraphLinearOperator`
+ - Current
+ - ``operators`` (off root)
+ - Common ``matvec`` / ``rmatvec`` / ``solve`` /
+ ``expm_action`` / leading eigpairs / residual norms.
+ One-tap wrapper reuses ``matrix_free``. Dense assembly
+ refused above
+ :data:`~koopman_graph.operators.MAX_DENSE_LINEAR_OPERATOR_SIZE`.
+ Trainer DDP does not shrink the representation. Kronecker
+ exact spectrum remains a special case. Not a factory kind.
+ ``FORMAT_VERSION`` remains 1
+ * - :class:`~koopman_graph.nn.MassConservingDecoder` /
+ :class:`~koopman_graph.nn.LinearConservingDecoder` /
+ :class:`~koopman_graph.nn.PositivityDecoder`
+ - Current
+ - ``nn`` (off root)
+ - Opt-in decoded-space simplex / affine mass,
+ :math:`Cx=c_0`, and positivity heads on named channels.
+ Not a factory kind; no checkpoint type string.
+ :mod:`koopman_graph.nn.constraint_decoders` must not import
+ ``model``. Latent symplectic :math:`K` alone does not
+ conserve decoded mass (``Greydanus2019HNN``). IEEE-118
+ remains Laplacian diffusion, not AC power flow.
+ ``FORMAT_VERSION`` remains 1
+ * - :class:`~koopman_graph.mpc.TubeKoopmanMPC` /
+ :class:`~koopman_graph.mpc.TubeMPCReport`
+ - Current
+ - ``mpc`` (off root)
+ - Residual-tube tightening from conformal or ensemble
+ radii on additive discrete plants. ``evaluate`` reports
+ constraint-violation rate, feasibility rate, and cost
+ against nominal boxes. Not a chance-constraint solver,
+ not recursive feasibility, and not a Lyapunov
+ closed-loop certificate (``Zhang2022TubeMPC``). Bilinear
+ / networked / continuous plants raise. Coverage stays
+ ``per_node_marginal``. Not a factory kind.
+ ``FORMAT_VERSION`` remains 1
+ * - :class:`~koopman_graph.analysis.SyntheticSCM` /
+ :class:`~koopman_graph.analysis.SyntheticInterventionReport` /
+ :func:`~koopman_graph.analysis.recover_synthetic_interventional_edges`
+ - Current
+ - ``analysis`` (off root)
+ - Labeled synthetic linear Gaussian SCM and paired
+ do-interventions. Teaching fixture recovers a known
+ edge ``0 → 1``.
+ :func:`~koopman_graph.analysis.granger_latent_influence`
+ remains **non-interventional**. Not a factory kind and
+ not field-data causal discovery.
+ ``FORMAT_VERSION`` remains 1
+ * - Checkpoint schema
+ - Current
+ - Serialization
+ - ``FORMAT_VERSION`` 1; additive type strings only
+
Related documentation
---------------------
* :doc:`api` — module-level API reference
* :doc:`capabilities` — feature inventory
* :doc:`limitations` — scope boundaries and when not to use
-* :doc:`graphon` — graphon sampling and continuum-limit citations
+* :doc:`graphon` — graphon sampling, dense teaching estimation, and
+ continuum-limit citations
+* :doc:`benchmarks` — identity-bound manifests and ``benchmark`` CLI
+* :doc:`identification` — opt-in closed-form :math:`K` and rank grid
+* :doc:`spectral_diagnostics` — conditioning, Nyquist, and
+ :math:`\kappa(V)`
+* :doc:`graph_dynamics` — predicted topology versus hold-last
+* :doc:`matrix_free` — ``LinearOperatorProtocol`` wrappers
+* :doc:`criticality` — sliding-window spectral-gap monitor
+* :doc:`time_conditioning` — :math:`(\mu, t, u)` records and
+ parametric :math:`K(\mu)`
* :doc:`quickstart` — end-user training and prediction walkthrough
* :doc:`cli` — config-driven console script
* :doc:`faq` — install / import / checkpoint troubleshooting and support routing
diff --git a/docs/source/benchmarks.rst b/docs/source/benchmarks.rst
new file mode 100644
index 0000000..66a2f06
--- /dev/null
+++ b/docs/source/benchmarks.rst
@@ -0,0 +1,54 @@
+Identity-bound benchmarks
+=========================
+
+A **benchmark** in this library is a frozen protocol record plus an
+identity-bound summary, not a trained forecast table. The runner hashes
+dataset bytes and declared methods; it does **not** fit
+:class:`~koopman_graph.model.GraphKoopmanModel` or invent MAE / RMSE.
+
+What ships
+----------
+
+:class:`~koopman_graph.benchmark.ExperimentManifest` (schema
+``benchmark_manifest_v1``) records dataset SHA-256, seeds, horizons,
+declared metric *names*, and method rows. Teaching GNN methods require
+non-empty ``deviations``. JSON always loads; YAML needs
+``pip install "koopman-graph[cli]"`` (PyYAML).
+
+``koopman-graph benchmark run`` writes ``summary.json`` (schema
+``benchmark_summary_v1``) with ``executed=False`` and a canonical
+SHA-256 of the identity fields. ``verify`` recomputes that digest and
+fails on a tampered hash or a dataset-byte mismatch. Types live under
+:mod:`koopman_graph.benchmark` (off root ``__all__``). No other
+package imports ``benchmark`` at module load; the CLI lazy-imports
+handlers.
+
+Tracked smoke fixtures live under ``benchmarks/v0.15/`` (telemetry-like,
+multiphysics, topology transfer). Payloads are tiny hashed UTF-8
+stand-ins, not METR-LA HDF5. Default CI runs ``benchmark verify`` on
+those stubs.
+
+How to use it
+-------------
+
+.. code-block:: bash
+
+ koopman-graph benchmark verify \
+ --manifest benchmarks/v0.15/smoke_telemetry.yaml \
+ --against benchmarks/v0.15/summaries/smoke_telemetry.json
+
+A Python walkthrough is ``examples/47_benchmark_manifest.ipynb``. Full
+telemetry remains a documented extra, not default CI.
+
+Ceilings
+--------
+
+* ``run`` / ``verify`` do not train, download METR-LA, or host a
+ LibCity / BasicTS leaderboard (``LibCity2021``, ``BasicTS2024``).
+* Identity-bound summaries are **not** forecast scores. Declared metric
+ names are labels on the protocol, not computed errors.
+* The ``[benchmark]`` extra is empty. YAML is the only optional
+ dependency (``[cli]``).
+
+See :doc:`cli` for flags, :doc:`limitations` for the honesty boundary,
+and :doc:`tutorials` for the gallery row.
diff --git a/docs/source/capabilities.rst b/docs/source/capabilities.rst
index 55f81d1..e98b13b 100644
--- a/docs/source/capabilities.rst
+++ b/docs/source/capabilities.rst
@@ -22,6 +22,11 @@ Topology-aware learning
diffusion, Li et al. 2018; Transformer: PyG ``TransformerConv`` / Shi et al.
masked attention on edges — typically denser compute than GCN/GAT/DiffConv
per edge × heads)
+* ``SeparableDictionaryEncoder`` / ``SeparableDictionaryDecoder``
+ (node-wise MLP; ``encoder_kind="separable"``; zero graph hops). Pass
+ the classes into ``GraphKoopmanModel``; there is no factory
+ ``encoder="separable"``. Homomorphism precondition
+ (``Peng2026KoopmanGKFA``); not a GNN and not on the root façade.
* ``HypergraphEncoder`` / ``HypergraphDecoder`` with incidence
``hyperedge_index`` (``koopman="hypergraph"``); directed incidence via
``tail_index`` / ``head_index`` when using non-Zhou ``incidence_mode``
@@ -62,8 +67,9 @@ Topology-aware learning
``pip install "koopman-graph[equivariance]"``) — Tier B steerable
encode to invariant scalar latents; still **not** an equivariant
:math:`K`
-* ``DelayEmbeddingEncoder`` / ``n_delays`` for Hankel-style partial
- observability
+* ``DelayEmbeddingEncoder`` / ``n_delays`` for Takens-style channel
+ stacking — not :class:`~koopman_graph.baselines.HankelDMDBaseline` or
+ :class:`~koopman_graph.baselines.HAVOKBaseline`
* Per-snapshot ``edge_index`` (dynamic topology) and end-to-end
``edge_weight`` support — mechanical rewiring only; not
criticality / spectral-degeneracy analysis (see
@@ -72,6 +78,21 @@ Topology-aware learning
:math:`N_{\max}` capacity and ``presence_masks`` ``(T, N_{\max})``
(losses ignore inactive nodes; matvecs stay at capacity — see
:doc:`limitations`)
+* :class:`~koopman_graph.data.EntityRemap` places source nodes into a
+ finite :math:`N_{\max}` via a user-supplied injective index (not
+ automatic identity resolution; changing :math:`N` without a remap
+ raises)
+* Homogeneous ``parameter_trajectory`` and
+ :class:`~koopman_graph.data.ConditioningContext` record
+ :math:`(\\mu, t, u)` (off the root façade). This does not change the
+ default operator. ``koopman="switched"`` / ``"mixture"`` are not
+ :math:`K(\\mu)`. Opt-in ``koopman="parametric"`` interpolates
+ :math:`K(\\mu)=\\sum_j \\alpha_j(\\mu) K_j`.
+ :func:`~koopman_graph.data.diurnal_control_features` and
+ :func:`~koopman_graph.data.diurnal_phase_index` are time-of-day
+ recipes on existing control / switched ``phase_index``, not a
+ calendar serializer. Discrete sequences still require uniform
+ :math:`\\Delta t`.
* Optional self-adaptive pairwise topology
(``learn_topology="self_adaptive"``; ``koopman_graph.nn.AdaptiveAdjacency``)
* Optional symmetry-adapted orbit-tied ``K_self`` (and, on discrete
@@ -84,9 +105,23 @@ Topology-aware learning
:func:`~koopman_graph.graph_utils.compute_isotypic_decomposition`;
``examples/45_isotypic_symmetry.ipynb``) — not a guaranteed
sample-efficiency win
-* Predicted next-step topology
- (:class:`~koopman_graph.nn.PredictedTopologyHead`); AdaptiveAdjacency
- remains the static Graph WaveNet formula
+* Predicted next-step topology: default
+ :class:`~koopman_graph.nn.SparseCandidateTopologyHead` when
+ :class:`~koopman_graph.data.GraphDynamicsConfig` is set
+ (``topology_head="sparse_candidate"``; at most ``candidate_k``
+ destinations per node). :class:`~koopman_graph.nn.PredictedTopologyHead`
+ remains the ``dense_mlp`` power-user path with an :math:`N` ceiling of
+ 64. Distinct from ``learn_topology="self_adaptive"`` /
+ :class:`~koopman_graph.nn.AdaptiveAdjacency` (static Graph WaveNet);
+ the two cannot be combined when ``topology_head`` is not ``none``.
+ When ``recursive_training`` is true, omitted ``future_topologies``
+ uses predicted :math:`\hat A_{t+1}=g_\phi(z_t)` as sigmoid weights on
+ the candidate COO (``topology_policy="hold_last"`` restores the 0.14
+ control). Evaluate on dynamic sequences injects oracle futures only
+ when that recursive path is off. See
+ ``examples/50_graph_state_closure.ipynb`` (wiring check versus
+ hold-last; not a learned-forecast claim). Narrative guide:
+ :doc:`graph_dynamics`.
Dynamics
~~~~~~~~
@@ -94,16 +129,44 @@ Dynamics
* Discrete ``KoopmanOperator`` with soft modes (``dense``, ``odo``),
structural guarantees (``schur``, ``dissipative``, ``lyapunov``), or
structure-preserving maps (``row_stochastic``, ``doubly_stochastic``,
- ``symplectic``; conservation is on parameterized :math:`K`)
+ ``symplectic``; those maps constrain parameterized :math:`K`, not
+ decoded :math:`x`)
+* Opt-in decoded-space constraint heads
+ (:class:`~koopman_graph.nn.MassConservingDecoder`,
+ :class:`~koopman_graph.nn.PositivityDecoder`,
+ :class:`~koopman_graph.nn.LinearConservingDecoder`). Softmax /
+ affine mass, positivity, and :math:`Cx=c_0` act after decode.
+ Latent symplectic :math:`K` alone does not conserve decoded mass
+ (``Greydanus2019HNN``). IEEE-118 remains Laplacian diffusion, not
+ AC power flow
* Networked ``GraphKoopmanOperator`` (``koopman="graph"``) with
self/neighbor coupling
:math:`I_N \otimes K_{\mathrm{self}} + \widehat{A} \otimes K_{\mathrm{nbr}}`
- (factorized blocks inspired by compositional / networked Koopman
- constructions; cross-topology transfer is measured, not assumed — see
- :doc:`limitations` and ``examples/37_cross_topology_transfer.ipynb``).
+ by default (``koopman_filter_degree=1``). Degree :math:`P>1` adds
+ :math:`\sum_{k=2}^{P}\widehat{A}^{k}\otimes K_{k}` (monomial; extra hops
+ globally shared; factory ``koopman_filter_degree``). Factorized blocks
+ inspired by compositional / networked Koopman constructions;
+ cross-topology transfer is measured, not assumed — see
+ :doc:`limitations` and ``examples/37_cross_topology_transfer.ipynb``.
+ Degree-:math:`P` ablation and Kronecker-versus-dense spectrum live in
+ ``examples/38_operator_factorization_ablation.ipynb`` and
+ ``examples/49_multi_hop_factorization.ipynb``; they do not attribute
+ the path-diffusion joint-LS gap to hop order.
``.spectrum`` auto-routes Kronecker-sum reduction when eligible (shared
- self; ``symmetric`` / ``random_walk``); see :doc:`limitations` (Scale)
- for distributed Arnoldi, dense fall-backs, and inverse ceilings
+ self; ``symmetric`` / ``random_walk``; any monomial :math:`P\ge 0`); see
+ :doc:`limitations`
+ (Scale) for distributed Arnoldi, dense fall-backs, and inverse ceilings.
+ ``fit`` warns when encoder hops exceed ``filter_degree``; encoder
+ mixing does not compensate for a one-hop :math:`K`
+* Matrix-free linear-operator protocol
+ (:class:`~koopman_graph.operators.LinearOperatorProtocol`) wrapping
+ a degree-:math:`P\\ge 2` graph polynomial and the existing one-tap
+ ``matrix_free`` helpers. Dense assembly is refused above
+ :data:`~koopman_graph.operators.MAX_DENSE_LINEAR_OPERATOR_SIZE`.
+ Trainer DDP does not shrink that representation. Leading eigpairs
+ are Arnoldi Ritz values, not Kronecker :math:`\\operatorname{eig}(B(\\lambda))`.
+ :math:`10^{5}`-node scaling is not a release gate. See
+ :doc:`matrix_free`.
* Pairwise ``adjacency`` modes on graph / continuous-graph operators:
``"symmetric"`` (default), ``"random_walk"``
(:math:`D_{\mathrm{out}}^{-1}A`), and ``"dual_random_walk"`` (forward plus
@@ -124,11 +187,39 @@ Dynamics
* Global/local non-stationary discrete operator
(``GlobalLocalKoopmanOperator``; ``koopman="global_local"``)
* Switched and mixture discrete maps (``koopman="switched"`` /
- ``"mixture"``; piecewise-linear or softmax mixture of LTI modes)
+ ``"mixture"``; piecewise-linear or softmax mixture of LTI modes,
+ not a parameter interpolant :math:`K(\\mu)`)
+* Opt-in parametric interpolant (``koopman="parametric"``;
+ :class:`~koopman_graph.operators.ParametricKoopmanOperator`)
+ :math:`K(\\mu)=\\sum_j \\alpha_j(\\mu) K_j` with RBF or simplex
+ weights. Convex combinations preserve dense / row-stochastic /
+ doubly-stochastic factors; symplectic and other structural mixes
+ raise. Export refuses the interpolant
+ (``Macesic2018Nonautonomous``)
* Hodge / Laplacian-structured networked operator (``koopman="hodge"``)
+* Degree-specific cochain maps
+ (:class:`~koopman_graph.operators.CochainKoopmanOperator`) on a
+ static signed :math:`B_1` for :math:`k\\le 1`. Not a factory kind;
+ default ``koopman=None`` stays ``"pernode"``. Face latents are
+ stored, not evolved. Not TopologicX parity
+* Order-2 TDL teaching path
+ (:func:`~koopman_graph.nn.order2_cochain_teaching`) binding that
+ cochain operator to a filled triangle. Cell-complex degree
+ :data:`~koopman_graph.nn.MAX_CELL_COMPLEX_DEGREE` (3) is the
+ ceiling. Sheaf restriction maps stay learned-optional. Not
+ ecosystem parity
+* Combinatorial Hodge split of stored Koopman mode shapes
+ (:func:`~koopman_graph.analysis.hodge_decompose_modes`).
+ Analysis-only gradient / curl / harmonic blocks on a static
+ signed :math:`B_1` (:math:`k\\in\\{0,1\\}`). Not physical
+ circulation, not a factory kind, and not ``koopman="hodge"``.
+ Teaching notebook: ``examples/52_cochain_hodge_modes.ipynb``
* Equivariant block operator
- (:class:`~koopman_graph.operators.EquivariantKoopmanOperator`; vector
- channels are multiples of :math:`I_3`)
+ (:class:`~koopman_graph.operators.EquivariantKoopmanOperator`;
+ vector channels are multiples of :math:`I_3`; optional
+ :math:`l=2` channels are multiples of :math:`I_5`). Not a
+ factory kind and not a molecular MD stack. Default encode may
+ still project to invariants
* Continuous-time ``ContinuousKoopmanOperator``
(``dynamics_mode="continuous"``), irregular timestamps, and
``predict_at``
@@ -144,7 +235,22 @@ Dynamics
``dynamics_mode="continuous"`` (dense stacked :math:`\Phi` cost; see
:doc:`limitations`)
* Opt-in ``dynamics_mode="stochastic"`` — discrete linear map plus learned
- diagonal process noise (not a continuous-time SDE)
+ diagonal process noise (not a continuous-time SDE).
+ :class:`~koopman_graph.operators.DriftDiffusionKoopman` is a separate
+ Euler–Maruyama / Yosida stepper, not that factory string
+* :class:`~koopman_graph.uq.JointCoverageSpec` names the conformal
+ estimand (default ``per_node_marginal``). Simultaneous / event
+ targets are named but not implemented. Proper scores
+ :func:`~koopman_graph.uq.gaussian_crps`,
+ :func:`~koopman_graph.uq.gaussian_nll`, and
+ :func:`~koopman_graph.uq.energy_score` are not coverage certificates
+* Innovation-whiteness report
+ (:func:`~koopman_graph.analysis.markov_closure_report`) and a
+ convolution MVP
+ (:class:`~koopman_graph.analysis.FiniteMemoryKoopman`). Not
+ Mori–Zwanzig identification, not HAVOK, and not
+ :class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder`. Not a
+ factory kind
* Continuous ``koopman_parameterization="auxiliary_spectral"`` — state-dependent
``generator_at(z)`` / instantaneous spectrum (Lusch-style; locally linear,
not a fixed global matrix). Prefer delay embeddings first for continuous-
@@ -172,6 +278,54 @@ Forecasting and training
(structured report; mandatory ``pernode`` control; negative advantage
allowed — see :doc:`limitations` and
``examples/37_cross_topology_transfer.ipynb``)
+* Frozen identification config / report types and closed-form ridge /
+ TLS / constrained least-squares solvers under
+ :mod:`koopman_graph.identification` (off root ``__all__``).
+ ``GraphKoopmanModel.fit(..., identification=IdentificationConfig(...))``
+ is opt-in; default ``identification=None`` is the Adam path. Discrete
+ dense per-node operators only. The report records latent one-step /
+ short-rollout mean squared error (MSE) and :math:`\rho(K)`, not an
+ invariance or ResDMD certificate.
+ :func:`~koopman_graph.identification.subspace_invariance_report` and
+ ``evaluate(..., include_invariance=True)`` report finite-sample
+ projection leakage :math:`\eta` on a truncated-SVD encoding span
+ (discrete dense per-node :math:`K` only). That ratio is not a
+ Haseli–Cortés invariance-proximity certificate.
+ :func:`~koopman_graph.identification.select_resdmd_gated` compares
+ pre-scored dictionaries by train one-step mean squared error (MSE)
+ after optionally dropping candidates whose max finite-dictionary
+ ResDMD residual exceeds :math:`10^{-2}`.
+ ``IdentificationConfig.gate_resdmd=True`` fills the report
+ ``spectral`` block; it does not abort ``fit``.
+ :class:`~koopman_graph.training.ResDMDFitCallback` ``mode="gate"``
+ raises at fit end when the observed max residual exceeds that
+ cutoff. Default callback mode is ``"observe"``. None of these is a
+ certified infinite-dimensional residual bound.
+ :func:`~koopman_graph.identification.identify_sparse_graph_factors`
+ selects sparse :math:`K_{\\mathrm{self}}` / :math:`K_{\\mathrm{nbr}}`
+ on frozen encodings (STLSQ or teaching group-lasso, then unpenalized
+ refit; related literature ``Pan2021SparseSubspace``, not that paper's
+ multi-task EDMD pruning). Existing
+ :class:`~koopman_graph.losses.KoopmanSparsityLoss` and
+ :func:`~koopman_graph.analysis.identify_sparse_dynamics` still ship.
+ :func:`~koopman_graph.identification.select_latent_rank` scores
+ truncated-SVD ranks of frozen encodings (in-tree VAMP-2, ResDMD
+ elbow, or stability-penalized held-out MSE). It is not Ray Tune /
+ AutoML for encoder ``latent_dim``
+ (:mod:`koopman_graph.tuning` remains caller-owned HPO).
+ Teaching notebooks: ``examples/48_identification_invariance.ipynb``
+ and ``examples/53_latent_rank_selection.ipynb``. See
+ :doc:`identification`.
+* :mod:`koopman_graph.benchmark` holds frozen
+ :class:`~koopman_graph.benchmark.ExperimentManifest` records (schema
+ ``benchmark_manifest_v1``; JSON always, YAML via PyYAML / ``[cli]``).
+ Teaching GNN methods require non-empty ``deviations``. Dataset SHA-256
+ mismatch is rejected. The CLI ``benchmark run`` / ``verify`` commands
+ write identity-bound summaries (dataset SHA-256 and a canonical
+ digest). They do not train models, download telemetry, or host a
+ LibCity / BasicTS leaderboard. Tracked smoke YAML under
+ ``benchmarks/v0.15/`` is what default CI verifies. See
+ :doc:`benchmarks` and ``examples/47_benchmark_manifest.ipynb``.
Training performance
~~~~~~~~~~~~~~~~~~~~
@@ -189,6 +343,10 @@ Internal training-path reuse (same scientific defaults; see
one training-loss evaluation
* Shared one-step predictions across reconstruction / PDE / worst-case
terms when those losses are active together
+* Opt-in multi-graph ``Batch`` collate: ``fit(..., batch_graphs=True)``
+ vectorizes the existing ``MultiTrajectory`` loop (reconstruction and
+ forward consistency). Default per-sequence averaging is unchanged.
+ Multi-topology training did not require this flag.
* Opt-in CUDA automatic mixed precision: ``use_amp=True`` on
``GraphKoopmanModel.fit`` / ``run_fit_loop`` (FP32 fallback on
CPU/MPS)
@@ -272,6 +430,17 @@ Analysis
~~~~~~~~
* ``KoopmanSpectrum`` / ``compute_spectrum`` with mode decoding helpers
+ and optional :class:`~koopman_graph.spectrum_types.SpectralDiagnostics`
+ (:math:`\kappa(V)`, Wilkinson :math:`\kappa_i`, departure from
+ normality, discrete Nyquist :math:`1/(2\Delta t)` in cycles per unit
+ time, aliasing flags). Not a finite-horizon :math:`\|K^{k}\|` bound.
+ See :doc:`spectral_diagnostics` and
+ ``examples/51_spectral_diagnostics.ipynb``
+* Sliding-window spectral-gap monitor
+ (:func:`~koopman_graph.analysis.monitor_critical_transition`) — positive
+ rate means the gap shrank; **not** a Ghosh-grade certificate
+ (``Ghosh2025``). See :doc:`criticality` and
+ ``examples/54_criticality_monitor.ipynb``
* Networked graph / continuous-graph ``.spectrum`` paths: distributed
Arnoldi (discrete / multiplex hetero / Zhou-symmetric hypergraph /
continuous-graph generator factors), Kronecker-sum exact reduction
@@ -305,10 +474,49 @@ Analysis
(:func:`~koopman_graph.analysis.resolvent_norm_grid`) — **not**
infinite-dimensional certified pseudospectra; see
``examples/40_resdmd_pseudospectra.ipynb``
+* Finite-sample subspace invariance leakage
+ (:func:`~koopman_graph.identification.subspace_invariance_report`,
+ ``evaluate(..., include_invariance=True)``) — truncated-SVD
+ projection residual on encoded samples; **not** a Haseli–Cortés
+ certificate (``HaseliCortes2023``); discrete dense per-node
+ :math:`K` only
+* Residual-aware dictionary selection
+ (:func:`~koopman_graph.identification.select_resdmd_gated`) and
+ opt-in ``ResDMDFitCallback(mode="gate")`` — drop or reject
+ finite-dictionary ResDMD-polluted RMSE-only winners; **not** a
+ certified residual bound; default callback mode remains
+ ``"observe"``
+* Sparse graph-factor identification
+ (:func:`~koopman_graph.identification.identify_sparse_graph_factors`)
+ — STLSQ or teaching group-lasso on frozen
+ :math:`K_{\\mathrm{self}}` / :math:`K_{\\mathrm{nbr}}` with
+ unpenalized refit. Distinct from latent SINDy
+ (:func:`~koopman_graph.analysis.identify_sparse_dynamics`) and from
+ :class:`~koopman_graph.losses.KoopmanSparsityLoss`; those tools still
+ ship. Dual random-walk and :math:`P>1` polynomial hops are out of
+ scope.
+* Latent-rank selection
+ (:func:`~koopman_graph.identification.select_latent_rank`) — VAMP-2,
+ ResDMD elbow, or stability-penalized held-out MSE on a truncated-SVD
+ grid of frozen encodings. Not Ray Tune AutoML for encoder
+ ``latent_dim``; ``[msm]`` / deeptime is an optional VAMP-2
+ cross-check (``deeptime2021``).
+* Protocol-locked experiment manifests
+ (:class:`~koopman_graph.benchmark.ExperimentManifest`) — schema
+ ``benchmark_manifest_v1`` with dataset SHA-256, ≥3 seeds, mandatory
+ controls, and teaching-port ``deviations``. CLI ``run`` / ``verify``
+ hash protocol identity; they do not train and are not SOTA. Default
+ CI verifies hashed synthetics under ``benchmarks/v0.15/``, not full
+ METR-LA.
* Kronecker dispersion
(:func:`~koopman_graph.analysis.graph_dispersion`) and Granger-style
causal MVP (:func:`~koopman_graph.analysis.granger_latent_influence`;
- assumption-laden, not interventional)
+ assumption-laden, **non-interventional**)
+* Labeled synthetic SCM interventions
+ (:func:`~koopman_graph.analysis.teaching_three_node_scm`,
+ :func:`~koopman_graph.analysis.recover_synthetic_interventional_edges`).
+ Recovers a known do-edge on the teaching fixture only — not Granger
+ and not field-data discovery
* 0-dimensional persistence
(:func:`~koopman_graph.analysis.persistence_diagram_0d`; optional
``[tda]``) — not a TDA library
@@ -320,7 +528,8 @@ Analysis
* Dynamical similarity and anomaly utilities via
``koopman_graph.analysis``
* ``identify_sparse_dynamics`` (SINDy / STLSQ on learned latents — not
- physical governing equations)
+ physical governing equations; distinct from
+ :class:`~koopman_graph.baselines.GEDMDBaseline`)
* ``koopman_spectral_clustering`` (node communities from Koopman modes)
* ``estimate_coupling_from_snapshots`` (topology / coupling diagnostics)
* ``plot_spectrum`` for unit-disk / data-zoom views
@@ -332,12 +541,26 @@ Control, adaptation, and observation
online RLS updates
* ``KoopmanObserver`` for latent Kalman filtering / imputation under
``observation_masks``
+* ``JointStateTopologyObserver`` for Kalman filtering plus group-sparse
+ :math:`K_{\mathrm{self}}` / :math:`K_{\mathrm{nbr}}` write-back on the
+ observed COO (or dense RLS on per-node :math:`K`). Homomorphism
+ claims require :class:`~koopman_graph.nn.SeparableDictionaryEncoder`
+ and a one-tap graph operator (``Peng2026KoopmanGKFA``); default GNN
+ encoders raise when ``claim_homomorphism=True``. This is not
+ Koopman-GKFA, does not infer a new edge set, and is not on the root
+ façade.
* ``koopman_graph.env.GraphKoopmanEnv`` / ``to_latent_env`` for Gymnasium
closed-loop control
* ``koopman_graph.mpc.KoopmanMPC`` for additive-control receding-horizon
QP and bilinear sequential-linearization (``[mpc]`` / OSQP; local
decoder-linearization guarantees; optional conformal output-constraint
tightening)
+* ``koopman_graph.mpc.TubeKoopmanMPC`` for additive discrete residual-tube
+ tightening from conformal quantiles or ensemble radii
+ (``[mpc]`` / OSQP). ``evaluate`` reports constraint-violation rate,
+ feasibility rate, and cost — not tracking MSE alone, not recursive
+ feasibility, and not a Lyapunov closed-loop certificate
+ (``Zhang2022TubeMPC``)
* Hybrid physics observables: Laplacian, nodewise graph-gradient magnitude,
graph curvature (:math:`L_{\mathrm{sym}}^2 x`), polynomial dictionaries, or
custom lifting callables. Residual losses are soft penalties, not
@@ -384,7 +607,20 @@ Research tooling
* Classical baselines via ``koopman_graph.baselines``: ``DMDBaseline``,
``EDMDBaseline`` (polynomial / RBF / kernel dictionaries; kernel path is
:math:`O(T^2)` — small/medium ``T``; Nyström / random-feature
- approximations available), ``DMDcBaseline``, ``FBDMDBaseline``,
+ approximations available), ``MpEDMDBaseline`` (measure-preserving EDMD
+ via a Gram-weighted Procrustes polar factor; ``Colbrook2023mpEDMD``;
+ not a Euclidean :math:`K_{\\mathrm{eff}}` certificate),
+ ``GEDMDBaseline`` (generator EDMD from supplied
+ :math:`dx/dt`; ``Klus2020gEDMD``; not
+ derivative-mode SINDy; irregular timestamps do not create
+ :math:`L`),
+ ``HankelDMDBaseline`` (Hankel-DMD on delay-embedded flattened
+ states; ``Arbabi2017HankelDMD``; not
+ :class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder`),
+ ``HAVOKBaseline`` (teaching HAVOK on the same delay rows;
+ ``Brunton2017HAVOK``; autonomous ``predict`` uses
+ :math:`u=0`),
+ ``DMDcBaseline``, ``FBDMDBaseline``,
``TLSDMDBaseline``, ``OptDMDBaseline``, ``StreamingDMDBaseline``,
``MRDMDBaseline``, and ``UlamTransferOperatorBaseline``. Truncated-SVD
``rank`` accepts ``None``, a positive integer, or ``"auto"``
@@ -410,9 +646,12 @@ Research tooling
* Restricted portable export (:mod:`koopman_graph.export`; fixed-topology
discrete homogeneous MVP) and in-tree FedAvg
(:mod:`koopman_graph.federated`; not DP)
-* Graphon-sampled adjacency for transfer experiments
- (:func:`~koopman_graph.operators.sample_graphon_adjacency`;
- :doc:`graphon`)
+* Graphon-sampled adjacency and a dense teaching estimator
+ (:func:`~koopman_graph.operators.sample_graphon_adjacency`,
+ :func:`~koopman_graph.operators.estimate_graphon`;
+ :doc:`graphon`) — constant / product oracle recovery on aligned
+ graphs; not sparse-graph graphon theory and not a transferability
+ certificate
* Benchmark datasets and Jupyter tutorials under ``examples/``
* Model ``save`` / ``load`` checkpoints (default ``safetensors_v1``
directory or ``.kgckpt`` / ``.zip`` bundle; ``legacy_pt`` pickle escape
@@ -530,5 +769,13 @@ Related pages
* :doc:`data` — FAIR dataset cards (PEMS / contact epidemic / acquisition)
* :doc:`tutorials` — notebook gallery
* :doc:`architecture` — public vs power-user API layers
+* :doc:`benchmarks` — identity-bound manifests (not trained scores)
+* :doc:`identification` — opt-in closed-form :math:`K` versus Adam
+* :doc:`spectral_diagnostics` — Nyquist, :math:`\kappa(V)`, and aliasing
+* :doc:`graph_dynamics` — predicted topology versus hold-last
+* :doc:`matrix_free` — ``LinearOperatorProtocol`` (not trainer DDP)
+* :doc:`criticality` — spectral-gap monitor (not a certificate)
+* :doc:`time_conditioning` — :math:`(\mu, t, u)` records and
+ parametric :math:`K(\mu)`
* :doc:`api` — module reference
* Repository ``README.md`` — short landing page with a Related software niche map
diff --git a/docs/source/cli.rst b/docs/source/cli.rst
index 7fd7b03..d768181 100644
--- a/docs/source/cli.rst
+++ b/docs/source/cli.rst
@@ -2,10 +2,12 @@ Command-line interface
======================
The ``koopman-graph`` console script is a config-driven façade over the library
-``fit`` / ``predict`` / ``save`` / ``load`` path. It does not fork training
-mathematics. Prefer the Python API when you need custom encoders, distributed
+``fit`` / ``predict`` / ``save`` / ``load`` path, plus identity-bound
+``benchmark run`` / ``verify``. It does not fork training mathematics.
+Prefer the Python API when you need custom encoders, distributed
strategies, or experiment callbacks; use the CLI for reproducible train and
-predict smokes from JSON (or YAML) configs.
+predict smokes from JSON (or YAML) configs, and for hashing a frozen
+:class:`~koopman_graph.benchmark.ExperimentManifest`.
Install the package as usual (:doc:`installation`). The console script is then
available as ``koopman-graph``. YAML configs additionally need
@@ -28,6 +30,9 @@ Subcommands
| | ``--data``, roll out ``--steps``, write a ``.pt`` |
| | forecast payload. |
+-------------------+----------------------------------------------------------+
+| ``benchmark`` | Nested ``run`` / ``verify``: hash protocol identity. |
+| | Does not train a model or invent forecast metrics. |
++-------------------+----------------------------------------------------------+
Global flags include ``--help`` and ``--version``.
@@ -69,6 +74,55 @@ Predict
The forecast payload is a ``torch.save`` dict with keys ``steps``,
``forecasts`` (list of PyG ``Data``), and ``summary``.
+Benchmark
+~~~~~~~~~
+
+.. code-block:: bash
+
+ koopman-graph benchmark run \
+ --manifest /tmp/kg-bench/manifest.json \
+ --data /tmp/kg-bench/payload.bin \
+ --out /tmp/kg-bench/artifacts
+
+ koopman-graph benchmark verify \
+ --manifest /tmp/kg-bench/manifest.json \
+ --against /tmp/kg-bench/artifacts
+
+* ``run --manifest`` (required) — JSON always; ``.yaml`` / ``.yml`` require
+ ``pip install "koopman-graph[cli]"`` (PyYAML).
+* ``run --data`` (required) — dataset payload whose SHA-256 must match
+ ``dataset.sha256`` on the manifest.
+* ``run --out`` (required) — directory that receives ``summary.json``.
+* ``verify --against`` (required) — that directory, or the ``summary.json``
+ file itself.
+
+``run`` writes schema ``benchmark_summary_v1`` with ``executed=False``
+and a canonical SHA-256 of the identity fields (UTF-8 JSON,
+``sort_keys=True``, compact separators, ``summary_sha256`` omitted from
+the digest). It does **not** fit
+:class:`~koopman_graph.model.GraphKoopmanModel` or GNN teaching ports
+and does not invent MAE / RMSE / MAPE numbers. ``verify`` recomputes
+the digest and binds the summary to the loaded manifest; a tampered
+hash or identity field fails with exit code 1.
+
+``koopman-graph benchmark --help`` lists ``run`` and ``verify``. Bare
+``benchmark`` (no nested command) prints that help and exits 0.
+
+Tracked smoke fixtures live under ``benchmarks/v0.15/`` (three tracks:
+telemetry-like, multiphysics, topology transfer). Payloads are tiny
+hashed UTF-8 stand-ins, not METR-LA HDF5. YAML needs
+``pip install "koopman-graph[cli]"``. Example:
+
+.. code-block:: bash
+
+ koopman-graph benchmark verify \
+ --manifest benchmarks/v0.15/smoke_telemetry.yaml \
+ --against benchmarks/v0.15/summaries/smoke_telemetry.json
+
+Default CI runs that verify on all three stubs. It does not download
+full telemetry or invent forecast metrics. See :doc:`benchmarks` and
+the walkthrough ``examples/47_benchmark_manifest.ipynb``.
+
Exit codes
----------
diff --git a/docs/source/criticality.rst b/docs/source/criticality.rst
new file mode 100644
index 0000000..f20fb42
--- /dev/null
+++ b/docs/source/criticality.rst
@@ -0,0 +1,66 @@
+Spectral-gap criticality monitor
+================================
+
+:func:`~koopman_graph.analysis.monitor_critical_transition` scores a
+**sequence** of already-computed
+:class:`~koopman_graph.spectrum_types.KoopmanSpectrum` objects. A
+positive windowed rate means the closest-eigenvalue gap shrank. That
+heuristic is **not** a topology-criticality certificate and not an
+infrastructure early-warning score.
+
+What ships
+----------
+
+For each spectrum the helper records
+
+.. math::
+
+ \gamma_t = \min_{i\neq j}\lvert\lambda_i(t)-\lambda_j(t)\rvert
+
+and the windowed closure rate
+:math:`(\gamma_{t-w+1}-\gamma_t)/(\tau_t-\tau_{t-w+1})`.
+:class:`~koopman_graph.analysis.CriticalityReport` stores the gap
+trajectory, the rate series, a near-defectivity flag
+(:math:`\kappa(V)` non-finite or above
+:data:`~koopman_graph.spectrum_types.CONDITION_WARN`), and
+``max_gap_closure_rate`` (the scalar “score”).
+
+Near-defectivity is a flag on the eigenbasis, not a Schur form and
+not :class:`~koopman_graph.spectrum_types.DefectiveSpectrumError`.
+The monitor does not raise on a singular stored basis; it sets the
+flag.
+
+This helper does **not** implement Ghosh, *Intelligent Systems with
+Applications*, 2025 (DOI
+`10.1016/j.iswa.2025.200575 `_;
+``Ghosh2025``). That paper is related literature and the honesty
+ceiling.
+
+How to use it
+-------------
+
+.. code-block:: python
+
+ from koopman_graph.analysis import monitor_critical_transition
+ from koopman_graph.spectrum_types import compute_spectrum
+
+ spectra = [compute_spectrum(k_t, time_step=1.0) for k_t in operators]
+ report = monitor_critical_transition(spectra, window=5)
+ score = report.max_gap_closure_rate # eigenvalue units / step
+
+``examples/54_criticality_monitor.ipynb`` closes a two-eigenvalue gap
+on a diagonal toy and shows the score rising versus a constant-gap
+control. It is not a certificate.
+
+Ceilings
+--------
+
+* Callers supply spectra. The helper does not fit a model or infer
+ edges.
+* Closest-pair distance need not be adjacent after magnitude sort.
+* This is not
+ :meth:`~koopman_graph.operators.KoopmanOperator.stability_certificate`
+ and not :func:`~koopman_graph.analysis.resolvent_norm_grid`.
+* Types stay on ``analysis.__all__``, off the root façade.
+
+See :doc:`spectral_diagnostics`, :doc:`limitations`, and :doc:`api`.
diff --git a/docs/source/data.rst b/docs/source/data.rst
index 849a4b8..0e64890 100644
--- a/docs/source/data.rst
+++ b/docs/source/data.rst
@@ -209,15 +209,63 @@ them:
peer). Inactive rows stay at capacity :math:`N_{\max}` (padded zeros is
conventional); losses ignore inactive nodes; operator matvecs still run
at full capacity. Opt-in
- :func:`~koopman_graph.data.remap_node_features` grows a larger union
+ :func:`~koopman_graph.data.remap_node_features` /
+ :class:`~koopman_graph.data.EntityRemap` grow a larger union
from a user-supplied injective map; it is **not** entity resolution
- (see :doc:`limitations`).
+ (see :doc:`limitations`). Changing :math:`N` across snapshots without
+ that remap is refused.
Default sequences have neither stack (fully present and fully observed).
Observation masks alone do **not** model entity drop-in/out; presence masks
alone do **not** mark measurement gaps. See
``examples/41_node_churn_presence_masks.ipynb``.
+Regime parameters versus control
+--------------------------------
+
+Homogeneous :class:`~koopman_graph.data.GraphSnapshotSequence` may carry
+an optional ``parameter_trajectory`` with shape :math:`(T, d_\\mu)`.
+Units are caller-defined (dimensionless if unspecified).
+:func:`~koopman_graph.data.conditioning_at` returns a frozen
+:class:`~koopman_graph.data.ConditioningContext` with time (caller
+timestamp unit), :math:`\\mu`, and the existing control layout. This is
+a data record. It does not select ``koopman="switched"`` or
+``"mixture"``. Opt-in ``koopman="parametric"`` evaluates
+:math:`K(\\mu)` from the stored coordinates
+(``Macesic2018Nonautonomous``). Heterogeneous sequences do not store
+parameters. Discrete uniform-:math:`\\Delta t` validation is unchanged.
+
+Time-of-day helpers are recipes on those existing fields, not a
+calendar serializer and not a checkpoint key.
+:func:`~koopman_graph.data.diurnal_control_features` returns Fourier
+sine/cosine columns in the same unit as the timestamps and
+``period``; pass them as ``control_inputs``.
+:func:`~koopman_graph.data.diurnal_phase_index` bins timestamps for a
+per-step switched ``phase_index``. Discrete ``fit`` / ``predict_at``
+still reject non-uniform increments. Use
+``dynamics_mode="continuous"`` for irregular sampling, or supply
+derivatives to :class:`~koopman_graph.baselines.GEDMDBaseline`
+(``Klus2020gEDMD``); irregular timestamps do not create :math:`L`.
+
+Protocol-locked smoke fixtures
+------------------------------
+
+Tracked YAML manifests under ``benchmarks/v0.15/`` cover three tracks
+(telemetry-like, multiphysics, topology transfer) with hashed UTF-8
+payloads, FAIR cards, and identity-bound ``summary.json`` files.
+Default CI runs ``koopman-graph benchmark verify`` on those stubs. It
+does **not** download METR-LA / PEMS HDF5, train a model, or invent
+MAE / RMSE. Cards:
+
+* ``benchmarks/v0.15/cards/smoke_telemetry.md`` — METR-LA/PEMS-style
+ split stand-in (12-step history, 0.7 / 0.1 / 0.2, z-score)
+* ``benchmarks/v0.15/cards/smoke_multiphysics.md`` — path-graph
+ Laplacian diffusion stand-in
+* ``benchmarks/v0.15/cards/smoke_topology_transfer.md`` — unseen-\(N\)
+ / rewire stand-in with ``hold_last``, ``pernode``, and ``joint_ls``
+
+See :doc:`cli` and :doc:`benchmarks` for ``run`` / ``verify``.
+
Related pages
-------------
@@ -226,3 +274,6 @@ Related pages
* :doc:`tutorials` — notebook gallery (including molecular / churn demos)
* :doc:`api` — dataset module reference
* :doc:`architecture` — simulated vs real-telemetry factory idioms
+* :doc:`benchmarks` — identity-bound manifests (not trained scores)
+* :doc:`time_conditioning` — :math:`(\\mu, t, u)` records and calendar recipes
+* :doc:`graph_dynamics` — predicted topology and fixed-union remap
diff --git a/docs/source/faq.rst b/docs/source/faq.rst
index 19bf19a..59a3ed8 100644
--- a/docs/source/faq.rst
+++ b/docs/source/faq.rst
@@ -82,9 +82,9 @@ Optional feature extras
pip install "koopman-graph[baselines-graphcast]" # reserved; teaching GraphCast is pure PyTorch
pip install "koopman-graph[distributed]" # meta: lightning + ray + dask
-* **MPC:** ``from koopman_graph.mpc import KoopmanMPC``. Construction works
- without OSQP; ``solve`` / ``rollout`` raise with install guidance if OSQP
- is missing.
+* **MPC:** ``from koopman_graph.mpc import KoopmanMPC, TubeKoopmanMPC``.
+ Construction works without OSQP; ``solve`` / ``rollout`` /
+ ``evaluate`` raise with install guidance if OSQP is missing.
* **Symmetry:** ``koopman_auto_orbits=True`` uses ``networkx``
(``method="auto"``). Without ``[symmetry]``, ``node_orbit_partition``
warns and returns the identity partition (no tying). Exact orbits /
@@ -132,7 +132,7 @@ aliases), for example:
ConformalKoopmanUQ,
EnsembleGraphKoopmanModel,
)
- from koopman_graph.mpc import KoopmanMPC
+ from koopman_graph.mpc import KoopmanMPC, TubeKoopmanMPC, TubeMPCReport
from koopman_graph.nn import AdaptiveAdjacency
from koopman_graph.analysis import (
identify_sparse_dynamics,
@@ -170,6 +170,24 @@ incidence). Self-adaptive topology and orbit-tied ``K_{\mathrm{self}}`` are
separate options and are not substitutes for directed normalization. See
:doc:`architecture` (adjacency contract) and :doc:`limitations`.
+Can I train on several graphs or topologies?
+--------------------------------------------
+
+Yes. Pass a :class:`~koopman_graph.data.MultiTrajectory` of homogeneous
+(or all-hetero) sequences to :meth:`~koopman_graph.model.GraphKoopmanModel.fit`.
+That path already averages per-trajectory losses; it is not a
+single-topology restriction.
+
+``fit(..., batch_graphs=True)`` is an opt-in **vectorization** of that
+loop: independent graphs are collated into one PyG ``Batch`` so shared
+:math:`K` applies to the disconnected union with per-graph shifts.
+Reconstruction (and forward consistency when weighted) match the mean of
+the per-sequence losses on modest batches. The default Python loop is
+unchanged. Hetero, hypergraph, windowed, and DDP graph-batching are out
+of scope. This flag does not enable multi-topology training for the
+first time, and this page does not quote a throughput number.
+See :doc:`architecture` (multi-graph ``Batch``).
+
Why is training slow on large :math:`N`?
----------------------------------------
@@ -192,8 +210,8 @@ supports are cached within an evaluation:
Shared pair latents, inverse / support / :math:`\hat{H}` / :math:`\Phi`
reuse, and PDE/worst-case prediction sharing reduce repeated work; they
-do not change those representation sizes. See :doc:`limitations` (Scale)
-and :doc:`capabilities` (Training performance).
+do not change those representation sizes. See :doc:`limitations` (Scale),
+:doc:`matrix_free`, and :doc:`capabilities` (Training performance).
Does spectrum still assemble :math:`N\cdot d`?
----------------------------------------------
@@ -208,7 +226,10 @@ Not always for discrete ``GraphKoopmanOperator`` /
``{"symmetric", "random_walk"}``; ``sparsity`` in
``{"dense", "block_diagonal"}``) — Kronecker-sum exact spectrum
(order :math:`O(N^3 + N d^3)` via dense :math:`N\times N`
- :math:`\widehat{A}` plus :math:`N` blocks of size :math:`d\times d`)
+ :math:`\widehat{A}` plus :math:`N` blocks of size :math:`d\times d`).
+ Discrete hop degree :math:`P\ge 0` is eligible; the pencil is
+ :math:`B(\lambda)=\sum_k\lambda^k K_k`, not a sum of independent
+ factor eigenvalues. Continuous graph stays the one-tap generator.
* Else — dense :math:`(N\cdot d)` eigendecomposition
(``dual_random_walk``, discrete orbit / isotypic self banks, hetero /
hypergraph, helper fall-back)
@@ -217,6 +238,116 @@ Exact inverse and eigenvalue-regularization hinges are unchanged (still
dense assembled ceilings where documented). Details:
:doc:`limitations` (Scale) and :doc:`architecture` (spectrum routing).
+Does a deeper GNN encoder make up for one-hop :math:`K`?
+--------------------------------------------------------
+
+No. Encoder neighborhood mixing and the Koopman factor hop degree are
+different maps. :meth:`~koopman_graph.model.GraphKoopmanModel.fit`
+warns when encoder hops exceed discrete graph ``filter_degree``
+(GCN/GAT depth, or DiffConv ``num_layers * diffusion_steps``). Raise
+``koopman_filter_degree`` or reduce encoder depth if you want them
+matched. The check does not fail training and skips operators without
+a hop radius. See :doc:`limitations`,
+``examples/38_operator_factorization_ablation.ipynb`` (hop-matched
+:math:`P` arm; not a rewrite of the historical joint-LS gap), and
+``examples/49_multi_hop_factorization.ipynb`` (Kronecker versus dense
+:math:`P=2` spectrum).
+
+Does identification replace Adam ``fit``?
+-----------------------------------------
+
+No. :meth:`~koopman_graph.model.GraphKoopmanModel.fit` still defaults to
+Adam (``identification=None``). Pass
+:class:`~koopman_graph.identification.IdentificationConfig` to alternate
+frozen-encoder closed-form :math:`K` updates (ridge, TLS, or
+constrained least squares) with encoder/decoder Adam steps. That path
+currently supports discrete dense per-node
+:class:`~koopman_graph.operators.KoopmanOperator` only. After fit, read
+``model.identification_report`` for latent one-step / short-rollout
+mean squared error (MSE) and :math:`\rho(K)` — not a Haseli–Cortés,
+ResDMD, or stability certificate. Types and solvers stay off the root
+façade (``from koopman_graph.identification import ...``). Tutorials
+continue to use Adam unless they opt in. See :doc:`identification`
+and ``examples/48_identification_invariance.ipynb``.
+
+Is ``evaluate(..., include_invariance=True)`` a Haseli–Cortés certificate?
+--------------------------------------------------------------------------
+
+No. Opt-in ``include_invariance`` (and
+:meth:`~koopman_graph.model.GraphKoopmanModel.subspace_invariance_report`)
+reports a dimensionless finite-sample projection leakage
+:math:`\eta` on a truncated-SVD basis of encoded snapshots. Default
+evaluate MAE / RMSE / MAPE are unchanged. The helper currently
+supports discrete dense per-node
+:class:`~koopman_graph.operators.KoopmanOperator` only. It is **not**
+the Haseli–Cortés invariance-proximity certificate (principal angles /
+worst-case bound; ``HaseliCortes2023``), **not**
+:class:`~koopman_graph.losses.ForwardConsistencyLoss`, and **not**
+:func:`~koopman_graph.analysis.spectral_residuals`. Closed-form
+``fit`` still does not fill ``IdentificationReport.invariance``.
+
+Does residual-aware selection certify a ResDMD spectral measure?
+----------------------------------------------------------------
+
+No. :func:`~koopman_graph.identification.select_resdmd_gated` compares
+already-scored dictionaries: lowest train one-step mean squared error
+(MSE) wins, unless ``gate_resdmd=True`` first drops candidates whose
+max finite-dictionary ResDMD residual exceeds the default cutoff
+:math:`10^{-2}` (same as
+:func:`~koopman_graph.analysis.resdmd`).
+``IdentificationConfig.gate_resdmd=True`` fills
+``IdentificationReport.spectral`` on the final identification ``fit``;
+it does not abort training.
+:class:`~koopman_graph.training.ResDMDFitCallback` defaults to
+``mode="observe"``. ``mode="gate"`` raises at ``on_fit_end`` when the
+observed max residual exceeds that cutoff, without mutating parameters.
+None of these is an infinite-dimensional residual certificate.
+
+Does ``identify_sparse_graph_factors`` replace SINDy or L1 training?
+--------------------------------------------------------------------
+
+No. :func:`~koopman_graph.identification.identify_sparse_graph_factors`
+fits shared :math:`K_{\mathrm{self}}` / :math:`K_{\mathrm{nbr}}` on
+frozen encodings (STLSQ or a teaching proximal group-lasso, then an
+unpenalized refit). It is not
+:func:`~koopman_graph.analysis.identify_sparse_dynamics` (polynomial /
+graph library on learned latents) and not
+:class:`~koopman_graph.losses.KoopmanSparsityLoss` (soft training
+penalty on operator entries). Those tools still ship. Dual
+random-walk and polynomial :math:`P>1` hops are out of scope. Related
+sparse-Koopman literature: Pan, Arnold-Medabalimi, and Duraisamy
+(*J. Fluid Mech.*, 2021; ``Pan2021SparseSubspace``). The identifier is
+not that paper's multi-task EDMD dictionary pruning.
+
+Does ``select_latent_rank`` replace Ray Tune for ``latent_dim``?
+----------------------------------------------------------------
+
+No. :func:`~koopman_graph.identification.select_latent_rank` scores a
+truncated-SVD grid of **frozen** encodings (in-tree VAMP-2, a
+finite-dictionary ResDMD residual elbow, or stability-penalized
+held-out one-step mean squared error). It does not train an encoder
+per candidate and does not choose
+:class:`~koopman_graph.model.GraphKoopmanModel` ``latent_dim``.
+:mod:`koopman_graph.tuning` Ray Tune helpers remain caller-owned
+example scaffolds; KoopmanGraph is not an AutoML product. deeptime
+(``[msm]``; ``deeptime2021``) is an optional VAMP-2 cross-check, not a
+runtime requirement. See :doc:`identification` and
+``examples/53_latent_rank_selection.ipynb``.
+
+Does ``koopman-graph benchmark run`` train a model?
+---------------------------------------------------
+
+No. ``koopman-graph benchmark run --manifest … --data … --out …``
+verifies the dataset SHA-256 against a frozen
+:class:`~koopman_graph.benchmark.ExperimentManifest` and writes
+identity-bound ``summary.json`` (canonical digest;
+``executed=False``). ``verify`` recomputes that digest and fails on a
+tampered hash. Neither command fits
+:class:`~koopman_graph.model.GraphKoopmanModel`, downloads METR-LA, or
+hosts a LibCity / BasicTS leaderboard. Default CI verifies hashed
+stand-ins under ``benchmarks/v0.15/`` (see :doc:`cli` and
+:doc:`benchmarks`); it does not download full telemetry.
+
When should I use ``sparsity="block_diagonal"``?
------------------------------------------------
@@ -341,6 +472,10 @@ Is trainer “distributed” the same as ``sparsity="distributed"``?
matrix-free inverse and Arnoldi spectrum on discrete graph and multiplex
hetero constructors (hypergraph / continuous peers may still assemble).
It does **not** enable multi-GPU training.
+ :class:`~koopman_graph.operators.LinearOperatorProtocol` is the same
+ operator-math surface (polynomial graph + one-tap ``matrix_free``).
+ Trainer DDP does **not** shrink
+ :data:`~koopman_graph.operators.MAX_DENSE_LINEAR_OPERATOR_SIZE`.
Can I use Dask with KoopmanGraph?
---------------------------------
@@ -411,6 +546,213 @@ They answer different questions:
infinite-dimensional certified pseudospectra / spectral measures.
See ``examples/40_resdmd_pseudospectra.ipynb`` and :doc:`limitations`.
+Does ``MpEDMDBaseline`` replace Euclidean EDMD or spectral conditioning?
+------------------------------------------------------------------------
+
+No. :class:`~koopman_graph.baselines.MpEDMDBaseline` is measure-preserving
+EDMD (Colbrook, *SIAM J. Numer. Anal.*, 2023; ``Colbrook2023mpEDMD``):
+a Gram-weighted orthogonal Procrustes polar factor of the dictionary
+map. Unitarity is in that Gram inner product. On a regular polygonal
+planar rotation (identity dictionary; empirical Gram a multiple of the
+identity), mpEDMD matches :class:`~koopman_graph.baselines.EDMDBaseline`;
+on a contraction it does **not** recover the dissipative map
+(eigenvalues stay on the unit circle). It does not obsolete Euclidean
+conditioning diagnostics on a general directed
+:math:`K_{\mathrm{eff}}`. Use
+:func:`~koopman_graph.metrics.evaluate_forecast` with the same Data-only
+``predict`` call site as EDMD. Euclidean conditioning on a general
+directed :math:`K` is :doc:`spectral_diagnostics`.
+
+Does ``GEDMDBaseline`` infer a generator from irregular timestamps?
+-------------------------------------------------------------------
+
+No. :class:`~koopman_graph.baselines.GEDMDBaseline` is generator EDMD
+(Klus et al., *Physica D*, 2020; ``Klus2020gEDMD``): least squares of
+:math:`\dot\psi \approx \psi L^{\top}` on a polynomial dictionary.
+Callers must supply :math:`dx/dt` as ``Data.dx_dt``
+or ``fit(..., derivatives=)``. Discrete neural ``fit`` still rejects
+non-uniform :math:`\Delta t`. Irregular timestamps on the gEDMD
+sequence are unused and do not create :math:`L`. This is not
+:func:`~koopman_graph.analysis.identify_sparse_dynamics` (SINDy / STLSQ
+on learned latents, including ``mode="derivative"``). ``predict``
+advances by :math:`\exp(L\,\Delta t)` with fitted ``time_step``;
+:func:`~koopman_graph.metrics.evaluate_forecast` uses the same Data-only
+call site as EDMD.
+
+Does SpectralDiagnostics certify a finite-horizon bound?
+--------------------------------------------------------
+
+No. :class:`~koopman_graph.spectrum_types.SpectralDiagnostics` reports
+:math:`\kappa(V)`, Wilkinson :math:`\kappa_i`, departure from
+normality, discrete Nyquist :math:`1/(2\Delta t)` in cycles per unit
+time, and per-mode aliasing flags. Discrete
+:func:`~koopman_graph.spectrum_types.compute_spectrum` warns when a
+mode is Nyquist-adjacent.
+:meth:`~koopman_graph.spectrum_types.KoopmanSpectrum.mode_amplitudes`
+warns when :math:`\kappa(V)` exceeds
+:data:`~koopman_graph.spectrum_types.CONDITION_WARN` (:math:`10^{6}`)
+and still solves :math:`Va=z^{\top}`. None of these is a bound on
+:math:`\|K^{k}\|`. See :doc:`spectral_diagnostics` and
+``examples/51_spectral_diagnostics.ipynb``.
+
+Does ``monitor_critical_transition`` certify a critical transition?
+-------------------------------------------------------------------
+
+No. :func:`~koopman_graph.analysis.monitor_critical_transition` is a
+sliding-window spectral-gap heuristic. A positive rate means the
+closest-eigenvalue gap shrank. It is not a Ghosh-grade
+topology-criticality certificate (``Ghosh2025``) and not
+:meth:`~koopman_graph.operators.KoopmanOperator.stability_certificate`.
+See :doc:`criticality` and ``examples/54_criticality_monitor.ipynb``.
+
+Does LinearOperatorProtocol replace Kronecker spectrum or DDP?
+--------------------------------------------------------------
+
+No. :class:`~koopman_graph.operators.LinearOperatorProtocol` is
+``matvec`` / ``solve`` / Arnoldi algebra without assembling
+:math:`K_{\mathrm{eff}}`. Leading eigpairs are Ritz values, not
+:math:`\operatorname{eig}(B(\lambda))`. Trainer DDP does not shrink
+the representation. Dense assembly is refused above
+:data:`~koopman_graph.operators.MAX_DENSE_LINEAR_OPERATOR_SIZE`.
+See :doc:`matrix_free`.
+
+Does GraphDynamicsConfig close the topology loop by default?
+------------------------------------------------------------
+
+No. Default ``graph_dynamics=None`` keeps the 0.14 hold-last path.
+Pass :class:`~koopman_graph.data.GraphDynamicsConfig` to attach a
+topology head (default ``sparse_candidate``). Recursive prediction
+is opt-in and mutually exclusive with
+``learn_topology="self_adaptive"`` when the head is not ``none``.
+See :doc:`graph_dynamics` and
+``examples/50_graph_state_closure.ipynb`` (wiring check, not a
+learned-forecast claim).
+
+Can discrete neural ``fit`` use irregular :math:`\\Delta t`?
+------------------------------------------------------------
+
+No. Discrete ``fit`` and ``predict_at`` still require a uniform
+increment equal to ``time_step``. Gaps raise
+(``validate_uniform_discrete_increments``). Set
+``dynamics_mode="continuous"`` so ``predict_at`` integrates a
+generator over the supplied intervals. Generator EDMD is a different
+escape hatch and **requires supplied derivatives**; irregular
+timestamps on that sequence do not create :math:`L`
+(``Klus2020gEDMD``). See :doc:`time_conditioning` and
+``examples/12_irregular_sampling_continuous_time.ipynb``.
+
+How do I encode time of day?
+----------------------------
+
+:func:`~koopman_graph.data.diurnal_control_features` returns Fourier
+sine/cosine columns for existing additive / bilinear
+``control_inputs``. :func:`~koopman_graph.data.diurnal_phase_index`
+bins timestamps for a per-step ``phase_index`` on
+``koopman="switched"``. These are recipes, not a native calendar
+field or checkpoint key. Discrete uniform-:math:`\\Delta t`
+validation is unchanged. Heterogeneous sequences have no calendar
+helper. See :doc:`time_conditioning`.
+
+What does example 22 report for GraphKoopman versus the GNN ports?
+------------------------------------------------------------------
+
+Saved METR-LA weekday-cache output ranks GraphKoopman first on
+aggregate RMSE (z-scored speed): :math:`0.6551` versus STGCN
+:math:`0.7076`, DCRNN :math:`1.0754`, and Graph WaveNet
+:math:`0.9036`. GraphKoopman uses a longer ODO / rollout /
+early-stopping budget than the GNN teaching refs (unequal budgets).
+These are in-repo teaching baselines, not dedicated-library SOTA.
+See ``examples/22_gnn_forecaster_comparison.ipynb``.
+
+Does ``CochainKoopmanOperator`` replace ``koopman="hodge"`` or TopologicX?
+--------------------------------------------------------------------------
+
+No. :class:`~koopman_graph.operators.CochainKoopmanOperator` advances
+node and edge latents on a static signed :math:`B_1`. It is not a
+factory kind; ``koopman=None`` stays ``"pernode"``.
+``koopman="hodge"`` is a node Laplacian neighbor term
+(:class:`~koopman_graph.operators.HodgeKoopmanOperator`). Face latents
+may be stored; :math:`k=2` is not evolved.
+:func:`~koopman_graph.operators.boundary_nilpotency` flags
+:math:`B_1 B_2\\approx 0`. This is not sheaf theory and not
+TopologicX parity (``Lim2020Hodge``, ``TopoX2024``).
+
+Is the order-2 teaching path TopologicX or TDA parity?
+------------------------------------------------------
+
+No. :func:`~koopman_graph.nn.order2_cochain_teaching` binds
+:class:`~koopman_graph.operators.CochainKoopmanOperator` to a filled
+triangle and scores :math:`B_1 B_2\\approx 0`. Face latents may be
+stored; :math:`k=2` is not evolved. Optional tetrahedra reach
+:data:`~koopman_graph.nn.MAX_CELL_COMPLEX_DEGREE` (3). Sheaf
+restriction maps stay learned-optional (default diagonal). This is
+not TopologicX or TDA ecosystem parity (``TopoX2024``).
+
+Are Hodge mode components physical circulation?
+-----------------------------------------------
+
+No. :func:`~koopman_graph.analysis.hodge_decompose_modes` projects
+stored eigenvector columns onto the combinatorial gradient / curl /
+harmonic subspaces of a static signed :math:`B_1`
+(``Lim2020Hodge``). On a consistently oriented cycle the constant
+1-cochain is harmonic; that algebraic kernel is not a validated
+fluid or electrical current. The helper is analysis-only, not a
+factory kind, not ``koopman="hodge"``, and not TopologicX / sheaf
+parity (``TopoX2024``).
+
+Is ``dynamics_mode="stochastic"`` a continuous-time SDE?
+--------------------------------------------------------
+
+No. That factory string adds learned diagonal process noise after a
+discrete linear map. :class:`~koopman_graph.operators.DriftDiffusionKoopman`
+is a separate Euler–Maruyama / Yosida stepper: ``forward`` is the
+conditional-expectation semigroup and ``advance`` samples a path. It
+is not certified Itô theory and not SDMD
+(``Xu2025StochasticSemigroup``, ``Zhou2025Yosida``).
+
+What coverage does conformal UQ claim?
+--------------------------------------
+
+:attr:`~koopman_graph.uq.ConformalKoopmanUQ.coverage` names
+:class:`~koopman_graph.uq.JointCoverageSpec`
+``target="per_node_marginal"``. That is frequentist marginal coverage
+under exchangeability, approximate on graph time series. Simultaneous
+node–feature–horizon boxes and event coverage are named but not
+implemented (``Schlembach2025Conformal``). Proper scores
+(:func:`~koopman_graph.uq.gaussian_crps`,
+:func:`~koopman_graph.uq.gaussian_nll`,
+:func:`~koopman_graph.uq.energy_score`) evaluate forecasts; they do
+not certify coverage.
+
+Does Hankel-DMD or HAVOK replace ``DelayEmbeddingEncoder``?
+-----------------------------------------------------------
+
+No. :class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder` stacks
+Takens-style channels around a sized GNN encoder.
+:class:`~koopman_graph.baselines.HankelDMDBaseline` (Arbabi and Mezić,
+*SIAM J. Appl. Dyn. Syst.*, 2017; ``Arbabi2017HankelDMD``) and
+:class:`~koopman_graph.baselines.HAVOKBaseline` (Brunton et al.,
+*Nature Communications*, 2017; ``Brunton2017HAVOK``) fit operators on
+delay-embedded flattened snapshots. HAVOK ``predict`` is autonomous
+(:math:`u=0`). Optional ``history`` supplies older delay slots
+(oldest → newest); without it, those slots are zeros, so
+``predict(data, steps)`` is not a faithful delay initial condition
+when ``n_delays > 1``.
+
+Is delay embedding or HAVOK a Mori–Zwanzig memory model?
+--------------------------------------------------------
+
+No. :class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder` stacks
+Takens-style channels. :class:`~koopman_graph.baselines.HAVOKBaseline`
+is delay-plus-forcing (``Brunton2017HAVOK``), not a projection-operator
+memory kernel. :func:`~koopman_graph.analysis.markov_closure_report`
+flags residual-energy autocorrelation (Ljung–Box-style;
+``Ljung1978Box``). :class:`~koopman_graph.analysis.FiniteMemoryKoopman`
+is a convolution MVP at the same latent width as a delay encoder, not
+a factory kind and not Mori–Zwanzig identification
+(``Lin2021MoriZwanzig``). Recovered memory length is an oracle test,
+not a general theorem.
+
Does ``InvariantGeometryEncoder`` make the Koopman operator equivariant?
------------------------------------------------------------------------
@@ -419,10 +761,49 @@ Does ``InvariantGeometryEncoder`` make the Koopman operator equivariant?
rotation-/translation-invariant features from ``Data.pos`` and lifts them
with a standard GCN. Optional Tier B
:class:`~koopman_graph.nn.E3EquivariantEncoder` (``e3nn``,
-``[equivariance]``) uses steerable message passing but still emits
-**invariant scalar latents** for the ordinary linear map :math:`K`.
-Neither path makes :math:`K` itself E(n)/SE(3) equivariant (see
-:doc:`limitations`).
+``[equivariance]``) uses steerable message passing but still **defaults**
+to invariant scalar latents. A separate
+:class:`~koopman_graph.operators.EquivariantKoopmanOperator` is a
+block MVP: scalars, ``scale * I_3`` vectors, and optional
+:math:`l=2` ``scale * I_5`` tensors. It is not a factory kind
+and not a molecular MD production stack (see :doc:`limitations`).
+
+Does symplectic :math:`K` conserve decoded mass?
+------------------------------------------------
+
+**No.** ``parameterization="symplectic"`` constrains the latent
+operator matrix. A nonlinear decoder can still break mass in
+feature space (``Greydanus2019HNN``). Use
+:class:`~koopman_graph.nn.MassConservingDecoder` or
+:class:`~koopman_graph.nn.LinearConservingDecoder` when a named
+decoded channel must satisfy a linear conservation law. Those
+heads do not turn IEEE-118 Laplacian diffusion into AC power
+flow.
+
+Does TubeKoopmanMPC prove recursive feasibility?
+------------------------------------------------
+
+**No.** :class:`~koopman_graph.mpc.TubeKoopmanMPC` erodes nominal
+output boxes by conformal quantiles or ensemble residual radii
+and reports constraint-violation rate, feasibility rate, and
+quadratic stage cost on a toy closed loop. Local decoder
+linearization is unchanged. The helper is not a chance-constraint
+solver and not a Lyapunov closed-loop certificate. Zhang et al.,
+*Automatica* 137:110114 (2022), prove robustness for an r-KMPC
+scheme with an offline nonlinear ancillary law; this MVP does not
+implement that controller or inherit those proofs
+(``Zhang2022TubeMPC``).
+
+Does ``granger_latent_influence`` recover interventional edges?
+---------------------------------------------------------------
+
+**No.** :func:`~koopman_graph.analysis.granger_latent_influence`
+is **non-interventional**: it reports linear residual-MSE
+reduction on observed latents. The labeled synthetic helper
+:func:`~koopman_graph.analysis.recover_synthetic_interventional_edges`
+recovers a known do-edge on
+:func:`~koopman_graph.analysis.teaching_three_node_scm` only. That
+protocol is not observational discovery on field data.
Does ``evaluate_topology_transfer`` mean factorization transfers well?
----------------------------------------------------------------------
diff --git a/docs/source/graph_dynamics.rst b/docs/source/graph_dynamics.rst
new file mode 100644
index 0000000..7d2fdc2
--- /dev/null
+++ b/docs/source/graph_dynamics.rst
@@ -0,0 +1,73 @@
+Predicted graph dynamics
+========================
+
+Default ``graph_dynamics=None`` keeps the 0.14 encode / decode /
+advance path: omitted future edges **hold last**. Opt-in
+:class:`~koopman_graph.data.GraphDynamicsConfig` attaches a topology
+head so a recursive loop can consume
+:math:`\widehat{A}_{t+1}=g_{\phi}(z_t)`.
+
+What ships
+----------
+
+When a config is passed, ``topology_head`` defaults to
+``sparse_candidate`` (at most ``candidate_k`` destinations per node;
+default 8) via
+:class:`~koopman_graph.nn.SparseCandidateTopologyHead`.
+``dense_mlp`` keeps
+:class:`~koopman_graph.nn.PredictedTopologyHead` with an :math:`N`
+ceiling of 64. The two heads are distinct from
+``learn_topology="self_adaptive"`` /
+:class:`~koopman_graph.nn.AdaptiveAdjacency` (static Graph WaveNet)
+and cannot be combined when ``topology_head`` is not ``none``.
+
+If a head is attached and ``recursive_training`` is true,
+``fit`` / ``predict`` / ``evaluate`` / UQ use predicted sigmoid
+weights on the candidate COO unless ``future_topologies`` or
+``topology_policy="hold_last"`` is set. Evaluate on dynamic sequences
+injects oracle futures only when that recursive path is off.
+
+:class:`~koopman_graph.data.GraphStateSnapshot` is a supervision
+record (features, edges, presence). It does not replace
+:class:`~koopman_graph.data.GraphSnapshotSequence`. Changing node
+count still requires :class:`~koopman_graph.data.EntityRemap` into a
+finite :math:`N_{\max}` (injective placement; not automatic entity
+resolution; no unbounded growth).
+
+How to use it
+-------------
+
+.. code-block:: python
+
+ from koopman_graph import GNNDecoder, GNNEncoder, GraphKoopmanModel
+ from koopman_graph.data import GraphDynamicsConfig
+
+ config = GraphDynamicsConfig(
+ topology_head="sparse_candidate",
+ recursive_training=True,
+ candidate_k=8,
+ )
+ model = GraphKoopmanModel(
+ encoder=GNNEncoder(3, 16, 8),
+ decoder=GNNDecoder(8, 16, 3),
+ latent_dim=8,
+ graph_dynamics=config,
+ )
+
+``examples/50_graph_state_closure.ipynb`` is a wiring check versus
+hold-last on a synthetic structural event. It is **not** a
+learned-forecast claim.
+
+Ceilings
+--------
+
+* Default ``graph_dynamics=None`` is unchanged 0.14 behavior.
+* Dense logits have an :math:`N\le 64` ceiling. Sparse-candidate is
+ the default for a reason.
+* Presence BCE uses a linear per-node head when masks exist. Losses
+ ignore inactive nodes; matvecs stay at capacity.
+* Additive checkpoint key ``graph_dynamics``; ``FORMAT_VERSION``
+ remains 1.
+
+See :doc:`limitations` (graph-state fixed-union), :doc:`data`, and
+:doc:`tutorials`.
diff --git a/docs/source/graphon.rst b/docs/source/graphon.rst
index 4f9cc7e..7adcd05 100644
--- a/docs/source/graphon.rst
+++ b/docs/source/graphon.rst
@@ -6,7 +6,8 @@ A **graphon** is a symmetric measurable kernel
sequences (Lovász and Szegedy, 2006; DOI
`10.1016/j.jctb.2006.05.001 `_).
KoopmanGraph uses that viewpoint for **transfer experiments at multiple**
-:math:`N`, not as theorems proved in this repository.
+:math:`N` and for a dense teaching estimator of two named kernels. Bounds
+in the citations are **cited**, not proved in this repository.
What ships
----------
@@ -15,15 +16,33 @@ What ships
symmetric 0/1 adjacency from a small family of kernels:
* ``kernel="constant"`` — independent edges with probability ``density``
-* ``kernel="product"`` — :math:`W(u,v)=u v` after sorting uniform latent
- positions
+ (dimensionless, in :math:`[0, 1]`)
+* ``kernel="product"`` — :math:`W(u,v)=u v` at uniform latent positions,
+ or at caller-supplied ``positions`` in :math:`[0, 1]`
-The helper returns an undirected ``edge_index`` (both orientations). A
-shared :math:`K_{\mathrm{self}}` with graphon-sampled :math:`A` is the
+The helper returns an undirected ``edge_index`` (both orientations).
+Latent coordinates are **not** sorted before sampling.
+
+:func:`~koopman_graph.operators.estimate_graphon` fits a kernel on
+**aligned** homogeneous graphs that share a finite node count
+:math:`N`. The mean loopless adjacency is the sufficient statistic:
+
+* ``kernel_family="constant"`` — mean off-diagonal edge probability
+* ``kernel_family="product"`` — degree scores
+ :math:`\hat u_i = 2 d_i / (N-1)`, clipped to :math:`[0, 1]`
+* ``kernel_family="low_rank"`` — truncated SVD of the mean adjacency,
+ clipped to :math:`[0, 1]` (a sketch; **not** an oracle-quality or
+ USVT consistency claim)
+
+A shared :math:`K_{\mathrm{self}}` with graphon-sampled :math:`A` is the
intended transfer setup: train at one :math:`N`, evaluate at another,
-and report measured error. The library does **not** claim a proven
-continuum-limit theorem, a unique graphon estimator, or sparse-graph
-graphon theory (Borgs–Chayes–Lovász and related sparse limits).
+and report measured error. Recovering a teaching kernel at one
+:math:`N` is **not** a transferability certificate on arbitrary sparse
+sensor graphs (Ruiz, Chamon, and Ribeiro, 2023; DOI
+`10.1109/TSP.2023.3297848 `_).
+The library does **not** claim a proven continuum-limit theorem, a
+unique graphon identifier, or sparse-graph graphon theory
+(Borgs–Chayes–Lovász and related sparse limits).
How to use it
-------------
@@ -35,7 +54,10 @@ with ``koopman="graph"``:
import torch
from koopman_graph import GNNDecoder, GNNEncoder, GraphKoopmanModel
- from koopman_graph.operators import sample_graphon_adjacency
+ from koopman_graph.operators import (
+ estimate_graphon,
+ sample_graphon_adjacency,
+ )
from torch_geometric.data import Data
edge_index = sample_graphon_adjacency(16, kernel="constant", density=0.3)
@@ -47,15 +69,28 @@ with ``koopman="graph"``:
latent_dim=8,
koopman="graph",
)
+ fit = estimate_graphon([graph], kernel_family="constant")
+ density = fit.density # dimensionless mean edge probability
Ceilings
--------
-* Sampling is dense :math:`O(N^2)` and is a teaching / diagnostic path.
+* Sampling and estimation are dense :math:`O(N^2)` teaching / diagnostic
+ paths. :func:`~koopman_graph.operators.estimate_graphon` refuses
+ :math:`N > 256` (``MAX_GRAPHON_NODES``) and refuses mixed or unbounded
+ node counts.
+* Graphs must share :math:`N` (aligned teaching estimator). Unaligned
+ multi-:math:`N` histogram estimators are out of scope.
* User-supplied injective remaps
- (:func:`~koopman_graph.data.remap_node_features`) grow a fixed union;
+ (:class:`~koopman_graph.data.EntityRemap` /
+ :func:`~koopman_graph.data.remap_node_features`) grow a fixed union;
they are **not** entity resolution across unrelated universes.
* Theory bounds in Lovász–Szegedy (2006) are **cited**, not re-proved.
+ Sparse-graph limits are refused. Size-transfer remains “sample at two
+ :math:`N`”; estimation quality is not GNN/graphon transferability
+ (Ruiz, Chamon, and Ribeiro, 2023).
See :doc:`limitations` for the remaining continuum-limit honesty boundary
-and :doc:`capabilities` for the operator inventory.
+and :doc:`capabilities` for the operator inventory. Related guides:
+:doc:`graph_dynamics` (fixed-union remap), :doc:`time_conditioning`
+(parameter records), and :doc:`benchmarks` (identity-bound fixtures).
diff --git a/docs/source/identification.rst b/docs/source/identification.rst
new file mode 100644
index 0000000..7177c1b
--- /dev/null
+++ b/docs/source/identification.rst
@@ -0,0 +1,89 @@
+Operator identification
+=======================
+
+Identification is an **opt-in** closed-form update of a discrete dense
+per-node :math:`K`. Default ``fit(..., identification=None)`` remains
+the Adam path and does not import
+:mod:`koopman_graph.identification`.
+
+What ships
+----------
+
+:class:`~koopman_graph.identification.IdentificationConfig` selects
+ridge, TLS, or constrained least squares. Pass it to
+:meth:`~koopman_graph.model.GraphKoopmanModel.fit` to alternate a
+frozen-encoder :math:`K` write with encoder/decoder Adam steps.
+:class:`~koopman_graph.identification.IdentificationReport` records
+latent one-step / short-rollout mean squared error (MSE) and
+:math:`\rho(K)`. Those scalars are not Haseli–Cortés, certified
+ResDMD, or stability certificates.
+
+Related helpers (all off root ``__all__``):
+
+* :func:`~koopman_graph.identification.identify_operator` — closed-form
+ solve on frozen :class:`~koopman_graph.identification.LatentPairs`
+* :func:`~koopman_graph.identification.subspace_invariance_report` —
+ finite-sample projection leakage :math:`\eta` (not a Haseli–Cortés
+ certificate). ``fit`` does not populate the report
+ ``invariance`` block; use
+ ``evaluate(..., include_invariance=True)`` or the helper
+* :func:`~koopman_graph.identification.select_resdmd_gated` — drop
+ pre-scored dictionaries whose max finite-dictionary ResDMD residual
+ exceeds :data:`~koopman_graph.identification.DEFAULT_RESDMD_GATE_TOLERANCE`
+ (:math:`10^{-2}`). ``IdentificationConfig.gate_resdmd=True`` fills
+ the report ``spectral`` block and does **not** abort ``fit``.
+ :class:`~koopman_graph.training.ResDMDFitCallback` ``mode="gate"``
+ may raise at fit end
+* :func:`~koopman_graph.identification.identify_sparse_graph_factors`
+ — sparse :math:`K_{\mathrm{self}}` / :math:`K_{\mathrm{nbr}}` on
+ frozen encodings (not latent SINDy; not
+ :class:`~koopman_graph.losses.KoopmanSparsityLoss`)
+* :func:`~koopman_graph.identification.select_latent_rank` —
+ truncated-SVD rank grid on frozen encodings (VAMP-2, ResDMD elbow,
+ or stability-penalized held-out MSE). **Not** Ray Tune AutoML for
+ encoder ``latent_dim``
+
+Graph, hetero, continuous, and controlled operators raise.
+``solver="varpro"`` is not implemented.
+
+How to use it
+-------------
+
+.. code-block:: python
+
+ from koopman_graph.identification import (
+ IdentificationConfig,
+ LatentPairs,
+ build_identification_report,
+ identify_operator,
+ select_resdmd_gated,
+ )
+
+ pairs = LatentPairs(z_t=z_t, z_next=z_next)
+ snapshot = identify_operator(
+ pairs, IdentificationConfig(solver="ridge", ridge=0.0)
+ )
+ report = build_identification_report(pairs, snapshot, gate_resdmd=True)
+ residual_max = report.spectral.residual_max
+
+A teaching walkthrough that populates report fields, rejects a
+polluted RMSE-only dictionary, and reads finite-sample leakage
+:math:`\eta` on a rank-deficient line is
+``examples/48_identification_invariance.ipynb``. Rank selection on a
+linear Gaussian oracle is ``examples/53_latent_rank_selection.ipynb``.
+
+Ceilings
+--------
+
+* Discrete dense per-node :math:`K` only. Networked / continuous maps
+ stay on Adam (or a later increment).
+* Closed-form ridge / TLS / constrained least squares is **not**
+ ResKoopNet residual training (``Xu2025ResKoopNet``).
+* Reports are finite-sample MSE and :math:`\rho(K)`, not certificates.
+* :func:`~koopman_graph.identification.select_latent_rank` does not
+ train an encoder per candidate and does not set
+ :class:`~koopman_graph.model.GraphKoopmanModel` ``latent_dim``.
+ :mod:`koopman_graph.tuning` remains caller-owned HPO.
+
+See :doc:`faq` (identification versus Adam; rank versus Ray Tune),
+:doc:`limitations` (Identification), and :doc:`api`.
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 35bb9a7..0b1f156 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -7,15 +7,17 @@ space, a learned linear operator advances those states, and a matching decoder
reconstructs physical node features for multi-step forecasting and spectral
analysis.
-Version **0.14.0** is a remaining-limits close-out: opt-in structure-preserving
-operators, switched / mixture maps, equivariant and Hodge-structured
-:math:`K`, protocol-matched leaderboard adapters, restricted portable
-export, and analysis extras (wired finite ResDMD, dispersion, TDA).
-Homogeneous scientific defaults and the linear latent operator contract
-are unchanged relative to 0.13.0 (factory ``koopman=None`` still selects
-``"pernode"``; ``sparsity="dense"``; AMP off). See :doc:`capabilities`
-for the full inventory, :doc:`limitations` for the new honesty ceilings,
-and :doc:`architecture` for the public vs power-user API contract.
+Version **0.15.0** is an integration increment on the 0.14 stack: opt-in
+closed-form identification, identity-bound benchmark manifests,
+polynomial graph filters, Nyquist / conditioning diagnostics, and
+labeled research MVPs (graph-state closure, cochain dynamics,
+matrix-free algebra, residual-tube MPC). Homogeneous scientific
+defaults and the linear latent operator contract are unchanged
+relative to 0.14.0 (factory ``koopman=None`` still selects
+``"pernode"``; ``sparsity="dense"``; AMP off). ``FORMAT_VERSION``
+stays 1. See :doc:`capabilities` for the full inventory,
+:doc:`limitations` for the honesty ceilings, and :doc:`architecture`
+for the public vs power-user API contract.
.. toctree::
:maxdepth: 2
@@ -31,6 +33,13 @@ and :doc:`architecture` for the public vs power-user API contract.
tutorials
architecture
graphon
+ benchmarks
+ identification
+ spectral_diagnostics
+ graph_dynamics
+ matrix_free
+ criticality
+ time_conditioning
api
Indices and tables
diff --git a/docs/source/installation.rst b/docs/source/installation.rst
index c2b6608..8749da7 100644
--- a/docs/source/installation.rst
+++ b/docs/source/installation.rst
@@ -169,7 +169,9 @@ contact-graph oracle needs no extra. The ``[equivariance]`` extra pins
``e3nn`` for :class:`~koopman_graph.nn.E3EquivariantEncoder` (steerable
encode to invariant latents by default; pass
``project_invariants=False`` for steerable channels consumed by
-:class:`~koopman_graph.operators.EquivariantKoopmanOperator`).
+:class:`~koopman_graph.operators.EquivariantKoopmanOperator`, whose
+optional :math:`l=2` blocks are ``scale * I_5``). The extra also
+runs the rotation test for that block.
``[baselines-ode]`` is required to construct
:class:`~koopman_graph.baselines.gnn.STGODEBaseline`.
``[baselines-graphcast]`` is a reserved no-op — the GraphCast teaching
@@ -241,8 +243,8 @@ version with the tag you intend; see PyPI and ``CHANGELOG.md``):
.. code-block:: bash
- pip install koopman-graph==0.14.0
- # or: uv pip install koopman-graph==0.14.0
+ pip install koopman-graph==0.15.0
+ # or: uv pip install koopman-graph==0.15.0
YAML CLI configs from PyPI:
@@ -268,6 +270,7 @@ on your ``PATH``:
koopman-graph --version
koopman-graph train --help
koopman-graph predict --help
+ koopman-graph benchmark --help
JSON train configs work with the core install. YAML requires
``pip install "koopman-graph[cli]"`` (or ``-e ".[cli]"``). See :doc:`cli` for
diff --git a/docs/source/limitations.rst b/docs/source/limitations.rst
index 410e2a1..d9896fd 100644
--- a/docs/source/limitations.rst
+++ b/docs/source/limitations.rst
@@ -13,6 +13,14 @@ behavior enforced by the current API. Absences listed under
:ref:`limitations-remaining` and :ref:`limitations-when-else` are **scoped
non-goals or honesty boundaries**, not overlooked gaps.
+Homogeneous scientific defaults stay as in 0.14:
+``koopman=None`` still selects ``"pernode"``, ``sparsity="dense"``,
+AMP is off, and discrete advance remains linear time-invariant.
+Polynomial graph hops, closed-form identification, graph-state
+closure, cochain dynamics, and conditioned interpolants are
+**opt-in**. Negative transfer advantage and large one-tap versus
+joint least-squares gaps remain allowed outcomes.
+
Graph structure
---------------
@@ -36,9 +44,13 @@ Graph structure
inactive nodes; operator matvecs still run at the full
:math:`N_{\max}` (or typed) capacity. Opt-in
:func:`~koopman_graph.data.remap_node_features` grows a larger union
- from a **user-supplied injective** index map. Silent merge of colliding
- IDs is refused. That remap is **not** entity resolution across unrelated
- universes (see :ref:`limitations-remaining`).
+ from a **user-supplied injective**
+ :class:`~koopman_graph.data.EntityRemap` (or the tensor helper).
+ Silent merge of colliding IDs is refused. Changing :math:`N` across
+ snapshots without that remap raises. Graph-state closure
+ (:class:`~koopman_graph.data.GraphStateSnapshot`) stays on that
+ **fixed-union** :math:`N_{\max}`; it is **not** open-world entity
+ resolution across unrelated universes (see :ref:`limitations-remaining`).
* **Heterogeneous / multiplex graphs are supported (opt-in).** Use
``koopman="hetero_graph"`` with
:class:`~koopman_graph.nn.heterogeneous.RelGraphEncoder` /
@@ -81,21 +93,82 @@ Graph structure
Self-adaptive topology, orbit-tied ``K_{\mathrm{self}}``, and isotypic
configurations bind node cardinality and are excluded from transfer
claims. See ``examples/37_cross_topology_transfer.ipynb``.
+* **Predicted topology is sparse by default and enters the forecast
+ loop only when configured.** :class:`~koopman_graph.data.GraphDynamicsConfig`
+ (opt-in; default model argument ``None``) selects
+ ``sparse_candidate`` scoring at most ``candidate_k`` destinations per
+ node (default 8). ``dense_mlp`` uses
+ :class:`~koopman_graph.nn.PredictedTopologyHead` and refuses
+ :math:`N>64` unless ``max_nodes`` is raised. This is distinct from
+ ``learn_topology="self_adaptive"`` /
+ :class:`~koopman_graph.nn.AdaptiveAdjacency`. Combining
+ ``self_adaptive`` with a non-``none`` topology head raises.
+ Predicted edges enter :math:`K_{\mathrm{eff}}` as sigmoid weights on
+ the candidate COO (not a hard threshold). Homogeneous models only;
+ RelGraph / hypergraph layouts raise. ``batch_graphs=True`` refuses
+ recursive graph-state training. On a seeded six-node directed
+ :math:`G(n,p)` event, a dense stub that scored the post-event graph
+ beat hold-last when the comparison target was the model's own oracle
+ one-step forecast; that is a wiring check, not a learned-forecast
+ claim. See ``examples/50_graph_state_closure.ipynb``.
Operator and theory
-------------------
* **Factorized networked form is a parameter-tying assumption.** The discrete
graph operator uses
- :math:`I_N \otimes K_{\mathrm{self}} + \widehat{A} \otimes K_{\mathrm{nbr}}`.
+ :math:`I_N \otimes K_{\mathrm{self}} + \widehat{A} \otimes K_{\mathrm{nbr}}`
+ by default (``koopman_filter_degree=1``).
On path diffusion with :math:`N=4`, latent width :math:`d=2`, and 80
training epochs, the factorized model reached hold-out MSE approximately
:math:`0.71` with 8 trainable factor parameters, versus approximately
:math:`0.019` MSE for a post-hoc unconstrained joint least-squares fit on
the same frozen encodings (64 fitted entries; spectral distance
approximately :math:`0.59` via ``dynamical_similarity``). The joint arm is
- not a second end-to-end trained model. See
- ``examples/38_operator_factorization_ablation.ipynb``.
+ not a second end-to-end trained model. Degree :math:`P>1` is opt-in.
+ The same notebook also reports a hop-matched :math:`P\in\{0,1,2\}` arm
+ on that surrogate (40 epochs; not the 80-epoch historical protocol) and
+ still does **not** attribute that gap to hop order. See
+ ``examples/38_operator_factorization_ablation.ipynb`` and
+ ``examples/49_multi_hop_factorization.ipynb``.
+* **Encoder mixing does not compensate for one-hop** :math:`K`. Default
+ ``GNNEncoder`` uses two message-passing layers; default discrete
+ ``koopman="graph"`` mixes one hop (``koopman_filter_degree=1``).
+ Degree :math:`P>1` is the opt-in polynomial; it is not the factory
+ default. :meth:`~koopman_graph.model.GraphKoopmanModel.fit` emits
+ :class:`~koopman_graph.nn.ReceptiveFieldMismatchWarning` when the
+ encoder receptive field exceeds operator hops. Deeper GCN/GAT stacks
+ or DiffConv ``diffusion_steps`` do **not** substitute for raising
+ :math:`P`. The check is warn-only and skips operators without
+ ``receptive_field_hops`` (per-node, continuous graph, hetero,
+ hypergraph).
+* **Per-node** :math:`K` **is not a cochain.** Factory
+ ``koopman=None`` still selects a node latent map. Opt-in
+ :class:`~koopman_graph.operators.CochainKoopmanOperator` advances
+ 0- and 1-cochain latents on a static signed :math:`B_1`. It is not
+ a factory kind, not ``koopman="hodge"``, and not TopologicX parity
+ (see :ref:`limitations-remaining`).
+* **Discrete sequences still require uniform** :math:`\Delta t`.
+ ``fit`` and ``predict_at`` reject gaps
+ (``validate_uniform_discrete_increments``). Time-of-day Fourier /
+ phase recipes do not lift that rule. Irregular timestamps on a
+ gEDMD sequence are unused and do **not** create a generator
+ :math:`L` (``Klus2020gEDMD``). Prefer
+ ``dynamics_mode="continuous"`` or supplied derivatives when the
+ sampling grid itself is irregular.
+* **Structural homomorphism is a separable-dictionary lemma, not a GNN
+ property.** Peng, Shen & Zhu (arXiv:2606.17797,
+ ``Peng2026KoopmanGKFA``) prove that block sparsity of the lifted
+ coupling matches graph edges only under a node-wise dictionary
+ :math:`\psi_i(x_i)`. Default GNN encoders mix neighbors.
+ :class:`~koopman_graph.adaptation.JointStateTopologyObserver` with
+ ``claim_homomorphism=True`` raises unless the encoder is
+ :class:`~koopman_graph.nn.SeparableDictionaryEncoder` and the
+ operator is a one-tap graph Koopman operator. The observer writes
+ :math:`K_{\mathrm{self}}` / :math:`K_{\mathrm{nbr}}` on the
+ **observed** COO; it does **not** infer a new edge set, does not
+ implement Koopman-GKFA ADMM, and does not certify a three-term MSE
+ bound. The 2026 arXiv preprint is provisional.
* **Relational factorization must earn its keep.** The multiplex operator
:math:`K_{\mathrm{eff}} = I_N \otimes K_{\mathrm{self}} + \sum_r
\widehat{A}_r \otimes K_r` (typed: block-diagonal per-type self blocks)
@@ -118,21 +191,59 @@ Operator and theory
:data:`~koopman_graph.analysis.MAX_JOINT_LYAPUNOV_SIZE`); they are
**not** factor-wise training parameterizations. Prefer assembled
spectrum / ``spectral_radius`` or ``kind="schur"`` when the true joint
- radius matters.
+ radius matters. Spectral radius (and
+ :class:`~koopman_graph.operators.StabilityCertificate`) is **not** a
+ finite-horizon bound on :math:`\|K^{k}\|`.
+* **Eigenvector conditioning is not a forecast bound.**
+ :class:`~koopman_graph.spectrum_types.SpectralDiagnostics` reports
+ :math:`\kappa(V)`, per-mode Wilkinson :math:`\kappa_i`, and Frobenius
+ departure from normality on spectra from ``compute_spectrum``.
+ Discrete spectra also report Nyquist frequency :math:`1/(2\Delta t)`
+ in **cycles per unit time** (``None`` on generator spectra), a
+ per-mode aliasing flag when the argument is near :math:`\pi`, and
+ :math:`\operatorname{sign}(\operatorname{Re}\lambda)`. Reported
+ frequencies are principal values; phase unwrapping does not recover
+ aliases, and :math:`\log|\lambda|` discards sign.
+ :func:`~koopman_graph.analysis.implied_timescales` uses the same
+ aliasing flag without changing the :math:`|\lambda|` validity window.
+ Finite-horizon transient growth of a non-normal map can exceed
+ :math:`\rho(K)^{t}`. ``mode_amplitudes`` emits a warning when
+ :math:`\kappa(V)` exceeds ``CONDITION_WARN`` and still solves
+ :math:`Va=z`; singular :math:`V` raises
+ :class:`~koopman_graph.spectrum_types.DefectiveSpectrumError` with a
+ Schur-subspace hint (not an automatic Schur fallback).
+ These diagnostics do not replace
+ ``stability_certificate``, are not resolvent-norm certificates, and
+ are not a sampling-theorem identification result.
+ Resolvent grids remain a separate opt-in helper
+ (:func:`~koopman_graph.analysis.resolvent_norm_grid`).
+* **Measure-preserving EDMD is not a conditioning certificate.**
+ :class:`~koopman_graph.baselines.MpEDMDBaseline` enforces unitarity
+ in a Gram-weighted dictionary inner product
+ (``Colbrook2023mpEDMD``). It does **not** replace
+ :class:`~koopman_graph.spectrum_types.SpectralDiagnostics`
+ (:math:`\kappa(V)`, Wilkinson :math:`\kappa_i`, departure from
+ normality) on a general directed :math:`K_{\mathrm{eff}}`.
* **Orbit ties and isotypic MVP are inductive biases.** Orbit-label ties
share ``K_{\mathrm{self}}`` blocks; they are not automatic isotypic
reduction. Opt-in ``koopman_symmetry="isotypic"`` (exact automorphism
groups on modest :math:`N`; see ``examples/45_isotypic_symmetry.ipynb``)
- ties isotypic projectors for the self block. Neighbor-factor
- (:math:`K_{\mathrm{nbr}}`) isotypic tying is **not** shipped. Shared-
- :math:`d` typed hetero orbits are partitioned independently within each
- type block; rectangular typed orbits are unsupported. Neither path
- guarantees sample-efficiency wins
+ ties isotypic projectors for the self block and, on discrete graph /
+ hypergraph / Hodge operators, the neighbor factor
+ (:math:`K_{\mathrm{nbr}}` / :math:`K_{\mathrm{hedge}}`). Dual
+ :math:`K_{\mathrm{bwd}}` stays globally shared. Shared-:math:`d` typed
+ hetero orbits are partitioned independently within each type block;
+ rectangular typed orbits are unsupported. Neither path guarantees
+ sample-efficiency wins
(see :ref:`limitations-remaining`).
* **Stochastic mode is process noise, not an SDE.** Opt-in
``dynamics_mode="stochastic"`` adds learned diagonal process noise after a
discrete linear map (:math:`z \mapsto Kz+\varepsilon`); it is not a
continuous-time stochastic generator.
+ :class:`~koopman_graph.operators.DriftDiffusionKoopman` is a separate
+ Euler–Maruyama / Yosida MVP (conditional-expectation semigroup vs
+ sampled paths). It is not certified Itô theory and not SDMD
+ (``Xu2025StochasticSemigroup``, ``Zhou2025Yosida``).
* **VAMP-2 precursor and GraphVAMP teaching path.** Optional
:func:`~koopman_graph.baselines.vamp2.vamp2_score` /
:func:`~koopman_graph.baselines.vamp2.vamp2_loss` and
@@ -162,8 +273,9 @@ Operator and theory
latents. Pass ``project_invariants=False`` together with
:class:`~koopman_graph.operators.EquivariantKoopmanOperator` for a
block :math:`K` whose vector channels are learnable multiples of
- :math:`I_3` — not a full steerable-generator library
- (see :ref:`limitations-remaining`).
+ :math:`I_3` and whose optional :math:`l=2` channels are multiples
+ of :math:`I_5`. This is not a full steerable-generator library and
+ not a molecular MD production stack (see :ref:`limitations-remaining`).
.. _limitations-topology-criticality:
@@ -179,10 +291,16 @@ Topology criticality (non-goal)
regimes discussed for infrastructure Koopman forecasting by Ghosh,
*Intelligent Systems with Applications*, 2025; ``Ghosh2025``). Held-out
``spectral_residuals`` and finite-dictionary ResDMD remain diagnostics,
- not certificates (see Spectral analysis). Continuous matrix-log /
- Van Loan helpers do not handle non-diagonalizable (Jordan) generators
- (:mod:`koopman_graph.operators.continuous_van_loan`). No early-warning
- score or spectral-gap monitor is shipped.
+ not certificates (see Spectral analysis). Continuous ``matrix_log``
+ raises :class:`~koopman_graph.spectrum_types.DefectiveSpectrumError` on
+ non-diagonalizable (Jordan) or numerically singular eigenbases by
+ default (``defective="error"``); opt-in ``defective="schur"`` uses
+ SciPy ``logm`` on CPU and is not a topology-criticality certificate
+ (:mod:`koopman_graph.operators.continuous_van_loan`).
+ :func:`~koopman_graph.analysis.monitor_critical_transition` is an
+ opt-in sliding-window spectral-gap / near-defectivity heuristic on a
+ sequence of spectra; it is not a Ghosh-grade early-warning certificate
+ and does not mitigate forecast-horizon collapse.
Spectral analysis
-----------------
@@ -214,6 +332,15 @@ Spectral analysis
``HeteroData``, RelGraph / ``hetero_graph``, hypergraph encoders /
operators, adaptive / learned pairwise topology, ``n_delays != 1``, and
``control_dim > 0``.
+* **Granger-style scores stay non-interventional.**
+ :func:`~koopman_graph.analysis.granger_latent_influence` reports
+ linear residual-MSE reduction on observed latents. The labeled
+ synthetic helper
+ :func:`~koopman_graph.analysis.recover_synthetic_interventional_edges`
+ applies paired do-interventions on
+ :func:`~koopman_graph.analysis.teaching_three_node_scm` only. That
+ fixture is not field-data causal discovery and is not a factory
+ kind. ``FORMAT_VERSION`` remains 1.
* **Largest Lyapunov exponent (Rosenstein) is available but sensitive.**
``koopman_graph.statistics.largest_lyapunov_exponent`` implements the
Rosenstein et al. (1993) small-data-set method
@@ -223,6 +350,26 @@ Spectral analysis
(see the statistics unit tests); bare defaults are not a high-precision
spectral computation.
+Identification
+--------------
+
+* **Closed-form identification does not replace Adam** ``fit``.
+ Default ``identification=None`` keeps the existing Adam path and
+ does not import :mod:`koopman_graph.identification` at model load.
+ ``fit(..., identification=IdentificationConfig(...))`` is opt-in
+ and currently writes dense per-node discrete :math:`K` only
+ (graph / hetero / continuous / controlled / delay / windowed /
+ DDP layouts raise). ``solver="varpro"`` raises
+ ``NotImplementedError``.
+ :class:`~koopman_graph.identification.IdentificationReport`
+ records latent one-step / short-rollout mean squared error (MSE)
+ and :math:`\rho(K)` separately from optional invariance leakage
+ and finite-dictionary ResDMD residuals. Those blocks are not
+ Haseli–Cortés certificates and not spectral-measure certificates.
+ :func:`~koopman_graph.identification.select_latent_rank` is not
+ Ray Tune AutoML for encoder ``latent_dim``. See :doc:`faq` and
+ :ref:`limitations-remaining`.
+
Scale
-----
@@ -246,10 +393,13 @@ modest latent width, and optional CUDA automatic mixed precision
certified continuous spectrum.
2. Else if Kronecker-sum eligible — shared self factor,
``adjacency`` in ``{"symmetric", "random_walk"}``, and
- ``sparsity`` in ``{"dense", "block_diagonal"}`` — exact reduction of
- :math:`I\otimes M_{\mathrm{self}} + \widehat{A}\otimes M_{\mathrm{nbr}}`
- (discrete :math:`K` or continuous generator :math:`L`). Cost is
- dominated by a dense :math:`N\times N` eigendecomposition of
+ ``sparsity`` in ``{"dense", "block_diagonal"}`` — exact reduction.
+ Discrete :math:`K` uses
+ :math:`B(\lambda)=\sum_k\lambda^k K_k` for any hop degree
+ :math:`P\ge 0` (not a sum of independent factor eigenvalues).
+ Continuous generator :math:`L` stays the one-tap form
+ :math:`I\otimes L_{\mathrm{self}} + \widehat{A}\otimes L_{\mathrm{nbr}}`.
+ Cost is dominated by a dense :math:`N\times N` eigendecomposition of
:math:`\widehat{A}` plus :math:`N` blocks of size :math:`d\times d`
(order :math:`O(N^3 + N d^3)`), not a full ambient
:math:`O((N\cdot d)^3)` factorization when the helper succeeds.
@@ -286,6 +436,12 @@ modest latent width, and optional CUDA automatic mixed precision
orchestration with the operator flag ``sparsity="distributed"``
(matrix-free inverse / Arnoldi spectrum on discrete graph and multiplex
hetero; see below).
+* **Multi-graph ``Batch`` collate is opt-in vectorization.**
+ ``fit(..., batch_graphs=True)`` collates independent homogeneous
+ trajectories so shared :math:`K` applies to a disconnected union. The
+ default ``MultiTrajectory`` Python loop is unchanged. Multi-topology
+ training did not require this flag. Hetero, hypergraph, windowed, and
+ DDP graph-batching raise.
* **Eigenvalue regularization** with a non-zero
``LossWeights.eigenvalue`` on dense or ODO networked operators runs an
:math:`O((N \cdot d)^3)` eigendecomposition of the assembled effective map
@@ -304,6 +460,13 @@ modest latent width, and optional CUDA automatic mixed precision
:mod:`koopman_graph.distributed` shards data and synchronizes
gradients; it does **not** shrink the dense :math:`N\cdot d`
representation ceilings above.
+ :class:`~koopman_graph.operators.LinearOperatorProtocol` unifies
+ polynomial-graph and one-tap ``matrix_free`` wrappers
+ (``matvec`` / ``rmatvec`` / ``solve`` / ``expm_action`` / leading
+ eigpairs). Dense assembly is refused above
+ :data:`~koopman_graph.operators.MAX_DENSE_LINEAR_OPERATOR_SIZE`.
+ Trainer DDP is **not** a smaller operator representation.
+ :math:`10^{5}`-node scaling is not a release gate.
* **Trainer taxonomy (DDP / Fabric / Ray).** Native DDP and Lightning Fabric
remain the **recommended** multi-GPU *model* paths.
:func:`~koopman_graph.distributed.run_ray_train_fit_loop` (optional
@@ -357,6 +520,10 @@ Uncertainty
neighbor smoothing), not joint coverage across nodes. The legacy
``score="per_node"`` mode still max-pools over nodes into a scalar score;
it is not heteroscedastic per node.
+ :class:`~koopman_graph.uq.JointCoverageSpec` names that estimand
+ (``target="per_node_marginal"``). Simultaneous node–feature–horizon
+ and event coverage are named but not implemented
+ (``Schlembach2025Conformal``).
* **Ensemble and latent-Gaussian UQ** are power-user surfaces with their own
modeling assumptions (empirical quantiles; linear-Gaussian latent
propagation). They are not deep probabilistic Koopman (DPK) or a full
@@ -385,8 +552,30 @@ Benchmarks and baselines
``deviations`` only when the named protocol truly matches). GraphCast is a
small-mesh weather teaching adapter, not a PEMS sensor-graph forecaster
and not ERA5-scale production training (see :ref:`limitations-remaining`).
+* **Example 22 teaching comparison.**
+ Saved METR-LA weekday-cache output ranks GraphKoopman first on
+ aggregate RMSE (z-scored speed): :math:`0.6551` versus STGCN
+ :math:`0.7076`, Graph WaveNet :math:`0.9036`, and DCRNN
+ :math:`1.0754`. GraphKoopman uses a longer ODO / rollout / early-stopping
+ budget than the GNN teaching refs (unequal budgets); STGCN remains
+ strongest at the shortest horizon. These are in-repo teaching
+ baselines, not dedicated-library SOTA. See
+ ``examples/22_gnn_forecaster_comparison.ipynb``.
* **Classical DMD family.** Shipped baselines include DMD, EDMD (including
- Nyström / random-feature kernel approximations), DMDc, forward–backward
+ Nyström / random-feature kernel approximations), measure-preserving EDMD
+ (``MpEDMDBaseline``; unitarity is in the dictionary Gram inner product,
+ not :class:`~koopman_graph.spectrum_types.SpectralDiagnostics` and
+ not a Euclidean certificate on general directed
+ :math:`K_{\mathrm{eff}}`),
+ generator EDMD (``GEDMDBaseline``; supplied dictionary derivatives,
+ not snapshot finite differences or derivative-mode SINDy; irregular
+ :math:`\Delta t` does not create :math:`L`),
+ Hankel-DMD (``HankelDMDBaseline``; delay-embedded flattened
+ states, not :class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder`),
+ teaching HAVOK (``HAVOKBaseline``; same delay rows; autonomous
+ ``predict`` uses :math:`u=0`; omitted ``history`` zero-pads older
+ delays),
+ DMDc, forward–backward
DMD, total-least-squares DMD, optDMD (variable-projection MVP), streaming
DMD, depth-2 multi-resolution DMD, and the Ulam transfer-operator
baseline. ``RecursiveKoopmanAdapter`` is not a streaming-DMD substitute; it
@@ -411,14 +600,92 @@ label.
caches. Teaching ports still declare non-empty ``deviations``. Adapter
numbers are protocol-matched comparisons, not dedicated-library
competition entries.
+ :class:`~koopman_graph.benchmark.ExperimentManifest` locks that protocol
+ in a frozen schema (dataset SHA-256, seeds, controls). The CLI
+ ``run`` / ``verify`` commands hash protocol identity; they do not
+ train models, invent forecast metrics, or host a leaderboard.
+ Default CI verifies hashed synthetics under ``benchmarks/v0.15/``;
+ full METR-LA / PEMS downloads stay documented, not a pull-request
+ gate.
* **ERA5-scale / production GraphCast** training. The in-repo adapter is a
documented small-mesh teaching slice plus an ERA5-slice fetch/checksum
card; CI uses a tiny generated mesh.
* **Open-world remap is user-supplied, not entity resolution.**
- :func:`~koopman_graph.data.remap_node_features` grows :math:`N_{\max}`
+ :class:`~koopman_graph.data.EntityRemap` and
+ :func:`~koopman_graph.data.remap_node_features` grow :math:`N_{\max}`
with an injective index map and presence masks. Silent universe merge
- is refused. Automatic identity matching across unrelated graphs is out
- of scope.
+ is refused. Sequences that change node count without a remap raise.
+ Automatic identity matching across unrelated graphs is out of scope.
+* **Graphon estimation is a dense teaching fit, not sparse-limit
+ theory.** :func:`~koopman_graph.operators.estimate_graphon` recovers
+ constant and product kernels on aligned graphs with a shared
+ :math:`N` (ceiling 256). It does not identify a unique graphon, does
+ not implement Borgs–Chayes–Lovász sparse limits, and does not certify
+ GNN transferability on arbitrary sparse sensor graphs
+ (``Ruiz2023Transferability``). Size-transfer remains sampling at two
+ :math:`N`.
+* **Regime coordinates are a data record; the interpolant is opt-in.**
+ Homogeneous sequences may carry ``parameter_trajectory`` and
+ :class:`~koopman_graph.data.ConditioningContext`. That does not
+ change the default LTI operator. ``koopman="switched"`` and
+ ``"mixture"`` remain latent-gated or piecewise LTI maps
+ (``Macesic2018Nonautonomous``). ``koopman="parametric"`` is a
+ discrete per-node convex combination
+ :math:`K(\\mu)=\\sum_j \\alpha_j(\\mu) K_j`, not a cocycle
+ :math:`K(t)` and not a graph-factorized :math:`K_{\\mathrm{eff}}(\\mu)`.
+ A convex combination preserves dense / row-stochastic / doubly-stochastic
+ factors; symplectic and other structural mixes raise. Export refuses
+ the interpolant. Discrete sequences still require uniform
+ :math:`\\Delta t`.
+* **The drift–diffusion stepper is an Euler–Maruyama / Yosida MVP.**
+ :class:`~koopman_graph.operators.DriftDiffusionKoopman` is not a
+ factory kind and does not change ``dynamics_mode="stochastic"``.
+ It is not certified SDE theory, not gEDMD, and not SDMD.
+ :class:`~koopman_graph.uq.JointCoverageSpec` names
+ ``per_node_marginal`` coverage; simultaneous / event targets raise.
+* **Cochain dynamics are node/edge maps, not a TDL stack.**
+ :class:`~koopman_graph.operators.CochainKoopmanOperator` advances
+ 0- and 1-cochain latents on a static signed :math:`B_1`. It is not
+ a factory kind and does not change ``koopman=None`` /
+ ``"pernode"``. Distinct from ``koopman="hodge"``. Face latents may
+ be stored; :math:`k=2` is not evolved.
+ :func:`~koopman_graph.operators.boundary_nilpotency` flags
+ :math:`B_1 B_2\\approx 0` at atol ``1e-6``. Not sheaf Laplacian
+ theory and not TopologicX parity (``Lim2020Hodge``, ``TopoX2024``).
+* **TDL teaching stops at cell-complex degree 3.**
+ :func:`~koopman_graph.nn.order2_cochain_teaching` binds
+ :class:`~koopman_graph.operators.CochainKoopmanOperator` to a filled
+ 2-simplex. Optional tetrahedra reach
+ :data:`~koopman_graph.nn.MAX_CELL_COMPLEX_DEGREE`. Face latents may
+ be stored; :math:`k=2` is not evolved. Sheaf restriction maps stay
+ learned-optional (default diagonal). This is still not TopologicX
+ / TDA ecosystem parity (``TopoX2024``).
+* **Hodge mode components are combinatorial, not physical circulation.**
+ :func:`~koopman_graph.analysis.hodge_decompose_modes` splits stored
+ eigenvector columns on a static signed :math:`B_1`
+ (:math:`k\\in\\{0,1\\}`). It is analysis-only, not a factory kind,
+ and not ``koopman="hodge"``. Face-based :math:`k=1` curl is not
+ shipped. Do not treat the harmonic block as a validated current
+ (``Lim2020Hodge``, ``TopoX2024``).
+* **Finite memory is a whiteness diagnostic plus a convolution MVP.**
+ :func:`~koopman_graph.analysis.markov_closure_report` is a
+ Ljung–Box-style flag on residual energy (lags in timesteps), not a
+ certified Markov-closure test (``Ljung1978Box``).
+ :class:`~koopman_graph.analysis.FiniteMemoryKoopman` stores
+ :math:`\\Omega` and taps :math:`K_s`. It is not a factory kind, not
+ :class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder`, and not
+ HAVOK. Delay embedding and HAVOK are not Mori–Zwanzig decompositions
+ (``Brunton2017HAVOK``, ``Lin2021MoriZwanzig``). Memory-order
+ selection is not implemented.
+* **Time-of-day is a control / phase recipe, not a calendar serializer.**
+ :func:`~koopman_graph.data.diurnal_control_features` writes Fourier
+ columns onto existing ``control_inputs``.
+ :func:`~koopman_graph.data.diurnal_phase_index` fills an optional
+ switched ``phase_index``. There is no native calendar field and no
+ checkpoint key. Discrete ``fit`` / ``predict_at`` still reject
+ non-uniform increments. Use ``dynamics_mode="continuous"`` or
+ supply gEDMD derivatives for irregular sampling. Irregular
+ timestamps do not create :math:`L` (``Klus2020gEDMD``).
* **Multi-node Ray is opt-in.** Default CI stays single-process. Set
``KOOPMAN_GRAPH_MULTINODE=1`` for the documented ``torchrun`` / Ray Train
recipe smoke. Measured cluster speedups are outside that contract.
@@ -438,13 +705,66 @@ label.
``evaluate(..., include_resdmd=True)`` and to an optional fit callback.
The dictionary remains finite — not certified infinite-dimensional
spectra or spectral measures.
-* **Conservation and symplectic constraints apply to parameterized**
- :math:`K`, **not decoded** :math:`x`. Row- / doubly-stochastic and
- symplectic maps constrain the operator matrix. A nonlinear decoder can
- still break mass or symplectic structure in feature space.
+ :func:`~koopman_graph.identification.select_resdmd_gated` and
+ ``ResDMDFitCallback(mode="gate")`` use the same max-residual cutoff
+ (default :math:`10^{-2}`). They may reject an RMSE-only winner; they
+ do not certify a spectral measure.
+* **Finite-sample subspace invariance.** ``evaluate(...,
+ include_invariance=True)`` and
+ :meth:`~koopman_graph.model.GraphKoopmanModel.subspace_invariance_report`
+ report projection leakage :math:`\eta` on a truncated-SVD encoding
+ span. That ratio is not the Haseli–Cortés invariance-proximity
+ certificate (``HaseliCortes2023``). Discrete dense per-node
+ :math:`K` only.
+* **Sparse graph-factor identification is not latent SINDy.**
+ :func:`~koopman_graph.identification.identify_sparse_graph_factors`
+ thresholds :math:`K_{\\mathrm{self}}` / :math:`K_{\\mathrm{nbr}}` on
+ frozen encodings. :func:`~koopman_graph.analysis.identify_sparse_dynamics`
+ and :class:`~koopman_graph.losses.KoopmanSparsityLoss` still ship.
+ Dual random-walk and polynomial :math:`P>1` maps are out of scope.
+* **Joint state–topology observation is not Koopman-GKFA.**
+ :class:`~koopman_graph.adaptation.JointStateTopologyObserver` filters
+ latents then updates graph factors or dense :math:`K`. It does not
+ recover a new adjacency :math:`A`, does not run ADMM, and does not
+ certify a three-term MSE bound. Homomorphism claims require a
+ separable dictionary (``Peng2026KoopmanGKFA``).
+* **Latent-rank selection is not AutoML for** ``latent_dim``.
+ :func:`~koopman_graph.identification.select_latent_rank` truncates a
+ frozen encoding cloud. :mod:`koopman_graph.tuning` Ray Tune helpers
+ remain example scaffolds; they are not a scientific default for
+ encoder width.
+* **Decoded constraint heads do not make latent symplectic**
+ :math:`K` **a mass certificate.**
+ :class:`~koopman_graph.nn.MassConservingDecoder`,
+ :class:`~koopman_graph.nn.LinearConservingDecoder`, and
+ :class:`~koopman_graph.nn.PositivityDecoder` project named
+ channels **after** decode (simplex or affine
+ :math:`\\mathbf{1}^{\\top}x=M`, linear :math:`Cx=c_0`, or
+ positivity). Row- / doubly-stochastic and symplectic maps still
+ constrain only the operator matrix (``Greydanus2019HNN``).
+ IEEE-118 ``generate`` / ``generate_typed`` remain Laplacian
+ diffusion plus a load ramp — not SCADA, OPF, or AC power flow.
+ Constraint heads do not create power-flow consistency.
+* **Tube Koopman-MPC is residual-box tightening, not a closed-loop
+ certificate.** :class:`~koopman_graph.mpc.TubeKoopmanMPC`
+ erodes nominal output boxes by conformal or ensemble residual
+ radii on additive discrete plants and reports
+ constraint-violation rate, feasibility rate, and quadratic
+ stage cost. Local decoder linearization remains. The helper is
+ not a chance-constraint solver, not a recursive-feasibility
+ proof, and not a Lyapunov closed-loop certificate. Zhang et
+ al., *Automatica* 137:110114 (2022), address an r-KMPC scheme
+ with an offline nonlinear ancillary law
+ (``Zhang2022TubeMPC``); this MVP does not implement that
+ controller. Bilinear, networked, and continuous operators
+ raise. ``FORMAT_VERSION`` remains 1.
* **Equivariant** :math:`K` **is a block MVP.** Vector channels are
- learnable multiples of :math:`I_3`. This is not a full steerable
- generator library.
+ learnable multiples of :math:`I_3`. One additional irrep
+ (:math:`l=2`) is ``scale * I_5`` (``n_tensors``). Irreps are
+ not mixed. Default :class:`~koopman_graph.nn.E3EquivariantEncoder`
+ still projects to invariant scalars. This is not a full steerable
+ generator library and not a molecular MD production stack
+ (``Thomas2018TFN``, ``Geiger2022e3nn``).
* **Portable export is a restricted MVP.** :mod:`koopman_graph.export`
covers fixed-topology, discrete, homogeneous, ``n_delays=1``, no
adaptive adjacency. Hetero / hypergraph / control / switched kinds are
@@ -476,8 +796,10 @@ When to use something else
* **Full sheaf / cell-complex TDL stacks.** Prefer libraries built for rich
sheaf Laplacians and cellular TDL when the domain model requires
complex-native operators end-to-end. KoopmanGraph’s sheaf / cell MVPs,
- Hodge-structured :math:`K`, and the TopologicX incidence bridge are
- precursors with a linear Koopman head, not a TopologicX replacement.
+ Hodge-structured :math:`K`, the order-2 cochain teaching hook, and
+ the TopologicX incidence bridge are precursors with a linear
+ Koopman head. Cell-complex degree 3 is the teaching ceiling, not a
+ TopologicX replacement.
* **Production biomolecular Koopman / MSM analysis.** Prefer GraphVAMPnets /
PyEMMA-scale MSM toolchains aimed at molecular trajectory data when you
need that production surface. The alanine-dipeptide teaching fetch and
@@ -490,9 +812,20 @@ When to use something else
fixed-topology discrete homogeneous models. Share general weights with
``safetensors_v1``. Hetero / hypergraph / control / switched export is
refused.
+* **Certified SDE / SDMD generators.** Prefer dedicated stochastic
+ Koopman / SDMD toolchains when the object of study is a certified
+ semigroup or generator identification method
+ (``Xu2025StochasticSemigroup``, ``Zhou2025Yosida``). The in-repo
+ stepper is a discrete-semigroup approximation.
+* **Native calendar / irregular discrete Koopman.** Discrete ``fit``
+ still requires uniform :math:`\\Delta t`. Time-of-day helpers are
+ Fourier / phase-bin recipes on existing control and switched APIs.
+ Prefer a dedicated calendar-aware or continuous-time toolchain when
+ the sampling grid itself is the scientific object.
* **Flat-vector deep Koopman without graph structure.** Packages such as
- PyKoopman and DLKoopman remain appropriate when the state is naturally a
- vector and edge-based coupling is not part of the model.
+ PyKoopman (``PyKoopman2024``), PyDMD (``PyDMD2018``), kooplearn
+ (``kooplearn2026``), and DLKoopman remain appropriate when the state is
+ naturally a vector and edge-based coupling is not part of the model.
Related pages
-------------
@@ -502,4 +835,12 @@ Related pages
* :doc:`data` — dataset cards and per-dataset limitations
* :doc:`tutorials` — notebook gallery
* :doc:`faq` — install and runtime troubleshooting
-* :doc:`graphon` — graphon sampling and continuum-limit citations
+* :doc:`graphon` — graphon sampling, dense teaching estimation, and
+ continuum-limit citations
+* :doc:`benchmarks` — identity-bound manifests and smoke fixtures
+* :doc:`identification` — opt-in solvers and finite-sample reports
+* :doc:`spectral_diagnostics` — conditioning and Nyquist fields
+* :doc:`graph_dynamics` — recursive topology head versus hold-last
+* :doc:`matrix_free` — dense-assembly ceiling and Arnoldi Ritz pairs
+* :doc:`criticality` — gap-closure heuristic (not Ghosh)
+* :doc:`time_conditioning` — uniform :math:`\Delta t` and calendar recipes
diff --git a/docs/source/matrix_free.rst b/docs/source/matrix_free.rst
new file mode 100644
index 0000000..9089c4a
--- /dev/null
+++ b/docs/source/matrix_free.rst
@@ -0,0 +1,67 @@
+Matrix-free linear operators
+============================
+
+:class:`~koopman_graph.operators.LinearOperatorProtocol` is a common
+``matvec`` / ``rmatvec`` / ``solve`` / ``expm_action`` / leading-eigpair
+surface for networked maps that should not assemble
+:math:`K_{\mathrm{eff}}`. It is **operator algebra**, not trainer DDP.
+
+What ships
+----------
+
+Two teaching wrappers implement the protocol (off root ``__all__``):
+
+* :class:`~koopman_graph.operators.MatrixFreeGraphLinearOperator` —
+ one-tap :math:`I\otimes K_{\mathrm{self}}+\widehat{A}\otimes
+ K_{\mathrm{nbr}}`, reusing the existing ``matrix_free`` helpers
+* :class:`~koopman_graph.operators.PolynomialGraphLinearOperator` —
+ monomial :math:`P\ge 2` graph polynomial
+ :math:`\sum_{k=0}^{P}\widehat{A}^{k}\otimes K_{k}`
+
+Dense assembly of a flat :math:`(N\cdot d)\times(N\cdot d)` matrix is
+refused when :math:`N\cdot d` exceeds
+:data:`~koopman_graph.operators.MAX_DENSE_LINEAR_OPERATOR_SIZE`
+(4096; the same teaching ceiling as assembled eig-regularization /
+joint Schur). :class:`~koopman_graph.operators.MemoryEstimate`
+declares that cost without building the matrix. Trainer DDP and the
+``[distributed]`` extras do **not** shrink the representation.
+
+Leading eigpairs on this surface are Arnoldi Ritz values, not the
+exact Kronecker reduction :math:`\operatorname{eig}(B(\lambda))`
+with :math:`B(\lambda)=\sum_k\lambda^{k}K_{k}`. Kronecker exact
+spectrum remains a special case on eligible
+``koopman="graph"`` operators. :math:`10^{5}`-node scaling is not a
+release gate.
+
+How to use it
+-------------
+
+.. code-block:: python
+
+ import torch
+ from koopman_graph.operators import MatrixFreeGraphLinearOperator
+
+ k_self = torch.tensor([[0.7, 0.05], [0.0, 0.6]], dtype=torch.float64)
+ k_nbr = torch.tensor([[0.04, 0.0], [0.0, 0.03]], dtype=torch.float64)
+ tails = torch.arange(5, dtype=torch.long)
+ forward = torch.stack((tails, tails + 1), dim=0)
+ edge_index = torch.cat((forward, forward.flip(0)), dim=1)
+ op = MatrixFreeGraphLinearOperator(
+ k_self, k_nbr, edge_index=edge_index, num_nodes=6
+ )
+ y = op.matvec(torch.randn(12, dtype=torch.float64))
+
+Wrappers are **not** a factory kind. Default ``koopman=None`` still
+selects ``"pernode"``.
+
+Ceilings
+--------
+
+* Protocol ``@runtime_checkable`` checks method presence only.
+* Dual / orbit layouts stay on dense assembly.
+* Inverse of a :math:`P\neq 1` polynomial still uses Richardson on
+ the polynomial map, not a cheap Kronecker inverse.
+* This page does not quote a throughput number.
+
+See :doc:`limitations` (Scale / matrix-free), :doc:`architecture`,
+and :doc:`api`.
diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst
index a23a4f2..b0d510e 100644
--- a/docs/source/quickstart.rst
+++ b/docs/source/quickstart.rst
@@ -114,7 +114,8 @@ path from a JSON config. After installing the package (see
--out /tmp/kg-cli/forecast.pt
YAML configs require the optional ``[cli]`` extra. Subcommands, exit codes,
-and the MVP config schema are documented in :doc:`cli`.
+and the MVP config schema are documented in :doc:`cli`. Identity-bound
+``benchmark run`` / ``verify`` (no training) is documented there as well.
Inspect the spectrum
--------------------
@@ -344,8 +345,10 @@ Delay embeddings
~~~~~~~~~~~~~~~~
For partially observed systems, stack the last ``n_delays`` snapshots into the
-encoder channels (delay-coordinate / Takens-style lifting; related Hankel
-constructions appear in HAVOK and Hankel-DMD). Size the base encoder yourself as
+encoder channels (delay-coordinate / Takens-style lifting). This is
+:class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder`, not
+:class:`~koopman_graph.baselines.HankelDMDBaseline` or
+:class:`~koopman_graph.baselines.HAVOKBaseline`. Size the base encoder yourself as
``in_channels = n_delays * feature_dim`` (composition; layers are not rebuilt),
then pass ``n_delays`` to the model. Training uses teacher-forced history;
``predict`` encodes the provided observation window once and advances in latent
diff --git a/docs/source/spectral_diagnostics.rst b/docs/source/spectral_diagnostics.rst
new file mode 100644
index 0000000..6a2a69b
--- /dev/null
+++ b/docs/source/spectral_diagnostics.rst
@@ -0,0 +1,69 @@
+Spectral diagnostics
+====================
+
+:class:`~koopman_graph.spectrum_types.SpectralDiagnostics` attaches
+conditioning and sampling fields to a
+:class:`~koopman_graph.spectrum_types.KoopmanSpectrum`. The numbers
+describe the **computed eigenbasis**. They are not a finite-horizon
+bound on :math:`\|K^{k}\|` and not a sampling-theorem identification
+result.
+
+What ships
+----------
+
+:func:`~koopman_graph.spectrum_types.compute_spectrum` (and
+:func:`~koopman_graph.spectrum_types.compute_generator_spectrum`)
+always populate optional ``diagnostics``:
+
+* :math:`\kappa(V)` after column normalization
+* per-mode Wilkinson sensitivities :math:`\kappa_i`
+* Frobenius departure from normality and a scale-free relative variant
+* discrete Nyquist frequency :math:`1/(2\Delta t)` in **cycles per
+ unit time** (``None`` on generator spectra)
+* per-mode aliasing flags and :math:`\operatorname{sign}(\operatorname{Re}\lambda)`
+
+Discrete assembly emits one ``UserWarning`` when any mode is
+Nyquist-adjacent. Frequencies remain principal values; phase
+unwrapping does not recover aliases.
+
+:meth:`~koopman_graph.spectrum_types.KoopmanSpectrum.mode_amplitudes`
+warns when :math:`\kappa(V)` exceeds
+:data:`~koopman_graph.spectrum_types.CONDITION_WARN` (:math:`10^{6}`)
+and still solves :math:`Va=z^{\top}`. Singular :math:`V` raises
+:class:`~koopman_graph.spectrum_types.DefectiveSpectrumError` (a
+``LinAlgError``) with a Schur-subspace hint.
+
+mpEDMD is a **baseline**
+(:class:`~koopman_graph.baselines.MpEDMDBaseline`). It does not
+replace these Euclidean conditioning fields on a general directed
+:math:`K_{\mathrm{eff}}`.
+
+How to use it
+-------------
+
+.. code-block:: python
+
+ import torch
+ from koopman_graph.spectrum_types import compute_spectrum
+
+ operator = torch.tensor([[1.0, 10.0], [0.0, 2.0]], dtype=torch.float64)
+ spectrum = compute_spectrum(operator, time_step=1.0)
+ kappa = spectrum.diagnostics.eigenvector_condition
+ nyquist = spectrum.diagnostics.nyquist_frequency # 0.5 cycles / unit time
+
+A non-normal / Nyquist teaching notebook is
+``examples/51_spectral_diagnostics.ipynb``.
+
+Ceilings
+--------
+
+* Diagnostics are optional on manually constructed
+ :class:`~koopman_graph.spectrum_types.KoopmanSpectrum` objects
+ (backward compatible).
+* Eligible Kronecker graph spectra may omit ``diagnostics``.
+* A ``CONDITION_WARN`` notice is not a transient-growth certificate.
+* Generator spectra have no Nyquist frequency: the placeholder
+ :math:`\Delta t` is not a sampling interval.
+
+See :doc:`limitations` (Nyquist / non-normal), :doc:`criticality`,
+and :doc:`api`.
diff --git a/docs/source/time_conditioning.rst b/docs/source/time_conditioning.rst
new file mode 100644
index 0000000..09154de
--- /dev/null
+++ b/docs/source/time_conditioning.rst
@@ -0,0 +1,79 @@
+Time and parameter conditioning
+===============================
+
+Homogeneous sequences may carry a ``parameter_trajectory`` and
+optional timestamps / controls. :class:`~koopman_graph.data.ConditioningContext`
+is a **data record** for one index :math:`(\mu, t, u)`. It does not
+select a factory operator.
+
+What ships
+----------
+
+:func:`~koopman_graph.data.conditioning_at` reads those fields from a
+:class:`~koopman_graph.data.GraphSnapshotSequence`. Optional
+``period`` / ``num_phases`` fill ``phase_index`` via
+:func:`~koopman_graph.data.diurnal_phase_index`.
+
+:func:`~koopman_graph.data.diurnal_control_features` returns Fourier
+sine/cosine columns for existing additive / bilinear
+``control_inputs``. That recipe is not a calendar serializer and not
+a checkpoint key.
+
+Factory kinds stay distinct:
+
+* ``koopman="switched"`` / ``"mixture"`` — latent-gated or piecewise
+ LTI maps, **not** :math:`K(\mu)`
+* ``koopman="parametric"`` —
+ :class:`~koopman_graph.operators.ParametricKoopmanOperator`, the
+ interpolant :math:`K(\mu)=\sum_j\alpha_j(\mu)K_j` (RBF or simplex
+ weights). Convex combinations preserve dense / row-stochastic /
+ doubly-stochastic factors; symplectic and other structural mixes
+ raise. Export refuses the interpolant
+* Default ``koopman=None`` still selects ``"pernode"``. Carrying
+ :math:`\mu` does not change that default
+
+Discrete sequences still require uniform :math:`\Delta t`
+(``validate_uniform_discrete_increments``).
+Gaps raise. Use ``dynamics_mode="continuous"`` and ``predict_at``,
+or supply derivatives to
+:class:`~koopman_graph.baselines.GEDMDBaseline`, for irregular
+sampling. Irregular timestamps on a gEDMD sequence do not create
+:math:`L`.
+
+Related literature for the interpolant (cited, not re-proved):
+Maćešić, Črnjarić-Žic, and Mezić, *SIAM J. Appl. Dyn. Syst.*, 2018
+(DOI
+`10.1137/17M1133610 `_;
+``Macesic2018Nonautonomous``).
+
+How to use it
+-------------
+
+.. code-block:: python
+
+ from koopman_graph.data import (
+ conditioning_at,
+ diurnal_control_features,
+ )
+
+ ctx = conditioning_at(sequence, index=3)
+ mu = ctx.parameters # shape (d_mu,) or None
+ clock = diurnal_control_features(timestamps, period=24.0)
+
+:func:`~koopman_graph.operators.leave_one_regime_out` compares the
+parametric interpolant to pooled LTI on latent pairs. Heterogeneous
+sequences have no calendar helper.
+
+Ceilings
+--------
+
+* Discrete ``fit`` / ``predict_at`` reject non-uniform increments.
+ Time-of-day helpers sit on existing control and switched APIs.
+* ``parameter_trajectory`` is homogeneous ``(T, d_\mu)``. Units are
+ caller-defined (dimensionless if unspecified).
+* Types stay in :mod:`koopman_graph.data` (off root ``__all__``).
+ ``data`` must not import ``nn``.
+
+See :doc:`faq` (irregular :math:`\Delta t`; time of day),
+:doc:`limitations`, :doc:`data`, and
+``examples/12_irregular_sampling_continuous_time.ipynb``.
diff --git a/docs/source/tutorials.rst b/docs/source/tutorials.rst
index b2b851b..bee179d 100644
--- a/docs/source/tutorials.rst
+++ b/docs/source/tutorials.rst
@@ -26,8 +26,10 @@ METR-LA teaching-baseline comparison
:alt: METR-LA aggregate RMSE bar chart for GraphKoopman vs STGCN, DCRNN, and Graph WaveNet
:width: 80%
-Protocol-matched aggregate RMSE on a METR-LA cache (notebook 22). Bars are
-**in-repo teaching baselines**, not dedicated-library SOTA implementations.
+Protocol-matched aggregate RMSE on a METR-LA cache (notebook 22). Saved
+ranking: GraphKoopman 0.6551 versus STGCN 0.7076, Graph WaveNet 0.9036,
+and DCRNN 1.0754 (unequal budgets). Bars are **in-repo teaching
+baselines**, not dedicated-library SOTA implementations.
Architecture overview
~~~~~~~~~~~~~~~~~~~~~
@@ -57,7 +59,18 @@ Forecasting and benchmarks
* - `06_epidemic_ring.ipynb `_
- SIR ring wave with Schur-stable spectrum
* - `22_gnn_forecaster_comparison.ipynb `_
- - GraphKoopman vs STGCN / DCRNN / Graph WaveNet references
+ - GraphKoopman vs STGCN / DCRNN / Graph WaveNet **teaching** baselines
+ (saved aggregate RMSE ranks GraphKoopman first: 0.6551 vs 0.7076 /
+ 1.0754 / 0.9036; unequal budgets; not SOTA)
+ * - `47_benchmark_manifest.ipynb `_
+ - Identity-bound ``koopman-graph benchmark run`` / ``verify`` on
+ hashed v0.15 smoke fixtures (``executed=False``; not METR-LA
+ training; not invented MAE / RMSE)
+ * - `48_identification_invariance.ipynb `_
+ - Opt-in identification report fields, residual-aware rejection
+ of a polluted RMSE-only dictionary, and finite-sample leakage
+ :math:`\eta` on a rank-deficient line (not a Haseli–Cortés
+ certificate; default Adam path unchanged)
* - `42_traffic_teaching_baselines.ipynb `_
- Teaching AGCRN / MTGNN / STGODE on a METR-LA slice, plus GraphCast on a
separate small weather mesh (deviation tables; not leaderboard or
@@ -86,6 +99,22 @@ Analysis and stability
- GAT encoder on grid graphs
* - `07_koopman_spectrum.ipynb `_
- Koopman eigenvalue analysis + held-out ``spectral_residuals`` / spurious-mode filter
+ * - `51_spectral_diagnostics.ipynb `_
+ - Non-normal shear and Nyquist toys: ``SpectralDiagnostics``,
+ aliasing warning, ``CONDITION_WARN`` on mode amplitudes, and a
+ :math:`\rho<1` :math:`\|K^{k}\|` versus :math:`\rho^{k}` sketch
+ (not a finite-horizon :math:`\|K^{k}\|` bound)
+ * - `52_cochain_hodge_modes.ipynb `_
+ - Combinatorial Hodge split on a cycle: harmonic 1-cochain
+ identified (not physical circulation; not ``koopman="hodge"``;
+ not TopologicX)
+ * - `53_latent_rank_selection.ipynb `_
+ - ``select_latent_rank`` recovers oracle rank 3 on a linear
+ Gaussian embedding when each criterion uses a matching
+ excitation (not Ray Tune for ``latent_dim``)
+ * - `54_criticality_monitor.ipynb `_
+ - Closing-gap diagonal toy: ``monitor_critical_transition`` score
+ rises versus a constant-gap control (not a Ghosh certificate)
* - `40_resdmd_pseudospectra.ipynb `_
- Finite-dictionary ResDMD MVP + resolvent-norm grid (≠ ``spectral_residuals``; not infinite-dim certificates)
* - `46_representation_explain.ipynb `_
@@ -101,9 +130,15 @@ Analysis and stability
* - `37_cross_topology_transfer.ipynb `_
- Measured zero-shot :math:`N_1\to N_2` transfer via ``evaluate_topology_transfer`` (mandatory ``pernode``; negative advantage allowed; adaptive/orbit/isotypic excluded)
* - `38_operator_factorization_ablation.ipynb `_
- - Factorized :math:`I\otimes K_{\mathrm{self}}+\hat{A}\otimes K_{\mathrm{nbr}}` vs joint :math:`Nd\times Nd` latent map (MSE, params, spectrum)
+ - One-tap factorized :math:`I\otimes K_{\mathrm{self}}+\hat{A}\otimes K_{\mathrm{nbr}}` vs joint :math:`Nd\times Nd` latent map (MSE, params, spectrum), plus a hop-matched :math:`P\in\{0,1,2\}` arm (40 epochs; not the 80-epoch historical protocol; hop order is not claimed as the cause of the joint-LS gap)
+ * - `49_multi_hop_factorization.ipynb `_
+ - Kronecker polynomial spectrum versus dense :math:`K_{\mathrm{eff}}` on a tiny :math:`P=2` path, plus a hop-mismatch warning smoke fit (not a forecasting contest)
* - `41_node_churn_presence_masks.ipynb `_
- Fixed-union presence-mask churn (:math:`N_{\max}`) vs observation masks; losses ignore inactive nodes
+ * - `50_graph_state_closure.ipynb `_
+ - Opt-in predicted topology versus hold-last on a synthetic structural
+ event (dense stub wiring check, not a learned-forecast claim;
+ permutation / ``EntityRemap`` note)
* - `43_tdl_sheaf_cell_ablation.ipynb `_
- Sheaf / cell-complex / simplicial-1 / GNN encode ablation with the same linear Koopman head
* - `44_graphvamp_md.ipynb `_
@@ -210,4 +245,13 @@ Related pages
* :doc:`capabilities` — feature and dataset inventory
* :doc:`quickstart` — minimal train/predict script
* :doc:`api` — API reference
-* :doc:`graphon` — graphon sampling for transfer at multiple :math:`N`
+* :doc:`graphon` — graphon sampling, dense teaching estimation, and continuum-limit citations
+* :doc:`benchmarks` — identity-bound manifests (example 47)
+* :doc:`identification` — closed-form :math:`K` and rank grid
+ (examples 48 / 53)
+* :doc:`spectral_diagnostics` — Nyquist and non-normal toys (example 51)
+* :doc:`graph_dynamics` — predicted topology versus hold-last (example 50)
+* :doc:`matrix_free` — ``LinearOperatorProtocol`` wrappers
+* :doc:`criticality` — gap-closure monitor (example 54)
+* :doc:`time_conditioning` — :math:`(\mu, t, u)` records and irregular
+ :math:`\Delta t`
diff --git a/examples/22_gnn_forecaster_comparison.ipynb b/examples/22_gnn_forecaster_comparison.ipynb
index 34e88d1..0e97ba0 100644
--- a/examples/22_gnn_forecaster_comparison.ipynb
+++ b/examples/22_gnn_forecaster_comparison.ipynb
@@ -1,618 +1,625 @@
{
- "cells": [
- {
- "cell_type": "markdown",
- "id": "22-0",
- "metadata": {},
- "source": [
- "# GNN Forecaster Comparison on METR-LA\n",
- "\n",
- "This notebook assumes the encode → Koopman step → decode workflow from\n",
- "[`01_synthetic_graph.ipynb`](01_synthetic_graph.ipynb) and the METR-LA protocol\n",
- "from [`03_traffic_network.ipynb`](03_traffic_network.ipynb); here the comparison\n",
- "is against nonlinear GNN forecasters.\n",
- "\n",
- "KoopmanGraph is motivated against **nonlinear spatiotemporal GNN\n",
- "forecasters** that learn convolutional and/or recurrent maps on graphs, rather\n",
- "than enforcing linear evolution in a learned observable space.\n",
- "KoopmanGraph instead learns a linear latent advance of the form\n",
- "$z \\leftarrow z K^{\\top}$ (row-state convention). Until this notebook, the only\n",
- "in-repo baselines were classical **vector** DMD/EDMD — useful, but not the\n",
- "comparison that motivation names.\n",
- "\n",
- "> **Primary question:** under a shared METR-LA train/test split and horizon set,\n",
- "> can a **rollout-trained** `GraphKoopmanModel` compete with lightweight STGCN /\n",
- "> DCRNN / Graph WaveNet reference forecasters — while still exposing a linear\n",
- "> latent operator (and spectrum) those GNN baselines lack?\n",
- "\n",
- "We answer that with the packaged METR-LA weekday cache and shared\n",
- "`evaluate_forecast` scoring. GraphKoopman uses the stronger METR-LA training\n",
- "recipe from notebook 03 (wider latent model, ODO-bounded operator, ramp into\n",
- "multi-step rollout loss, validation early stopping). The GNN models remain\n",
- "**in-repo teaching baselines** in the lineage of Yu et al. (2018), Li et al.\n",
- "(2018), and Wu et al. (2019) — not bit-exact dedicated traffic-library\n",
- "reproductions.\n"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "22-1",
- "metadata": {},
- "source": [
- "## Domain context (for non-experts)\n",
- "\n",
- "METR-LA is a highway traffic sensor graph: nodes are loop detectors and edges\n",
- "connect nearby sensors. Speeds evolve over time with strong spatial coupling —\n",
- "a natural setting for graph forecasters.\n"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "22-2",
- "metadata": {},
- "source": [
- "## Method: shared METR-LA split and horizons\n",
- "\n",
- "Compare a rollout-trained `GraphKoopmanModel` to lightweight STGCN / DCRNN /\n",
- "Graph WaveNet teaching baselines under the same chronological split and\n",
- "`evaluate_forecast` scoring. GNN models are in-repo teaching baselines — not\n",
- "bit-exact published SOTA reproductions.\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 1,
- "id": "22-3",
- "metadata": {
- "execution": {
- "iopub.execute_input": "2026-07-17T17:09:22.595411Z",
- "iopub.status.busy": "2026-07-17T17:09:22.595218Z",
- "iopub.status.idle": "2026-07-17T17:09:26.609079Z",
- "shell.execute_reply": "2026-07-17T17:09:26.608629Z"
- }
- },
- "outputs": [],
- "source": [
- "import os\n",
- "import random\n",
- "import warnings\n",
- "\n",
- "import matplotlib.pyplot as plt\n",
- "import numpy as np\n",
- "import torch\n",
- "from matplotlib.patches import Patch\n",
- "\n",
- "from koopman_graph import (\n",
- " GNNDecoder,\n",
- " GNNEncoder,\n",
- " GraphKoopmanModel,\n",
- ")\n",
- "from koopman_graph.data import temporal_split\n",
- "from koopman_graph.metrics import evaluate_forecast\n",
- "from koopman_graph.baselines.gnn import (\n",
- " DCRNNBaseline,\n",
- " GraphWaveNetBaseline,\n",
- " STGCNBaseline,\n",
- ")\n",
- "from koopman_graph.datasets import MetrLaTrafficBenchmark\n",
- "from koopman_graph.training import constant_loss_weights, linear_ramp_loss_weights\n",
- "\n",
- "warnings.filterwarnings(\"ignore\", category=UserWarning)\n",
- "\n",
- "SEED = 0\n",
- "random.seed(SEED)\n",
- "np.random.seed(SEED)\n",
- "torch.manual_seed(SEED)\n",
- "\n",
- "IS_TEST = bool(os.environ.get(\"PYTEST_CURRENT_TEST\"))\n",
- "# GraphKoopman: notebook-03-style capacity / budget (early-stops on val).\n",
- "# GNN refs: shared teaching width; shorter epoch budget for interactive CPU runs.\n",
- "EPOCHS = 5 if IS_TEST else 120\n",
- "GNN_EPOCHS = 5 if IS_TEST else 40\n",
- "RAMP_EPOCHS = 3 if IS_TEST else 40\n",
- "GK_HIDDEN = 16 if IS_TEST else 64\n",
- "GK_LATENT = 16 if IS_TEST else 64\n",
- "GNN_HIDDEN = 8 if IS_TEST else 32\n",
- "HORIZONS = (1, 2) if IS_TEST else (3, 6, 12)\n",
- "LR_GK = 1.5e-3\n",
- "LR_GNN = 1e-3\n"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "22-4",
- "metadata": {},
- "source": [
- "## Load METR-LA data\n",
- "\n",
- "Packaged weekday cache, shared train/test split, and horizon set.\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 2,
- "id": "22-5",
- "metadata": {
- "execution": {
- "iopub.execute_input": "2026-07-17T17:09:26.610658Z",
- "iopub.status.busy": "2026-07-17T17:09:26.610494Z",
- "iopub.status.idle": "2026-07-17T17:09:26.641247Z",
- "shell.execute_reply": "2026-07-17T17:09:26.640859Z"
- }
- },
- "outputs": [
+ "cells": [
{
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "sensors=207, edges=2833, full cache T=288\n",
- "train/val/test timesteps: 201 / 28 / 59\n",
- "epochs GK=120 / GNN=40, ramp=40, GK hidden/latent=64/64, GNN hidden=32, horizons=(3, 6, 12), rollout_horizon=12\n"
- ]
- }
- ],
- "source": [
- "topology = MetrLaTrafficBenchmark.load_topology()\n",
- "sequence = MetrLaTrafficBenchmark.load_sequence()\n",
- "split = temporal_split(sequence)\n",
- "train_seq = split.train\n",
- "val_seq = split.val\n",
- "test_seq = split.test\n",
- "\n",
- "if IS_TEST:\n",
- " train_seq = train_seq.slice(0, min(24, train_seq.num_timesteps))\n",
- " val_seq = val_seq.slice(0, min(12, val_seq.num_timesteps))\n",
- " test_seq = test_seq.slice(0, min(16, test_seq.num_timesteps))\n",
- "\n",
- "max_horizon = max(HORIZONS)\n",
- "rollout_horizon = min(\n",
- " max_horizon,\n",
- " train_seq.num_timesteps - 1,\n",
- " val_seq.num_timesteps - 1,\n",
- ")\n",
- "\n",
- "print(\n",
- " f\"sensors={topology.num_nodes}, edges={topology.edge_index.shape[1]}, \"\n",
- " f\"full cache T={sequence.num_timesteps}\"\n",
- ")\n",
- "print(\n",
- " f\"train/val/test timesteps: \"\n",
- " f\"{train_seq.num_timesteps} / {val_seq.num_timesteps} / {test_seq.num_timesteps}\"\n",
- ")\n",
- "print(\n",
- " f\"epochs GK={EPOCHS} / GNN={GNN_EPOCHS}, ramp={RAMP_EPOCHS}, \"\n",
- " f\"GK hidden/latent={GK_HIDDEN}/{GK_LATENT}, GNN hidden={GNN_HIDDEN}, \"\n",
- " f\"horizons={HORIZONS}, rollout_horizon={rollout_horizon}\"\n",
- ")\n"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "22-6",
- "metadata": {},
- "source": [
- "### Snapshot of the traffic field\n",
- "\n",
- "A few sensors from the train window — context for what the forecasters see\n",
- "before we compare models. Flat segments are missing-speed imputation artifacts\n",
- "in the METR-LA pipeline, not a plotting bug.\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 3,
- "id": "22-7",
- "metadata": {
- "execution": {
- "iopub.execute_input": "2026-07-17T17:09:26.642530Z",
- "iopub.status.busy": "2026-07-17T17:09:26.642392Z",
- "iopub.status.idle": "2026-07-17T17:09:26.720540Z",
- "shell.execute_reply": "2026-07-17T17:09:26.720060Z"
- }
- },
- "outputs": [
+ "cell_type": "markdown",
+ "id": "22-0",
+ "metadata": {},
+ "source": [
+ "# GNN Forecaster Comparison on METR-LA\n",
+ "\n",
+ "This notebook assumes the encode → Koopman step → decode workflow from\n",
+ "[`01_synthetic_graph.ipynb`](01_synthetic_graph.ipynb) and the METR-LA protocol\n",
+ "from [`03_traffic_network.ipynb`](03_traffic_network.ipynb); here the comparison\n",
+ "is against nonlinear GNN forecasters.\n",
+ "\n",
+ "KoopmanGraph is motivated against **nonlinear spatiotemporal GNN\n",
+ "forecasters** that learn convolutional and/or recurrent maps on graphs, rather\n",
+ "than enforcing linear evolution in a learned observable space.\n",
+ "KoopmanGraph instead learns a linear latent advance of the form\n",
+ "$z \\leftarrow z K^{\\top}$ (row-state convention). Until this notebook, the only\n",
+ "in-repo baselines were classical **vector** DMD/EDMD — useful, but not the\n",
+ "comparison that motivation names.\n",
+ "\n",
+ "> **Primary question:** under a shared METR-LA train/test split and horizon set,\n",
+ "> can a **rollout-trained** `GraphKoopmanModel` compete with lightweight STGCN /\n",
+ "> DCRNN / Graph WaveNet reference forecasters — while still exposing a linear\n",
+ "> latent operator (and spectrum) those GNN baselines lack?\n",
+ "\n",
+ "We answer that with the packaged METR-LA weekday cache and shared\n",
+ "`evaluate_forecast` scoring. GraphKoopman uses the stronger METR-LA training\n",
+ "recipe from notebook 03 (wider latent model, ODO-bounded operator, ramp into\n",
+ "multi-step rollout loss, validation early stopping). The GNN models remain\n",
+ "**in-repo teaching baselines** in the lineage of Yu et al. (2018), Li et al.\n",
+ "(2018), and Wu et al. (2019) — not bit-exact dedicated traffic-library\n",
+ "reproductions.\n"
+ ]
+ },
{
- "data": {
- "image/png": "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",
- "text/plain": [
- ""
+ "cell_type": "markdown",
+ "id": "22-1",
+ "metadata": {},
+ "source": [
+ "## Domain context (for non-experts)\n",
+ "\n",
+ "METR-LA is a highway traffic sensor graph: nodes are loop detectors and edges\n",
+ "connect nearby sensors. Speeds evolve over time with strong spatial coupling —\n",
+ "a natural setting for graph forecasters.\n"
]
- },
- "metadata": {},
- "output_type": "display_data"
- }
- ],
- "source": [
- "sensor_ids = [0, 25, 75, 150]\n",
- "times = range(train_seq.num_timesteps)\n",
- "fig, ax = plt.subplots(figsize=(9, 3.5))\n",
- "for sid in sensor_ids:\n",
- " series = [float(train_seq[t].x[sid, 0]) for t in times]\n",
- " ax.plot(times, series, label=f\"sensor {sid}\", linewidth=1.2)\n",
- "ax.set_xlabel(\"train timestep\")\n",
- "ax.set_ylabel(\"z-scored speed\")\n",
- "ax.set_title(\"METR-LA train-window sensor traces\")\n",
- "ax.legend(loc=\"upper right\", fontsize=8, ncol=2)\n",
- "ax.grid(True, alpha=0.3)\n",
- "plt.tight_layout()\n",
- "plt.show()\n"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "22-8",
- "metadata": {},
- "source": [
- "## Train\n",
- "\n",
- "Fit GraphKoopman (ODO + rollout ramp + early stopping) and the GNN teaching baselines.\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 4,
- "id": "22-9",
- "metadata": {
- "execution": {
- "iopub.execute_input": "2026-07-17T17:09:26.721614Z",
- "iopub.status.busy": "2026-07-17T17:09:26.721520Z",
- "iopub.status.idle": "2026-07-17T17:21:46.171881Z",
- "shell.execute_reply": "2026-07-17T17:21:46.171310Z"
- }
- },
- "outputs": [
+ },
{
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Fitting GraphKoopman (120 epochs max, early stopping)...\n",
- " done (37 epochs tracked)\n",
- "Fitting STGCN (40 epochs)...\n",
- " done\n",
- "Fitting DCRNN (40 epochs)...\n",
- " done\n",
- "Fitting GraphWaveNet (40 epochs, adaptive adj)...\n",
- " done\n",
- "GraphKoopman final train loss=1.1397; best epoch tracked via early stopping / restore_best_weights\n",
- "Parameter counts:\n",
- " GraphKoopman: 16,769\n",
- " STGCN: 21,089\n",
- " DCRNN: 31,905\n",
- " GraphWaveNet: 47,681\n",
- "fitted: GraphKoopman, STGCN, DCRNN, GraphWaveNet\n"
- ]
- }
- ],
- "source": [
- "random.seed(SEED)\n",
- "np.random.seed(SEED)\n",
- "torch.manual_seed(SEED)\n",
- "# time_step is recorded for spectrum/API consistency; inert for this discrete-time fit.\n",
- "koopman = GraphKoopmanModel(\n",
- " encoder=GNNEncoder(1, GK_HIDDEN, GK_LATENT),\n",
- " decoder=GNNDecoder(GK_LATENT, GK_HIDDEN, 1),\n",
- " latent_dim=GK_LATENT,\n",
- " time_step=5.0,\n",
- " koopman_parameterization=\"odo\",\n",
- " koopman_max_spectral_radius=1.05,\n",
- ")\n",
- "\n",
- "loss_schedule = linear_ramp_loss_weights(\n",
- " constant_loss_weights(\n",
- " reconstruction=1.0, forward=1.0, rollout=0.25, eigenvalue=0.05\n",
- " ),\n",
- " constant_loss_weights(\n",
- " reconstruction=1.0, forward=0.5, rollout=2.0, eigenvalue=0.05\n",
- " ),\n",
- " ramp_epochs=RAMP_EPOCHS,\n",
- ")\n",
- "\n",
- "print(f\"Fitting GraphKoopman ({EPOCHS} epochs max, early stopping)...\")\n",
- "history = koopman.fit(\n",
- " train_seq,\n",
- " validation_sequence=val_seq,\n",
- " epochs=EPOCHS,\n",
- " lr=LR_GK,\n",
- " loss_weight_schedule=loss_schedule,\n",
- " rollout_horizon=rollout_horizon,\n",
- " max_grad_norm=1.0,\n",
- " early_stopping_patience=30 if not IS_TEST else 5,\n",
- " restore_best_weights=True,\n",
- ")\n",
- "print(f\" done ({len(history.loss)} epochs tracked)\")\n",
- "\n",
- "random.seed(SEED)\n",
- "np.random.seed(SEED)\n",
- "torch.manual_seed(SEED)\n",
- "print(f\"Fitting STGCN ({GNN_EPOCHS} epochs)...\")\n",
- "stgcn = STGCNBaseline(\n",
- " 1,\n",
- " GNN_HIDDEN,\n",
- " 1,\n",
- " num_st_blocks=1 if IS_TEST else 2,\n",
- " history_len=2 if IS_TEST else 3,\n",
- ").fit(train_seq, epochs=GNN_EPOCHS, lr=LR_GNN)\n",
- "print(\" done\")\n",
- "\n",
- "print(f\"Fitting DCRNN ({GNN_EPOCHS} epochs)...\")\n",
- "dcrnn = DCRNNBaseline(\n",
- " 1,\n",
- " GNN_HIDDEN,\n",
- " 1,\n",
- " diffusion_steps=1 if IS_TEST else 2,\n",
- " history_len=1,\n",
- ").fit(train_seq, epochs=GNN_EPOCHS, lr=LR_GNN)\n",
- "print(\" done\")\n",
- "\n",
- "print(\n",
- " f\"Fitting GraphWaveNet ({GNN_EPOCHS} epochs\"\n",
- " f\"{', adaptive adj' if not IS_TEST else ''})...\"\n",
- ")\n",
- "wavenet = GraphWaveNetBaseline(\n",
- " 1,\n",
- " GNN_HIDDEN,\n",
- " 1,\n",
- " num_layers=2 if IS_TEST else 4,\n",
- " history_len=2 if IS_TEST else 4,\n",
- " adaptive_adj=not IS_TEST,\n",
- ").fit(train_seq, epochs=GNN_EPOCHS, lr=LR_GNN)\n",
- "print(\" done\")\n",
- "\n",
- "candidates = {\n",
- " \"GraphKoopman\": koopman,\n",
- " \"STGCN\": stgcn,\n",
- " \"DCRNN\": dcrnn,\n",
- " \"GraphWaveNet\": wavenet,\n",
- "}\n",
- "\n",
- "print(\n",
- " f\"GraphKoopman final train loss={history.loss[-1]:.4f}; \"\n",
- " f\"best epoch tracked via early stopping / restore_best_weights\"\n",
- ")\n",
- "print(\"Parameter counts:\")\n",
- "for name, model in candidates.items():\n",
- " n_params = sum(p.numel() for p in model.parameters())\n",
- " print(f\" {name:>12}: {n_params:,}\")\n",
- "print(\"fitted:\", \", \".join(candidates))\n"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "22-10",
- "metadata": {},
- "source": [
- "## Evaluate on the held-out test split\n",
- "\n",
- "Aggregate and per-horizon RMSE under the shared protocol.\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 5,
- "id": "22-11",
- "metadata": {
- "execution": {
- "iopub.execute_input": "2026-07-17T17:21:46.174109Z",
- "iopub.status.busy": "2026-07-17T17:21:46.173998Z",
- "iopub.status.idle": "2026-07-17T17:22:02.534716Z",
- "shell.execute_reply": "2026-07-17T17:22:02.534322Z"
- }
- },
- "outputs": [
+ "cell_type": "markdown",
+ "id": "22-2",
+ "metadata": {},
+ "source": [
+ "## Method: shared METR-LA split and horizons\n",
+ "\n",
+ "Compare a rollout-trained `GraphKoopmanModel` to lightweight STGCN / DCRNN /\n",
+ "Graph WaveNet teaching baselines under the same chronological split and\n",
+ "`evaluate_forecast` scoring. GNN models are in-repo teaching baselines — not\n",
+ "bit-exact published SOTA reproductions.\n"
+ ]
+ },
{
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Multi-horizon test RMSE (z-scored speed; lower is better)\n",
- " model h3 h6 h12 aggregate\n",
- " GraphKoopman 0.6226 0.6318 0.7110 0.6551\n",
- " STGCN 0.5726 0.6696 0.8807 0.7076\n",
- " DCRNN 0.7545 1.0566 1.4150 1.0754\n",
- " GraphWaveNet 0.6427 0.8748 1.1935 0.9036\n",
- "Aggregate ranking (best → worst): GraphKoopman < STGCN < GraphWaveNet < DCRNN\n"
- ]
- }
- ],
- "source": [
- "rows = []\n",
- "for name, model in candidates.items():\n",
- " result = evaluate_forecast(model, test_seq, horizons=HORIZONS)\n",
- " row = {\"model\": name, \"aggregate\": result.aggregate_rmse}\n",
- " for metric in result.horizons:\n",
- " row[f\"h{metric.horizon}\"] = metric.rmse\n",
- " rows.append(row)\n",
- "\n",
- "header = [\"model\", *[f\"h{h}\" for h in HORIZONS], \"aggregate\"]\n",
- "print(\"Multi-horizon test RMSE (z-scored speed; lower is better)\")\n",
- "print(\" \".join(f\"{col:>14}\" for col in header))\n",
- "for row in rows:\n",
- " cells_out = []\n",
- " for col in header:\n",
- " if col == \"model\":\n",
- " cells_out.append(f\"{row['model']:>14}\")\n",
- " else:\n",
- " cells_out.append(f\"{row[col]:14.4f}\")\n",
- " print(\" \".join(cells_out))\n",
- "\n",
- "ranked = sorted(rows, key=lambda r: r[\"aggregate\"])\n",
- "print(\n",
- " \"Aggregate ranking (best → worst): \"\n",
- " + \" < \".join(r[\"model\"] for r in ranked)\n",
- ")\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 6,
- "id": "22-12",
- "metadata": {
- "execution": {
- "iopub.execute_input": "2026-07-17T17:22:02.535907Z",
- "iopub.status.busy": "2026-07-17T17:22:02.535835Z",
- "iopub.status.idle": "2026-07-17T17:22:02.589299Z",
- "shell.execute_reply": "2026-07-17T17:22:02.588938Z"
- }
- },
- "outputs": [
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "22-3",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-07-17T17:09:22.595411Z",
+ "iopub.status.busy": "2026-07-17T17:09:22.595218Z",
+ "iopub.status.idle": "2026-07-17T17:09:26.609079Z",
+ "shell.execute_reply": "2026-07-17T17:09:26.608629Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "import os\n",
+ "import random\n",
+ "import warnings\n",
+ "\n",
+ "import matplotlib.pyplot as plt\n",
+ "import numpy as np\n",
+ "import torch\n",
+ "from matplotlib.patches import Patch\n",
+ "\n",
+ "from koopman_graph import (\n",
+ " GNNDecoder,\n",
+ " GNNEncoder,\n",
+ " GraphKoopmanModel,\n",
+ ")\n",
+ "from koopman_graph.data import temporal_split\n",
+ "from koopman_graph.metrics import evaluate_forecast\n",
+ "from koopman_graph.baselines.gnn import (\n",
+ " DCRNNBaseline,\n",
+ " GraphWaveNetBaseline,\n",
+ " STGCNBaseline,\n",
+ ")\n",
+ "from koopman_graph.datasets import MetrLaTrafficBenchmark\n",
+ "from koopman_graph.training import constant_loss_weights, linear_ramp_loss_weights\n",
+ "\n",
+ "warnings.filterwarnings(\"ignore\", category=UserWarning)\n",
+ "\n",
+ "SEED = 0\n",
+ "random.seed(SEED)\n",
+ "np.random.seed(SEED)\n",
+ "torch.manual_seed(SEED)\n",
+ "\n",
+ "IS_TEST = bool(os.environ.get(\"PYTEST_CURRENT_TEST\"))\n",
+ "# GraphKoopman: notebook-03-style capacity / budget (early-stops on val).\n",
+ "# GNN refs: shared teaching width; shorter epoch budget for interactive CPU runs.\n",
+ "EPOCHS = 5 if IS_TEST else 120\n",
+ "GNN_EPOCHS = 5 if IS_TEST else 40\n",
+ "RAMP_EPOCHS = 3 if IS_TEST else 40\n",
+ "GK_HIDDEN = 16 if IS_TEST else 64\n",
+ "GK_LATENT = 16 if IS_TEST else 64\n",
+ "GNN_HIDDEN = 8 if IS_TEST else 32\n",
+ "HORIZONS = (1, 2) if IS_TEST else (3, 6, 12)\n",
+ "LR_GK = 1.5e-3\n",
+ "LR_GNN = 1e-3\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "22-4",
+ "metadata": {},
+ "source": [
+ "## Load METR-LA data\n",
+ "\n",
+ "Packaged weekday cache, shared train/test split, and horizon set.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "22-5",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-07-17T17:09:26.610658Z",
+ "iopub.status.busy": "2026-07-17T17:09:26.610494Z",
+ "iopub.status.idle": "2026-07-17T17:09:26.641247Z",
+ "shell.execute_reply": "2026-07-17T17:09:26.640859Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "sensors=207, edges=2833, full cache T=288\n",
+ "train/val/test timesteps: 201 / 28 / 59\n",
+ "epochs GK=120 / GNN=40, ramp=40, GK hidden/latent=64/64, GNN hidden=32, horizons=(3, 6, 12), rollout_horizon=12\n"
+ ]
+ }
+ ],
+ "source": [
+ "topology = MetrLaTrafficBenchmark.load_topology()\n",
+ "sequence = MetrLaTrafficBenchmark.load_sequence()\n",
+ "split = temporal_split(sequence)\n",
+ "train_seq = split.train\n",
+ "val_seq = split.val\n",
+ "test_seq = split.test\n",
+ "\n",
+ "if IS_TEST:\n",
+ " train_seq = train_seq.slice(0, min(24, train_seq.num_timesteps))\n",
+ " val_seq = val_seq.slice(0, min(12, val_seq.num_timesteps))\n",
+ " test_seq = test_seq.slice(0, min(16, test_seq.num_timesteps))\n",
+ "\n",
+ "max_horizon = max(HORIZONS)\n",
+ "rollout_horizon = min(\n",
+ " max_horizon,\n",
+ " train_seq.num_timesteps - 1,\n",
+ " val_seq.num_timesteps - 1,\n",
+ ")\n",
+ "\n",
+ "print(\n",
+ " f\"sensors={topology.num_nodes}, edges={topology.edge_index.shape[1]}, \"\n",
+ " f\"full cache T={sequence.num_timesteps}\"\n",
+ ")\n",
+ "print(\n",
+ " f\"train/val/test timesteps: \"\n",
+ " f\"{train_seq.num_timesteps} / {val_seq.num_timesteps} / {test_seq.num_timesteps}\"\n",
+ ")\n",
+ "print(\n",
+ " f\"epochs GK={EPOCHS} / GNN={GNN_EPOCHS}, ramp={RAMP_EPOCHS}, \"\n",
+ " f\"GK hidden/latent={GK_HIDDEN}/{GK_LATENT}, GNN hidden={GNN_HIDDEN}, \"\n",
+ " f\"horizons={HORIZONS}, rollout_horizon={rollout_horizon}\"\n",
+ ")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "22-6",
+ "metadata": {},
+ "source": [
+ "### Snapshot of the traffic field\n",
+ "\n",
+ "A few sensors from the train window — context for what the forecasters see\n",
+ "before we compare models. Flat segments are missing-speed imputation artifacts\n",
+ "in the METR-LA pipeline, not a plotting bug.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "22-7",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-07-17T17:09:26.642530Z",
+ "iopub.status.busy": "2026-07-17T17:09:26.642392Z",
+ "iopub.status.idle": "2026-07-17T17:09:26.720540Z",
+ "shell.execute_reply": "2026-07-17T17:09:26.720060Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "sensor_ids = [0, 25, 75, 150]\n",
+ "times = range(train_seq.num_timesteps)\n",
+ "fig, ax = plt.subplots(figsize=(9, 3.5))\n",
+ "for sid in sensor_ids:\n",
+ " series = [float(train_seq[t].x[sid, 0]) for t in times]\n",
+ " ax.plot(times, series, label=f\"sensor {sid}\", linewidth=1.2)\n",
+ "ax.set_xlabel(\"train timestep\")\n",
+ "ax.set_ylabel(\"z-scored speed\")\n",
+ "ax.set_title(\"METR-LA train-window sensor traces\")\n",
+ "ax.legend(loc=\"upper right\", fontsize=8, ncol=2)\n",
+ "ax.grid(True, alpha=0.3)\n",
+ "plt.tight_layout()\n",
+ "plt.show()\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "22-8",
+ "metadata": {},
+ "source": [
+ "## Train\n",
+ "\n",
+ "Fit GraphKoopman (ODO + rollout ramp + early stopping) and the GNN teaching baselines.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "22-9",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-07-17T17:09:26.721614Z",
+ "iopub.status.busy": "2026-07-17T17:09:26.721520Z",
+ "iopub.status.idle": "2026-07-17T17:21:46.171881Z",
+ "shell.execute_reply": "2026-07-17T17:21:46.171310Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Fitting GraphKoopman (120 epochs max, early stopping)...\n",
+ " done (37 epochs tracked)\n",
+ "Fitting STGCN (40 epochs)...\n",
+ " done\n",
+ "Fitting DCRNN (40 epochs)...\n",
+ " done\n",
+ "Fitting GraphWaveNet (40 epochs, adaptive adj)...\n",
+ " done\n",
+ "GraphKoopman final train loss=1.1397; best epoch tracked via early stopping / restore_best_weights\n",
+ "Parameter counts:\n",
+ " GraphKoopman: 16,769\n",
+ " STGCN: 21,089\n",
+ " DCRNN: 31,905\n",
+ " GraphWaveNet: 47,681\n",
+ "fitted: GraphKoopman, STGCN, DCRNN, GraphWaveNet\n"
+ ]
+ }
+ ],
+ "source": [
+ "random.seed(SEED)\n",
+ "np.random.seed(SEED)\n",
+ "torch.manual_seed(SEED)\n",
+ "# time_step is recorded for spectrum/API consistency; inert for this discrete-time fit.\n",
+ "koopman = GraphKoopmanModel(\n",
+ " encoder=GNNEncoder(1, GK_HIDDEN, GK_LATENT),\n",
+ " decoder=GNNDecoder(GK_LATENT, GK_HIDDEN, 1),\n",
+ " latent_dim=GK_LATENT,\n",
+ " time_step=5.0,\n",
+ " koopman_parameterization=\"odo\",\n",
+ " koopman_max_spectral_radius=1.05,\n",
+ ")\n",
+ "\n",
+ "loss_schedule = linear_ramp_loss_weights(\n",
+ " constant_loss_weights(\n",
+ " reconstruction=1.0, forward=1.0, rollout=0.25, eigenvalue=0.05\n",
+ " ),\n",
+ " constant_loss_weights(\n",
+ " reconstruction=1.0, forward=0.5, rollout=2.0, eigenvalue=0.05\n",
+ " ),\n",
+ " ramp_epochs=RAMP_EPOCHS,\n",
+ ")\n",
+ "\n",
+ "print(f\"Fitting GraphKoopman ({EPOCHS} epochs max, early stopping)...\")\n",
+ "history = koopman.fit(\n",
+ " train_seq,\n",
+ " validation_sequence=val_seq,\n",
+ " epochs=EPOCHS,\n",
+ " lr=LR_GK,\n",
+ " loss_weight_schedule=loss_schedule,\n",
+ " rollout_horizon=rollout_horizon,\n",
+ " max_grad_norm=1.0,\n",
+ " early_stopping_patience=30 if not IS_TEST else 5,\n",
+ " restore_best_weights=True,\n",
+ ")\n",
+ "print(f\" done ({len(history.loss)} epochs tracked)\")\n",
+ "\n",
+ "random.seed(SEED)\n",
+ "np.random.seed(SEED)\n",
+ "torch.manual_seed(SEED)\n",
+ "print(f\"Fitting STGCN ({GNN_EPOCHS} epochs)...\")\n",
+ "stgcn = STGCNBaseline(\n",
+ " 1,\n",
+ " GNN_HIDDEN,\n",
+ " 1,\n",
+ " num_st_blocks=1 if IS_TEST else 2,\n",
+ " history_len=2 if IS_TEST else 3,\n",
+ ").fit(train_seq, epochs=GNN_EPOCHS, lr=LR_GNN)\n",
+ "print(\" done\")\n",
+ "\n",
+ "print(f\"Fitting DCRNN ({GNN_EPOCHS} epochs)...\")\n",
+ "dcrnn = DCRNNBaseline(\n",
+ " 1,\n",
+ " GNN_HIDDEN,\n",
+ " 1,\n",
+ " diffusion_steps=1 if IS_TEST else 2,\n",
+ " history_len=1,\n",
+ ").fit(train_seq, epochs=GNN_EPOCHS, lr=LR_GNN)\n",
+ "print(\" done\")\n",
+ "\n",
+ "print(\n",
+ " f\"Fitting GraphWaveNet ({GNN_EPOCHS} epochs\"\n",
+ " f\"{', adaptive adj' if not IS_TEST else ''})...\"\n",
+ ")\n",
+ "wavenet = GraphWaveNetBaseline(\n",
+ " 1,\n",
+ " GNN_HIDDEN,\n",
+ " 1,\n",
+ " num_layers=2 if IS_TEST else 4,\n",
+ " history_len=2 if IS_TEST else 4,\n",
+ " adaptive_adj=not IS_TEST,\n",
+ ").fit(train_seq, epochs=GNN_EPOCHS, lr=LR_GNN)\n",
+ "print(\" done\")\n",
+ "\n",
+ "candidates = {\n",
+ " \"GraphKoopman\": koopman,\n",
+ " \"STGCN\": stgcn,\n",
+ " \"DCRNN\": dcrnn,\n",
+ " \"GraphWaveNet\": wavenet,\n",
+ "}\n",
+ "\n",
+ "print(\n",
+ " f\"GraphKoopman final train loss={history.loss[-1]:.4f}; \"\n",
+ " f\"best epoch tracked via early stopping / restore_best_weights\"\n",
+ ")\n",
+ "print(\"Parameter counts:\")\n",
+ "for name, model in candidates.items():\n",
+ " n_params = sum(p.numel() for p in model.parameters())\n",
+ " print(f\" {name:>12}: {n_params:,}\")\n",
+ "print(\"fitted:\", \", \".join(candidates))\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "22-10",
+ "metadata": {},
+ "source": [
+ "## Evaluate on the held-out test split\n",
+ "\n",
+ "Aggregate and per-horizon RMSE under the shared protocol.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "22-11",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-07-17T17:21:46.174109Z",
+ "iopub.status.busy": "2026-07-17T17:21:46.173998Z",
+ "iopub.status.idle": "2026-07-17T17:22:02.534716Z",
+ "shell.execute_reply": "2026-07-17T17:22:02.534322Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Multi-horizon test RMSE (z-scored speed; lower is better)\n",
+ " model h3 h6 h12 aggregate\n",
+ " GraphKoopman 0.6226 0.6318 0.7110 0.6551\n",
+ " STGCN 0.5726 0.6696 0.8807 0.7076\n",
+ " DCRNN 0.7545 1.0566 1.4150 1.0754\n",
+ " GraphWaveNet 0.6427 0.8748 1.1935 0.9036\n",
+ "Aggregate ranking (best → worst): GraphKoopman < STGCN < GraphWaveNet < DCRNN\n"
+ ]
+ }
+ ],
+ "source": [
+ "rows = []\n",
+ "for name, model in candidates.items():\n",
+ " result = evaluate_forecast(model, test_seq, horizons=HORIZONS)\n",
+ " row = {\"model\": name, \"aggregate\": result.aggregate_rmse}\n",
+ " for metric in result.horizons:\n",
+ " row[f\"h{metric.horizon}\"] = metric.rmse\n",
+ " rows.append(row)\n",
+ "\n",
+ "header = [\"model\", *[f\"h{h}\" for h in HORIZONS], \"aggregate\"]\n",
+ "print(\"Multi-horizon test RMSE (z-scored speed; lower is better)\")\n",
+ "print(\" \".join(f\"{col:>14}\" for col in header))\n",
+ "for row in rows:\n",
+ " cells_out = []\n",
+ " for col in header:\n",
+ " if col == \"model\":\n",
+ " cells_out.append(f\"{row['model']:>14}\")\n",
+ " else:\n",
+ " cells_out.append(f\"{row[col]:14.4f}\")\n",
+ " print(\" \".join(cells_out))\n",
+ "\n",
+ "ranked = sorted(rows, key=lambda r: r[\"aggregate\"])\n",
+ "print(\n",
+ " \"Aggregate ranking (best → worst): \"\n",
+ " + \" < \".join(r[\"model\"] for r in ranked)\n",
+ ")\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "22-12",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-07-17T17:22:02.535907Z",
+ "iopub.status.busy": "2026-07-17T17:22:02.535835Z",
+ "iopub.status.idle": "2026-07-17T17:22:02.589299Z",
+ "shell.execute_reply": "2026-07-17T17:22:02.588938Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": "iVBORw0KGgoAAAANSUhEUgAABEEAAAGICAYAAACuvv4PAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlcelbwAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzsnQd4FNXbxd/d9AIkISQhlITeBUEREUQECyiK2EBAxILY+ItgVxQbKmDDgogd7Iq9URQb2FCp0hMIgRQS0vvu95wbJ9+W2c1uspuye348w25m7tw7bWfmnvsWg9lsNgshhBBCCCGEEEKIj2Ns7A0ghBBCCCGEEEIIaQgoghBCCCGEEEIIIcQvoAhCCCGEEEIIIYQQv4AiCCGEEEIIIYQQQvwCiiCEEEIIIYQQQgjxCyiCEEIIIYQQQgghxC+gCEIIIYQQQgghhBC/gCIIIYQQQgghhBBC/AKKIIQQQgghbnLs2DGZOXOmfP/9935x7G655RZ566233F6vrKxMHadvvvnGK9tFCCGEuEug22sQQgghXmLZsmWyadMmiYiIkMWLF+uWmTNnjhQVFcmJJ54oV111ld26jpg/f77s2bNH3nzzTZe25fHHH5d33nnHqs6QkBBp3769jBs3Tnr27OlSPb/99pu88sorct1110n//v1dWue2226T/Px89dm5c2eX1sExwbG56KKLZPTo0eIt6tLODTfcIFVVVeq7wWBQ57dXr14yYcIEiY6Odlj+5JNPlmnTptkt37VrlzzxxBPq+9VXXy0nnHCC1fK9e/fKF198IQcOHJAWLVpI79695dxzz5WwsDCH22VLQkKC3H///Q73qbCwUF588UXp27evnHbaaeLr4BouLi6Wyy67zK31Kioq1HFKTk6Ws846y2vbRwghhLgKRRBCCCFNhm+//VY+/PBD9f2CCy6QYcOGWS3/4Ycfajq/GIm3FEG0dV944QXduiFgtG7dWgYMGGA1XxMnMFptSVBQkF2deXl58sknn8gdd9yhRJqbb7651n1Chx2dwLPPPtslEeSXX36RhQsXKrGgVatW8thjj4krlJSUqHa6du3qVRGkLu1oYoF2jA8dOiT33nuv3H777bJ69Wo5/vjj7cpDnPj4449l8uTJEhho/bqydOlSVQZAgLAUQebOnStPP/20Ot6nnHKK6oTPmzdPZsyYoQQwiCGOtssSPXGGEEIIIc0fiiCEEEKaFLASwKjxa6+9ZieCvPrqq0rE2LFjh8P19Tq0GlFRUXYWHBBB0J6z9SyXwToDFgr4xKh4XFyceJKXXnpJWZugE49j8NBDDylBprlje4yvvPJKZQ0CIWn9+vV25QcPHqysaL7++msr4QKixsqVK2XIkCGyceNGq3XgcgFxCgLLAw88UDP/zjvvVBMsRGrbLkIIIYT4NhRBCCGENDngAvHggw/KM888I+Hh4TXuBx988IESBWBB0FjAQmPs2LHy66+/yp9//iljxozxWN1wgXnvvfeUpcn5558vy5cvl08//VQuvPBCp+vB7QPWDgCWK3D7ARCRpkyZUlMuIyND3n//fbUcriGjRo2ys+bIycmRd999VwkGkZGRyu0I+4v9drUdV+jUqZNyU7EVMjS6deumLEAgBFmKIHBzycrKUu5Ntutu2LBBfZ533nlW81EPrGsyMzPFG+Tm5srrr78uKSkp0qNHD7n88suVmKfFxEA8DRyjSZMm2a2L/cA1fuutt+rWDYsnXA8TJ05UlkRo5+DBg0qIw3WB84IymL9//3457rjjVPu21jMA4iGsaw4fPixt2rRRbl22llHaccR1ZzKZ1LkfMWKEw3135ZoihBBCmhIMjEoIIaTJMXXqVOV28dFHH9XMQ0cLHUq4RzQ2paWlNS42ngSBJ2HpcM0116jOLNw5IITUBjqf/fr1U9/btWunOraYOnToUFMGxxLxRXAcEe8C7iboRON4ms1mVWb79u3SpUsXFQsFFi7BwcEq1go6wq624+5xdHYMp0+fLp999pkSZiytgeACAwsOW7Tt+Pvvv3Xr87TVDoCwAjEBwkzLli1lwYIFyr0HQgPA/qWmpqo4KpWVlVbrbt26VcUdgdhQW+wRBGDF+YLgAssgiE6wRkpPT1ftZ2dnKzEFcU4QJ8WWp556Srn+QDiCpRHO9cCBA2tELQ2IjEOHDlWCCtzHFi1apNyL9HDlmiKEEEKaHGZCCCGkiXDhhReaIyIi1PdzzjnHPGrUqJplw4cPN0+YMEF9DwkJMV966aV26+Kxdu2119pNt912m8M2sc7555/vcHtsH5XHjh0zd+rUyRwTE2MuKCiodZ/efPNNVceqVatqLTtw4EDzJZdcUvP3ypUrzUaj0ZySklLrullZWaqdhQsX2i3bu3evOTQ01HzFFVdYzf/jjz9U/cuXL1d/33TTTebExERzZWWlVbldu3a51I4jAgIC7I7x999/bzYYDFb7a1l+8uTJ5ry8PHN4eLh5yZIlan5GRoY5MDDQ/MYbb5i/+uortR1vv/12zXqFhYXmXr16qTKXX365WrZ9+3an25WcnKx7zeDYO+PgwYOq/fj4eHNqamrN/EOHDplbtmxpvvjii2vmffnll6rs+++/b1XH9ddfr7b18OHDtbbTrl07q3I4/lgXv5O0tLSa+U8++aQ6rpbnbNOmTeo833jjjVZ1z58/X9W9evXqmusB695zzz1W5bBeUFCQOi7uXlP4jaCNBQsWOD2ehBBCSENBSxBCCCFNkiuuuEK+++475YIB14yffvpJzasNzTrBcurTp0+9tgUxIzDBnQHuDrCQQIBUuIsAbKNWRpv+/fdft9r466+/VCaa66+/vmYeMrDExsaqzBz1AevD6sI228mgQYOUW4WW+hSaEIK/btu2zc41pb78888/NccGlgtnnnmmcnNxFMgWwLICAXLhEgNWrFihrFEcuQfBBQVxRJDZBy4jiPcCl5vExERl8QDrIlsQfFbvmnHVugXuUB07dqz5G23BkglWErDiAIjvAouJ559/vqYcliFQK6xsYEVRGzhWluVw/GBZgnmwyrGcj/MIVy0NxFCBtcldd91lVSesU2CpAlcaoF0HmG/J//73P2WhVJdrihBCCGlqMCYIIYSQJgniOiCQ6RtvvKHcYODK4Er8DW8EudTiJsD14ffff1dxGNq2bVuzHPO1bCWWAoaraXS1gKiI4/D222+ryVIIgAvIfffdJ0Zj3cYuIGoEBASouBgAnWTNXeHIkSM17hizZ8+Wr776Su0vXDrgdoJONSbsc33QxAa4TKDjjX1F+lrsnzMgfJ1xxhnKdQTH4ZJLLqmJE6MHhCnsByawc+dOefbZZ1WMGcTEgOuGJwOjQhSzBecd+4kYIXBBwbGDIIO4HxDHsBwCSEFBgVWGI2cgG48lEMeczdfccQDideA4W16zmmiUlJRUE9sFn/id4XdnCQQc2+C8rl5ThBBCSFODIgghhJAmCawtYHmBUWqIIBhd1wv22BBYdpIRcwEBKhG4FJYb2E50JG0tGpD5xFWKi4vVyPm1116rOs2WoC1kUEGWFC02h7tAPMF22tYNEHtEs2hBZxexIhB/AhMscZCSGEE9kco2NDRU6oqt2ABBCxOsGGC54YjTTz9dWVrAGgFCCNLjuitSLFmyRIkPCKyL+CIxMTHiKWwtJEB5ebn6tLxekQ0H1ii4ThBjA/sBKw5Xz6ntsdcEMUfzIcJoQKywjUdiua1YrpXT2x/UZVmfO9cUIYQQ0tSgCEIIIaTJAiuA5557ruZ7UwDBIjH6jfS46FzDdQCZNupjTYCMMLAKQKYQ1G8LMrHAUsRZh1nryOoFpISLAtwzYNEBocMZ6Nhq1h8AnXa46KxZs0a5ZDhrxx3gIgKrjieffFJZQ+hZVGidbQhgDz/8sHLLQbBYZy43cH3SE8vgpgKOHj3qURFELwgrXJtgrWJ5rNHmpZdeqkS9c845RzZv3qyyHDWEsIdgtjj/sIqxPM4I5pqWlqbOhVZu1apVypXI0h0I22pr2eHONUUIIYQ0JRgThBBCSJPlhBNOULEH0HGsb1wPT6KlK0UmEKS1rS/IAIN91RNAACwmPv/8c+Vm4Ijo6GjV8T506JDdshkzZiihBi4ZttsLlw1YfACkT7XMxAK0EX0t5auzdtwFLioQU+69916n5SDCQIx5+eWXnZZDJhm4zmjuHRq7du1Sxw8iiq37SH1B7I1ffvml5u8//vhDiVoQdiAoWQIrIsRc0dIJwzqkIYCAiFgqSLWrWXpA1EB2GaAJeCgHyxLEDtFED1iKQKiytThx9ZoihBBCmhq0BCGEENKkQZpUd3BkkYHOGoQLT4AYD+jAI27J4sWLlQVHbcD9AS4ttowaNUp+/vlnu1SltiLI3LlzVYBQdGQdgY4p2oFYgrgOcGNBhxtxItauXau+Y9Qe1hSIxwGxAClX0ckFSLeKtKlwXcEESwG4xdx0000ycuTIWttxl+7du8u0adNUrA+4FqFtPWDF4YqlzYgRI1RME8TcGD58uNoH7BM65HBPgsuRbWwTLWCrHq643kDYeOCBB5RFB6xk4DaEWCoQyGyB0HXiiSequDLYPux/Q4DjgFgoONYQE7ENsO5AQF8IjNrvolOnTupv/OZgzYL5KPfII48oEckSV68pQgghpKlhQIqYxt4IQgghBKADiY5ZbcEiYTmBjt3o0aOt1kUWGUfA5B/r6HV00fk766yz7JZpdTrqJEOUQMDIyZMnO2x39+7dqrPoCMS7wD5D6EBsEWf7HB8frzKrOGPDhg0q/gXiqKDjD2FAA498iA2I+4FOOywjIDxYBlxFxg9kWNm/f7+y+kCH2TagZm3t2LJs2TK1b3rHGAE8kWkHcSSGDh1aUx4WG4gH4gi4bHzxxRfK8qNLly52yxC4E9YqsGBBxx+uHnrb5SyApzPhpaioSAU3hZiB/YdYlJqaqoQNZy47EEwQ5BbXDkSJ2rBsx9IaCpluIFigLct9w/lD3cjQYiv6oa7169erYw4rDpwzBKy1BYF+161bp44NxC+cfwhVOCfYDktqu6YQiwTX7pAhQ2oCDBNCCCGNCUUQQgghhJAGAlYzW7ZsUUKEsyw3hBBCCPEOjAlCCCGEENIAIDAp4ofA0okCCCGEENI40BKEEEIIIcSLIO7Is88+K998842KG4Jgqp7MUEMIIYQQ16EIQgghhBDiRZAtBQIIsv8gNo2WcYcQQgghDQ9FEEIIIYQQQgghhPgFjAlCCCGEEEIIIYQQv4AiCCGEEEIIIYQQQvwCiiCEEEIIIYQQQgjxCyiCEEIIIYQQQgghxC+gCEIIIYQQQgghhBC/gCIIIYQQQgghhBBC/AKKIIQQQgghhBBCCPELKIIQQgghhBBCCCHEL6AIQgghhBBCCCGEEL+AIgghhBBCCCGEEEL8AooghBBCCCGEEEII8QsoghBCCCGEEEIIIcQvoAhCCCGEEEIIIYQQv4AiCCGEEEIIIYQQQvwCiiCEEEIIIYQQQgjxCyiCEEIIIYQQQgghxC+gCEIIIYQQQgghhBC/gCIIIYQQQgghhBBC/AKKIIQQQgghhBBCCPELKIIQQgghhBBCCCHEL6AIQgghhBBCCCGEEL+AIgghhBBCCCGEEEL8AooghBBCCCGEEEII8QsoghBCCCGEEEIIIcQvoAhCCCGEEEIIIYQQv4AiCCGEEEIIIYQQQvwCiiCEEEIIIYQQQgjxCyiCEEIIIYQQQgghxC+gCEIIIYQQQgghhBC/gCIIIYQQQgghhBBC/AKKIIQQQgghhBBCCPELKIIQQgghhBBCCCHEL6AIQgghhBBCCCGEEL+AIgghhBBCCCGEEEL8AooghBBCCCGEEEII8QsoghBCCCGEEEIIIcQvoAhCCCGEEEIckpaWJgaDQZ599tkGOUqFhYWqvUcffZRnhdSQk5MjMTEx8vbbb/O6a0CioqJk5syZDfo7buh7TlPZJn++12ZmZkpkZKR8/PHHDdIeRRBCPMjFF1+sbibTpk3jcfXizbyhHhLutlNZWanKW04tWrSQk046SV577TWn5XHt6PHmm2/WlLF9MGRkZMjcuXOlV69eEhERIYmJiXLGGWfIG2+8IaWlpU63y3IaMGCA28eGENKwfPDBB1a/24CAAImPj5cJEybIP//8w9PRgGRnZ6tzsGjRoibXTmBgoNV1gk7FiSeeKC+99JLT8uPGjdNd/v7779fU9c4779ht3x133CG9e/dW7bRt21ZGjRolr776qpSUlDjdLsupZ8+eLu3b/fffr55zl156qcvHg5CG/t2SuhEXFyc33XST3HrrrVJeXi7ehiIIIR4iKytLPv30U2nfvr16aTh27BiPrZ+CFzSz2Swmk0m2b9+uRIrp06fLc889p1seAsZnn32mRrlsgXiC5bYcPXpUTjjhBFm1apU89dRTcvjwYfnrr7/UC+i1116rK7po22U7/f333x7ac0KIt8EoOH636GTi979t2zYZNmyY7N6922cOPjrU2Ed0sIn7nH/++TX39507d8rxxx8vM2bMkMWLF+uWxzPm66+/VsK6q88gvONAXMH1uHDhQjl06JBs3rxZxowZIzfeeKO8+OKLTrfLcvr3339r3Sc885YtWyY33HCDGI3svhDii/fa6667Tvbu3av6Ud6GdxFCPMTrr7+uPjFaDwVz5cqVPLZ+DkYcOnTooF4GYU76zDPP6JbDSyNe6mxNfFNTU+W7776Tiy66SPd6g6UKhJWzzjpLWrZsqUaF8SD76aefpE2bNl7bL0JI4xMcHCxDhw6VJ598Upk0L1++vLE3iTRB2rVrJ88//7waZXX0DDrzzDMlLCxMVqxYYTUf4vo333yj+wx66623JCUlRZ5++mk555xzpFWrVuq5A+vEDRs2SEJCgkf3A9YlGFigFQghvkvHjh3l1FNPlaVLl3q9LYoghHgIvIBeeOGFMmjQIGVW6uiFFIrr448/Lp06dVIvHXCV+OWXX+Tqq6+W2NjYOpcdMmSInHbaaUpBRacabhgTJ05UyyoqKmTBggXKZDU0NFT51OJFAi8wlhw5ckQuu+wy1WHHdPnll0t+fr5a5+abb3Zo2oqX8c6dOysTtqKiIrUcHXEIAADmbVpZjBBpuLpdlniyXry89evXTynhMLEdP368sqZwtR1XCQkJUTd2iBZ64OURbdtab+BvnGe8YNqiWY3gBdcWXIO4Fgkhvo/mSnDw4MGa5wbEUbi54bmB+8t5552nrNJs/cAfeugh+fLLL2XgwIHqPuWKkAKLx759+6p7a58+fZQVmy0FBQVyyy23SFJSkno+4D6FET6M5uu5G8LVB/fioKAg9V3PT/3ss8926E5hOYrpbtuu7I8lf/zxR43IjGeetg14LmtUVVUpk3ut3ujoaCUk4PlsCcSJ/v37q2cQRAO8O/z+++8ut+MqeF4nJycraw09wsPD1fbZPoPgWol3CTyf3HkGHXfccTXvH54C5wXXNJ7ntsDlE1YuOJbYFzzP4Zas/SZcvS4cUd/rua5Y1vnee++p3zqsckaPHl3zPgMXJVib4jobPHiwrmucq/sOdxEcN1yvuG9MmjRJcnNzdbfN1WvcE7jyG8VvSPuN4Ljjep89e7bad1d/T65cR+5sU0Psty/ea08//XT5+eefda2jPYqZEFJvfvjhBzN+Tj/++KP6+9tvv1V///7773Zl7777bnNgYKD5mWeeMR89etT877//ms877zzz6NGjza1bt65z2ZNOOsk8aNAg89lnn23euHGjOTMz0/zWW2+Zq6qqzOecc445NjbW/M4775hzc3PNu3fvNo8ZM8bctm1bc0ZGhlq/uLjY3KtXL3OXLl3MP/30kzkvL8/82WefmadMmWIOCQkx/+9//3O4//n5+eYvv/xS1Tdt2rSa+QcPHlTHYcmSJXbruLpdenii3qVLl5qDgoJUGWx/dna22t/LLrvMpXb0qKioUOUvvfRSq/klJSXmVq1amXv27Klb/qqrrqq5ZrZu3aqWmUwmc6dOncw333yzedWqVWoZPjW++uorNW/ixInqXNVluwghzYf3339f/Y7ffvttq/m492L+bbfdpv6+4oorzJGRkebly5er+1pqaqq6r+EetGfPHlWmoKBArTNu3DjzpEmT1Pxdu3aZV69erdu2di+86KKLzNddd52q88iRI+rv4OBgtVyjvLxcPY/i4uLMX3zxhbo/ff/99+akpCT1jEHblnWef/756h6YkpJi3rJli3qeatu3YMECp8dk+vTpqtybb75Zp7Zd2R89srKy1PoLFy60W4Z79wUXXGCOjo42r1y5Uj2D9u7dq57d2K5Dhw6pcq+99po5ICBAbTu2E894nMtLLrnEpXYcgTpxTC3BcWnTpo05OTlZt/zkyZNr3mP++OOPmmV4Zs2cObPmeWN57X333Xdq3oQJE9Q+1mW7XAXbj/cQnCu9441j27JlS/NLL72kziOmN954w3zXXXe5dV3oXXeeuJ4B3q2wvLYJbWlodY4fP948a9Ysc3p6unn//v3qXW/AgAHqneD6669X1xSu4cGDB5u7du1qrqysdHv7y8rKzMcff7y5Q4cO6tyi3Oeff26++OKL1b3j2muvdfsad/V3rEd9fqNo95tvvlH7YvneU9vvtrbryN1tqs859+d77Vf/3W8+/vhjszehCEKIB5g6daq5X79+VjfT7t27m2fMmGFVDi+keJBfc801VvPx8hMREWElbLhTFuBmhJvG33//bTX/3XffVfPxAm3JsWPHzFFRUebbb79d/f3CCy+ocuvXr7cqhxdpzHcmgmg8++yzZqPRqASV2kQEV7dLD0/UC/EAN2pn1FcEwXVw4MABJSRh/iuvvKJbHg8liDd4YM+dO1ctW7dunVr2zz//6Iog4N5771UPEVwnI0eOVILJp59+qkQXvXYcTa6cW0JI0xJB8BK6YcMG1VHFM2Hnzp3q/q13z0IHBy+nmkitvfgmJiaqempDuxf2799f3dc0Dh8+bDYYDOaHH364Zh46DSj73nvvWdWhdZq1DohWJ56VlnVabp+zF/MHHnhAlZk9e3ad23Zlf/Rw1pn65JNPrDoLlvsEcV6730KsgtBd13ZcFRvS0tLMV155parn+eefdyiCaJ22G2+8UX3/5Zdf1Dq//vqrrggCHnroIXNoaKh6Do0YMUJ11NFx0d4BbNtx9Ayy7GDrgX1AOZxzWz744APd56slrl4XetedJ67n+naIIU5Yguc85p988slWbaEzivlr1651e/tff/119TfOtSUQ62zPkavXuCdEkLr+Ri3fX/H+V9vvyZXryN1tqs859+d77d9//63Wefrpp83ehO4whNQTBAeDSdn1119fMw+mX0gnhhgPmnsIgCtLWVmZXRR2mHeecsopVvPcKasBkzSY8VkCczOYqcEc2hKYOiKw5vr169XfiD2BuBLwxbPEdj2Nb7/9VvkRt27dWsWz0FxF4LO7b98+3XXqsl3u4mq9OE47duxQbj5//vmnMu30FO+++646HjgucIOB7zTMzBEc1REoO3XqVOWTjW2B/zNM1GFW7IgHHnhA0tPTlQkzAtRhP2C2DDcgmH66GhgVgVUJIc0DmKjj/gL3FTwf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9VBPr5BQ3TYaQwB5JjtbYiIjpNnFBKmoqFD+t+Hh4ZKcnOzZrSKEEEIIsSAnJ6cmBshff/1l9bc2z13Wr18vH374oZSXl8uZZ54pEyZMqPXd55133pHff/9dKisrpU+fPnL55ZdLixYteK4IqScUQOoPLC1gpREcH6y7POb0GInoEyEBEQESGGnfDQyJC5Hui7pLVVGVhMSH1KkNDQog9adjcLCs6dJF8qqqJClY/3i/1KGjpJaXO1w+MSpaTg6PkFYBARIVEKDbRpDOfG8LILNiY2VnRETzcof55JNPpF27durh/+yzz6p5//zzj4waNcrT20cIIYQQokSOM844Q0233Xab1d/aPHd46aWXlPARGRkpSUlJcvXVV8ucOXOcrnPOOefIvHnz1OBP3759VR1DhgyRokb0aybEF6AA4hmMgUblMoJPR0Dc0BNANLDMkQDiahsUQDwHhAtHAgcINhikW0iI+nQE1tcTQDQMjSCAzGwdK42J25Yghw4dkiuuuEKWLFkie/fulYKCan+y/v37S2BgoBqVGT16tEt1HT16VO68805Zu3athIaGyqWXXip33XWXqscZq1atUuLL/v375fjjj1cR4rt16+burhBCCCGkGTB8+HA5ePBgreXwLuEKJSUlcuutt8pDDz2kPkHPnj3loosukuuvv166dOlitw5S8K5evVq++eYbJZ5ooggEkZ9//rlmHiHEPSiA+BYUQHyPzT4mgNRJBPn+++/Vg37KlCmyePHiGhEEnHjiifLjjz+6JIKYzWYZN26c+v7uu+9Kbm6uTJ06VX0++eSTDtd75plnlHDy1FNPqZGff//9Vx544AF588033d0VQgghhDQDQkJCpH379h6rD+8qeXl5cskll9TMO/fcc5WL75dffik33XST3ToxMTHSsmVLSUtLq5kHYcZoNErHjh09tm2E+BNH84vl3g1l8uDIELnnVMfWB44oKDPL2SuLZWtmlayeGiGD2/lHwMymCgUQ32OzDwogdRJBjh07Jq1atVLfDTZmN/n5+RIb69qOwfpjw4YNSsTo0aOHmvfII4/IzJkzlalpdHS03ToZGRnK3HXRokVyzTXXqHkYgTnrrLPc3Q1CCCGENCMweIIJooPG5s2bZcWKFcoCZNq0aboWHHrs27dP1dOhQ4eaeUFBQdK2bVu1TA/E/YBAcsMNN6jBGwgzeIf54IMPlBWJHmVlZWqyfE8CJpNJTYT4OzkFpfLg6WFy1/AQMdVBADnn7RLZnm2Wby9vISckBrhch8Fg9LnfIFLGIkNMg7drNAr+QQA5+ORBKT9cLp1v7VznLDAHFh+Q8A7hkjTbeRYY9EN97RwajEYxWzzjGvIcmnXa3VJSIjPSD0n30DBZ2qG9hBsD3L7Clh3NlmdzcuR/cXEyo3Ws1frYX0+fQ1frc1sEgbXH448/LsXFxVYiCMxEV65cqV4MXOGHH35QLx+aAAIgZiDo2MaNG2XMmDF263z66adqOSxGLLEVYwghhBDiW9xzzz1KiLjjjjvU3xAgBg8erGJ6BAQEyPPPPy9bt26VhIQEl9xhwsLCrAQVgPqxzBFvv/22siCZPHmyWh+WIK+//rpyiwnW8dlesGCBzJ8/325+VlaWlJaWurjnhPguJ554glw9tFAy3VyvpELkvt/KJCDRJJ9MDpGk1ka36mjfubVkZrrbatOmW6duYg607qJWFVZJwdYCFcMjtIOOu6BJpGBLtVV/i34tdKNFlh4sVeltW/RtIQGR9qJEVK8oaW1qLVlfZUlEmwiJuzbOaUwRR5RllEnm2kxJGJUgbca3EWOwczHA0Mngc+ewQ7du8mVetVg+LCJCN9vLv6Wlsre8TIaGR0i0TgiJ3MpK+aW4SLoEh0hPHRdRpNH96b84Vlob3WNbS5GNhUZKeZk8l50tYxIT5YbY1iLGAHE3+tVXBfnyS2SE3Nevn4xp0dJu/Y4BRo+fQ0svFY+KIHjhOOWUU2TgwIHqRaOqqkquuuoqee+995TPrqvxQBBbJD4+3mqe9nd6erruOshGA8uPL774QhYuXKiEGMQEwQuGpZjSWKMw1Upowyuw/gaOs7eUX1V33eIFkyZyHrURCdL8f4uNMRrib3jjHHrjmsBz/OWXX5Y9e/bUzIPrbK9eveSXX35RVhxnn322LF++XIkltQGLVgQzRVYYS/ECGWc0a1c9d+DnnntOBYI/7rjj1DyIIXDTee2112TGjBl268B995ZbbrF6B8EAUJs2bZRrDSH+Tt7hFInL13/vd2YBctHbxbIts0q+nhIhJwYFiFS/3rvMnn/jJC4uTnyJ3ft3i7nSuh+y76l9UryrWIkbPR7vIUExQVbLj649KodXHlbf205uK61HtbZaXpFTITvv3anEEqTE7XxHZ7t2923ZJ7JLlFCSPCdZSluXilS6bwGSsjhFiTVJU5Ik35hfax2G/QafO4db/vlHXv7PGvGuuHiZZOMZcaSiQq7at1eqROT4sDB5o2OSXR3XHUiVv0pKBHLV1527SEKQ9Tl/OzdXHsnMsGpj16E0iWjX3toCJO2gdA0OkTlwgcnNdXtfYAGyJDtbboqNlYuUwFJ9nVlyICjQ4+fQ1dhgdUqRC9PTF198UVl9HDlyRFln3H333VYP+tqASattAFTVeTEalbCiB9LRYdQF0djxMoSdvO+++2TEiBFq9EfPFachR2H69u4pQdEUQbxNRe+eXlN+u/fqK5ktK7xSN7E91kFeOY99evWRloHsXDQE8b3ivfZb7NG3jxRFRHqlbmJxnIsKG20Uxh1g9QHxAFYfGuvWrZNZs2Ypiwwwffp0NUjiCv369VOf27ZtU4MpADHJMECjLbMlJSVFWZ727t27Zh5cdxMTE1Wgdj3gMoPJFu19hxB/x2zG0JPJLQFkrGUMkESMlLsnvP52qEoOZOb63G/QZDbZucNYDvqa/vtnu07NcrPOcvlvuem/unSOdXlBuVRmVqrUuaGdQ3XL1CqALKwWQDrM7iCGMINLdRjMBp87h2bLQXqTSQw2gwr4u/o8VJe1XW5ZB34ZBr0yOm2YLMpZxgB5sV2iRKAmNwc3LGOAXBsd43B9bKunz6Gr9bktgiACOl5wrrvuOjXpLcNoTG1gFASByWyzxeAkOFKEMB+CywsvvFDjf/vqq69KVFSUfP311ypYa2OOwmzd/q+E9qNrjrcp3f6v15TfXTu2StyJ/285RLzHrh0hXjmP23Zsk9jKphF0ydfJ3pHttd/izq3bJCIqyit1E4vjfOxYo43CuAOe/Zg0srOzVYa6k08+uWYeBBJYiLrCCSecIN27d1dB1uHOApB1DoIKXFs0Zs+eLYMGDVLvFxBLMIDz0Ucf1QRU3bRpU02mOkKId/FEEFQIIGe8WSSd2rcQf6DDdR0kd32usuKwtQIBMSNjdL9rYJ3kucnKmiR6hH28RoVZlABS1xggmgCSNMd5DBB/oFVYuNwTV+0ZcYnOOxCsOpa17yCbSkrkIgdWi4vbJsoHeXkyMCzMzgrEtt5LbNrw1SCoHhFBEMwU1h96QgeWIXipKyII3Grg0nL48GEViEyLE4JRFryc6DFkyBD1GRERYfWyBV9gS5eXxhqFqVbmKIJ4Gxxnbym/qm63Q3ORpnQeHY1UkOb3W9Qb4SCeP86NNQrjDt26dZNdu3apmGF4F4BwgQEQS/EByx0FKLUF7xrIKgfBAyIHBJQ///xT3njjDavA7KtWrVLCB0SQ/v37q2x0l19+uSxbtky9f8AaBXHKLr74Yo/vMyHEOwJI37gAiY7V70D6GkHRQRI33rHQbQgwSOvR1i4wtkT2jlSTwzZaBFEA8RAIAXKZTnIQS06OiFCTI+KDguQGJ4lKAg0G3Tb8SQCpszuMHnhJgFkp/HNdAanokpKSZO7cucqHFxYaeLkYP358TRo8zMNIzdNPPy2XXnqpnHrqqeqFBy4wsAaBOw1cXeDP62osEkIIIYQ0LxCn4+abb1axx/COgGDsDz74oBoE0YCLLlxlXQWDMcgEgwEYxAYZNmyYshK1BJYilhlk7r33XhUHDXFBYJmC5V27dvXQXhJCGkIA+XpyuExez0FLT2EMMDaKBUhRnrthOokjKkwmvxJA3BJBEHUdqWsRNR2CB4KAWaJli8FIiivAOgO+u0hph9EcjEZBGLF8gcE8WJZokdoxuvTJJ58ov1+8EOHlB+nwMA+CCiGEEEJ8k0ceeUQJF3jPQGBSS+sLuNPCIgPWGu6AbDCW7i+2YGDGFsQAwUQIaZ4CSIsQCiCNiScEkMxPM0WOeWXz/JJjlZV+JYC4JYKMGjVKYmJi5PPPP69JD2cJ4mvAjcUd32IEF/v9999VHBFYdWjBzTQgdMBdxjJSO0Zk1qxZUyO62K5DCCGEEN8EooSeMNG6dWv53//+1yjbRAjxDhRAfA9PCSCZH2VK5/72mWpI3Qg0GPxKAHFLBEEKWkxDhw5V8Tfgn+spMBKjB0QOpOHVIzzc/eA7hBBCCGl+YPBly5YttZaDZWnfvn0bZJsIId6jOQsgYy8aK2lZadLQpKWnSdvKtlKeUS7B8cFiDNR3UynLKJOA8AAJbKHfDawsqJSq4ioJibePqQhMlaZa2/C2ABI3IU4ish3HxfAlcisrJd9kkiSLdO6WlJvNklperpYHI6iIDlje0miUaJvMrBqtAgMbRQCpMpmbT0yQjh07emdLCCGEEEJ0gNXoGWec4ZLVKqxFCSHNl+YsgAAIIOYrG75zVzm/UokMxTuLJbxHuHS+095SImddjqS/kS7GUKN0eaCLhMRZCx1lmWWyd95eMZWaJPHyRIk53T5jTG1tNIQAEndenMgr4vMcKC+XCSkpUmw2ybz4eJkYZR/Q9JqDB+T3khI5MSxMXu9oHx7inWO58kBGhoQbjPJRcrJ0tBFTDpSXS2UdAtHXVwApMlXJ4cICaVaBUWEJ8sorr8jff/8tOTk5KkaIxnnnnacipxNCCCGEeAK4vsI69KSTTpIrr7xSBUPH33rlCCHNl+YugDQ2SGWrfZpNZjEYrfe9cEeh+oTIUZpaaieClKaUqmVaWVsRBHXattEoAoifsL2sVAkg4NfiYjsRpMpslj//i52JT/wdYPNsxHoA9ewoK7UTQbaXlTaKADLjYJqUt2opjYXb4XyrqqpkxIgR8uyzz8pff/2lUtJVVlbK119/Lb/++qtV+lpCCCGEkPpyyimnyJ49e+T000+X+++/X0aOHKnS5CJgenJycs0UHx/Pg01IM4UCSP1JmJggwQnB6tNWAAFtzmkjIe1DpMXAFtLiOPtwBC36t1DLUAZlbUGdtbVhCQWQ+jEiIlJGRUZK9+AQuSbGPpUxBI9b28RJclCw+rQVQADWw/qo59SISN02jA7caLwpgOwuL5PEqChpNpYgMDNF6lqkh0Pq2iNHjsiiRYvk4MGDMmTIEOnUqZN3tpQQQgghfkvnzp3l4YcflgceeEC++uorWb58ucoYc9ppp6n0uWPGjGnsTSSE1BEKIJ4h9qxYNTkiLDlMuj3kOK6jMcQoSbOS6tWGBgWQ+hNmNMqSdu2dlpkWE6MmR/QODZWPnfTPw4xGCTQaG1wAWd6+g7waFCTNxhJk9+7dagQmKChIgoODVZYWLWvLlClTlEUIIYQQQog3CAgIkHPPPVc+/vhj+eKLL5RV6uLFi3mwCWmmUADxPSiA+B5LPSyAHNfI7qtuiyClpaU1PreJiYmyY8eOmmUQRCoqKjy7hYQQQggh/wFr1GXLlqn4IEiXC0FkwYIFPD6ENENMJsYA8TUogPgeS31MAKlzYFQNRGq/6qqrVJCy6Ohoeemll2T16tWe2zpCCCGE+D0IwP7DDz+ooOwffPCBHHfccXLNNdfIxIkTJTLS3seZ+D5nj79E0jKy3e5w52UfkcqKcolqkyBBwfopQJ1RUV4mUeEh8uuP69xel9iTlp0n+xkE1WegAOJ7LPVBAaROIsikSZNqrD1atWqlRI/HHntMxQSBCDJ8+HBvbCchhBBC/JR169apjDCw/njqqaekV69eaj6y1FkSFRUlffv2baStJA0JBJDCEXNcLm8qK5bM9++T8qwDEn/pg1KW2EPK3GyzLH2nZLx7r7Rt39Ht7SUOjmkFs8D4ChRAfI+lPiqA1EkEadeundXfgwcPlg8//NCT20QIIYQQYmUJApCFDpMjRo0apQK4E6IvgKQqASQksYfbB0gTQILbJEmrWGYh8hQd2rSSwe3claP8Ow2uO+Rvypcj7x2RyL6R0nZyW7vU4hU5FXJw6UH1vcN1HSQoOsju3nt45WEp3FooCZckSMuB9ilNUYYCiGeorKqSKQdS1ffFbRMl3iZwKI71I5mZ8nNRkcxp00ZGtbDP8LO2oEAWZ2XJKRERcldcnN05P1JRIXMPpztswx8EkHq7wxBCCCGEeBtYmcLitDZCQ0N5MohXBZC4i+eLceMLPMoeIjQ4sFEEkKP51YkdfB0IIOVHyiXnSI7EjIyR0HbW98ic9TlSvKv6WOSuz5W48XFWy8vSyyRnTU5NXXoiSFV5laQsTFF1J81JkoCwALe3M/PTTMn8KFPiJsRJ3HnW2+BP5JeWyqaSEvX9g7w8uSHWWnzYW14uK4/lqu9PZGfpiiAQQFIqyiXlWLlcGhUlXUOs3f4+yDvmtA1/EECA23cemKHecccdusuMRqNykTn55JNVCjuapBJCCCGkvoSEhEj79s7TBBLSIAJISDgPdCPiCQHkoR/KJDvPP0QQWIBAAAlqHaQmWyJ6REiWMUt9D+9uf21r61UcrVB16VFVXEUBxEOEBgWJJiEN1BEO2gYFSWJgoKRXVsrQ8AjdOmABAgEE5VDelkFh4RIgRx224Q8CSJ1EEERif/PNN1WK3GnTpqkMMVlZWbJy5Uo5cOCAzJs3TwUtO/PMM2Xbtm0qYCohhBBCiDfZvHmzSpl755138kATCiA+iKcEkHu/K5OhffzDpQkuMLAAgZAREGpvoRHZO1J6LKoWB4Ni7DvMWKfrw12VCBKSqB9I2BBooAWIhwgPDpbVnbuo7wk6AkaE0SifdOoshysqpEtwsG4dcIGBBQgEEJS35eSICIdt+IsAUicRJCMjQ6qqqmTjxo1KCNFAlhgELUNQss8++0xOPfVUWbVqlcocQwghhBBSX3755Rf5448/lNXphRdeqDLDpKenK+FjxYoVcsUVV/AgEwogPognBZAHR4bIb6X+YdGDeBC2LjC26IkftkJIQDvHLi5BkUGN4gJjMpvEF9ETPyyBsGHr4mJ7zp0td9TGUj8SQOokgiASO4KhWgog2gE/5ZRT5J9//pHzzz9fRo4cqSxDCCGEEELqy/333y/z589X7xsIDrdgwQJ58cUX5YILLpCEhAT56KOP1PsH8W/oAuM+Uy84S/IyGued/XB6GhzqG1QAuefUEDnv23psNLHCNvBmQwggVSVVkp+RzzPhIYqqqvxKAKmTCAL3lrVr10peXp4aidEoKSmRzz//XKZPn67+Tk1NVS4xhBBCCCH14dixY/Loo48q19uLL75YWX/g8+yzz5Y5c+YogSQwkLHe/R0KIHUDAsinZ1Zni2hoBi2vQNSfBhVASOPiCQEkdXGqtKi0DwpK6i6CzPIjAURqlV51wCgLXF569+4tN998szz++OMyd+5c9Xd+fr5MnTpVUlJSZM+ePWp0hhBCCCGkPuCdolevXnLZZZcpS9SkpCQlfnTs2FEeeughCiCEAogPQgHE9/CUAFJ6qFSiExh30lNEBAT4lQBSJxEEEdp/+uknueWWW2TLli3y6quvKv9cWID8+eefSiBJTk5WZcKa0YEghBBCSNO1BGndurXVPPzdoUOHRtsm0nSgBUj9KCguc7jsna0VsuDHMikoM+su/+dIldz3Xan61K27zKzWRz3uQAGkbpQcKJGMVRnq05GIkPV5lhzbeMxhHViGMihblzYaQgBJvjVZgoKdx87wFb7Mz5dlR48qwUGPf0tLZUl2lvrUA+thfdTjiDCdAKoNIYCUVrh3X/AkdbIdhbiBERhMhBBCCCHeJicnR9asWVPz919//WU3D8LI8ccfz5PhR1AAqT/pRwvk+5RKOS3Zulvw3f5KmfRhdUf3YL5Jnj/HupNjMpvl9DeKJafELM/+XiFZt0aK0SY+xO1rSuWFP6o7OgmRBrs2TDraCgWQupPyWIpUFVVJzpoc6bmkpxiM1ucj4/0MyVmXo74HtgqUyF7WaW8LdxRK2lLEaRGpyKmQxMsTrZabTWa7NhpDAAnvHC7yvXiNSWPGSG5a9XFoSNLRZnxCzd+/FhfJ3MPVrmpHKitknsUy7Tc4/eAByTOZ5K3cXPm5aze73+DirCx551i16BUbGCCDbVLr/lpcJJUmU4MLIJtLSiS9TF+4aQjq7EBbUVEhu3fvlvDwcGX5QQghhBDiLSB6nHHGGXbzLeeNGjXKShQhvg0FEO9Sh3iX9YYCiO/hFQHEy0AAebKiUhqaiyut22yIn6DBzfKeEkCuTjsoSW3bSrMSQT755BO55pprJCsrS1mDLFq0SGWFgYsMgqYSQgghhHiK4cOHy8GDB2stFxrqPBWkLQi0+uGHH0p5ebkK5n799dfXGl/k0KFDsmTJEtm8ebN06tRJbr31Vr8eDDp7/CWSlpGtvldUlEt5SbEEh4VLUFCwXVmTySwlhfkSEBgooTajkRqlxUVSVVkpYZEtxWgzim3ZRk5+kZS9f5+UZ6VK/KUPSkhiD7e3vSx9p2S8e68Et0mSuIvnizHEP9Km2pLYuoWcllxuNx9WG29fGCb7c01y42D784kR53WXh8uqfyvkgp5BdiPQ4LHRodKhpVE6RRvtrECq6/j/7xRA6k/y7cmS/2e+tBzU0s4KBMRfHK9S4gbFBtlZgQDMaz+zvVRkV0jMqBi75aiztjaauwDSlIDVxqK2iZJWUSGTo6PsluM392qHjrKmsEBGR7bQ/Q3OadNGEgKDpH1QkJ0VCMC8QGO1dVBDCiDdgkOkdSv7fWqyIgge/ldccYV6Adi7d68UFBSo+f3791cvDhiBGT16tDe2lRBCCCF+COKRtW/f3qN1Pvzww/LYY4+prDMtWrSQu+++W8U4e+ONNxyug1hop512mnrPgWBy9OhRufDCC1VMtKYkRjQkaemHJWrSIqt5iDDhOMqECMY6C2upt7iW5aUvXikVxYUUQDxAi3DHGVMm9nUed6F/QoCaHNYdYpA7h9eekYUCiGcI6ximJkcEhAVIm3PbOK0jakhUvdrQoADiGca2bOl0ec/QUDU5IsIYIDNsYmrZoieeeFsAWdahvdxTi4jWpESQ77//Xo2WTJkyRRYvXlwjgoATTzxRfvzxR4oghBBCCGmyIJsdsso89dRTcu2116p5iYmJ6v0Flh39+vXTXQ9B4IcOHSrvvvtuzbyLLrpImgIQQApHNHystoo3Z0tjYK6qogDiI1AA8T0ogPgWRV4QQCDONCbGukRob9WqlfpusFGN8FIRENC4O0QIIYQQ4gwM2JSWlsp5551XM2/kyJHq/Wb16tW662zbtk1ZfNx4441W8yMi9N06iHcJDG/ZaC4wRfmOM2sQ9yivMssZbxZJ37gA+XpyuLIccZeHfiiTe78rkwdHhsg9p9ZudUK8CwUQ36LIBwWQOlmCwNrj8ccfl+LiYisRJDU1VfnWWo6OEEIIIYQ0NfDOgkGbhIT/j7RvNBrV31imx9atW9VnbGysXH311XL48GHp3r273HzzzZKUlKS7TllZmZosB4uAyWRSkyeBObNR9NOYehMct8ZoNyAo2O12yw7vkqz375PQ+E4Sd9F9YgzGy7x7dRzb8J4EF+R5/Pw1JgaDUUzuj4t6hGOlZumXECRfTAqXiBCDuHtUH/mxTO5bXyEPnh4mdw0PcXl9tc9eOodGA67MxvotGhu13czPMiX742xJuDBB4sbVLQbIwScPSvnhcul8a2enMUDQD/XWOTQYjWKuQ9pYTxzLptRukalKZh5Kl72VFbK8Y5L0Cwtz+8reUlIiM9IPSffQMFnaob2EGwNq6sBx9vQ5dLU+t0WQwYMHyymnnCIDBw5ULwtVVVVy1VVXyXvvvacClzEeCCGEEEKaMgiEijgjthatyHiHZXqUlFSnCr3sssuU8IEYJYgfctxxx6ng8HrBURcsWCDz58+3m4/A8rBE8STduyRLSXTDd7za9eomLaPM+ikGzCKVBVkSEN5KDIH2gTVBVXG1MBQQru/3bq4ok6qSfAls0caqjXZ9ekhLN/a3IueQHNu9SpJGnCFRwy4TQxAsBtw7XkU7fpTYkn8l7sQTJTMzU3yF9p17SGbLWPU9u6T6mMSG6VtklFSIZJWYVLBTB6dcpdNtE26UMAe9DMs2jhtQJs+eHSIlQSLVvzDXeXdbhXxaUClPXBEol/YJEnfOSPvOrb12Drt16ibmwIb/LUb1ipI2gfbxPiqOVkhAywAxBul3rqsKq9RnQKT+6Lyp3CRVBVUS1DrIabt5v+VJyIEQ6XtjX2k1uNprwB3QTtZXWRLRJkLiro2TkHjnVj2GTgavncOO3bpJUVXDC53dY1tL2n/BQqMdBOkuNVVJbpVJEgID7Z5hwGw2y5HKSokOCJBQB4JK7n9ZaLQ20G5R61i7dp7LPioRrVrJ8thYSQ4OkSI39yelvEyey86WMYmJckNsaxFjgFUdHQOMHj+HlqE6PJ4dZsWKFfLiiy8qq48jR46odLkIKIbsMIQQQgghTZno6Ghl0QohwjKjDAKdYpkeMTHVmRLmzp2rMuSBsWPHqgwxL730kgq0asudd95p9W4ES5AOHTpImzZtpGUtwe7cZdfeFCls1/BB5tJ/2yihuUaJGVV9TCzJWfuSFGz6XAJaxkni9CViDLYO3leS+o9kvn+/+h538f0SltTfarmpvETSX50lVfmZ0mLguVZtZG3bKW0GGFy2AMl4775qF5iLZsmRQveyCGkWIHk/rZRWwyZL6eFDEhfn/ih3UyVt306J65Iu6/ZXytiV1SFpv5wcLqd3su4mFJSZZeiLhZKaZ5YbTwyWJ8+2P46zvy6VZ38vl6RWBvl7ZqREBlufI9s2juytkqSSELcVEGUB8n2ZzD8tRG7qECJSraW5zIE9bb12Dnfv3y3myoYXQVJ3pEpSpbVVWvrKdMlZm6MEjK4PdpWAUGuho3B7oaQ8kaK+J9+SLJG9I+0sM/bM26OEFGSLSZycaNfu/q37JWJVhGSuypS4C+KkbGCZZFZmup8F5sn/ssDMSZbS1qXVEZSdYNhv8No5PLB7t0Q0QorcrQcPyLnF1b+Ppe07yBAbd0tYZly2f7+kV1bKZVHRcmd8vF0dCzIy5K1juZIYGCgfdeosETZCyMaiIpmZdtCqjV2H0iSiXXurdm4+mCZ7ystkWfsO0qegtlDWDixA0g5K1+AQmQMXmNxcuzIHggI9fg5dzRLntgjy999/S1FRkVx33XVqIoQQQghpThx//PHqc9OmTSrQKcjIyJC0tLSaZbbAAhZAxNCAS03btm2VeKIHrE0w6ZqPe9jk2WQ2i0l3bN67wPS4aN8miRpl33bRvj+rXX+OHZHy/CwJju1otbzk4DYxVVV3NEoObpeQpAFWy8vzs6Xi2JH/6rJuQ9Xrwv5axgCJvfA+keBwt10ujv3yjuT9uEJaDZ8iLU++VEzrF3v8/DUmZjOcYUzyU2q5VPw3+v1zaoWM7mS9j4fyq2R/brXVwDd7ysUo9tY9X+8pV+dmf65IWl6l9G5j3em2bQMnA23XOQbIcFgnuLc+xJyDWbleO4cms6lR3GGqfxPWxyJ/S76aX5ZVJmVHyyS0nXUHsWBngZgqq9cp3FUo4b2t3U/KcsrUulpdCfL/LoQa5cXlUvBhgUqDGzsu1m4b3E2DG9o51KU6DGaD186h2WQSQyO4vJVVVUnFf+1uKiqSk23ibxwpK5e0/6wVfy4sEEMbe8ufnwoK1DlHuSNlZdLV5hn0Z1GhXRsmi/2FAHKtZQwQrO/msbCMAfJiu0SJwL1apw4cZ0+fQ1frc7vVH374QT744IO6bBMhhBBCSKMDF5ZBgwYpdxXNf/iRRx5R8T5g3aExceJEefbZZ9V3uL+cffbZ8uqrr0rlf6bESKmLwSEEVfVXDMYAiT7tCt1l0adNl8CY9tJi0DgJav3/4pFG5HFnSUj73mqK7H+m3XKsg3VRh6M2vB0E1VIAiRo6UXyZqwcGy7COAWq6eqC960OvWKPcNDhYerQ2ymOj9V0VHh8dopajHMq724a3g6BCADl7ZbGUVVSLOb5OwiUJEpwQLDGjYyQk0f54xYyIkfDu4WqKHmFvBYd1sC7qQF16mMpMSgCJO69uMUAsBRBnMUD8AbivDAwLU9NF/yUisaRLcLBMjoqWTsHBckusfqrjOW3aqOUoh/K2XNQqymEbvhoE1SOWIH379pX333/fO1tDCCGEENIAvPXWWzJu3DglboSFhUlhYaF6v7HM9rJx40ar4KmvvPKKnH/++Sr+B+bv2LFDubtceumlfnvOAiOiJLzbEN1lmO9omVq3ZawkTH7c4XL4u8eMrk5h7A8CyNQLzpK8jAPS0BxOT1Pjou1bGuXH6RFOz8czY5ybmp/fM0hNjqitjYYQQLZmVslJfd2PWdEcaTmwpZocERQTJJ3v6uz0nCdOsXeBsaojPIgCiIcIMBhkRcckp+fjbh0XGEtGtWihJkckBAXptuFPAkidRJA+ffooH9opU6bI5MmTlV+rJXgpwAsFIYQQQkhTBZldIGJs2bJFBUPt37+/BNuMmr3zzjvKOkQDri+//fab/Pvvv0o06datm0qrS5oWzVEAARBAPj0zXRqaQcsrMOYvTRlPCiCrp0bIQ9vqFBaR6GAbZ6ShLEAqynHdEk+5U87wIwEEuH0HePPNN5X5JyakxLVlzpw5smjRIk9tHyGEEEKIV4DvMMQPRwwZom/F0LNnT56RJkpzFUBIwwkgg9sFiGzjEW8sPCGAFO8rlqIj7uYqIY7Iq6yUgxUVfiOA1EkEuemmm+Tqq6+ud0RWDaTWXbt2rVrv4osvlmHDhrm0HtL/3HbbbXL48GFZsmSJw2juhBBCCCHE96EA4nt4RQAhzV4ASVmYIm07tPXKNvojlWazXwkgdQqMiijnUVFRDid3RJAbbrhBiSpIL4f1Tj/9dHnjjTdcWnfhwoXKTBXWKMhWQwghhBBC/BMKIL4HBRDfwpMCCLLcRMVHeWU7/ZGowEC/EkDqZAmSkpKifGEdmZXCN7Zfv34SHu78wt62bZs8//zz8u2338oZZ5yh5kEIgTvNpEmTJCjIcVAl+OM+99xzSghBWUIIIYQQ0rjk//6xFPz1pbQ4fqy0PHG83fKyI3vk6JdPSWBUgsSOmyvGIOuBM1NFqWR/tkgqjx2R1mNvlpCErg7bCAr5/3UpgNSPnBKTFFeYJTzIOuUw5k3+qET25pjktfFhMrCtfQfnyQ1l8sIfFXLdCUEy+2R7S40/06tk+icl0iXGKCsnhNm14QgKIHUj+5tsyVmXIzGnx0jsWf8fz0ijJKVE0panSXBcsHS4toMYQ4x2mV4OvnhQyjPLpf3V7SUsOcztNhpCAEmakyTGt30nTbUjSkwmue1wuhwsr5BH2raV3jrGBq/n5Mg7x3JlYlS0TIuJsVu+rbRU7j58WDoEB8njbRMlzCaFbAnS49Zh2zwhgOQ0oiGD21fPxx9/rKKpjxkzRk3nnHNOzXeklYP/bHx8vCxdutRpPV988YXExMTIqFGjrFLRZWdnq2jsjsjLy1PCx7Jly6yClRFCCCGEkMbBbKqS3O9ekcrcdPWJv23J3/iBVGSlSMnujVKy9w+75ZiHZSiDss7a0FIbUwCpP6UVJvlyd3XaZ0u+2FUpH/9bKVsyTfLoT2V2y6tMZpm7ukx255jUJ/625bGfy9T6qEevDQRktIUCSN058s4RKc8oV59mnfOR9UWWlKWVScGmAinYXGC3vOCfArUMZVDWFtRZWxsNIYAEhDUfi4P6sL6oUNYWFsqu8jJ5Keeo3fIqs1kWZmVKakWF+sTftizPOarWRz0/FBXqtmHSWc/bAsjSo9mSU1jUfCxBZs6cKa+99ppMmDBBrrnmGpUN5ujRo7JixQp59tlnVXyP1atXKzcXxPdASl099u3bJx06dFDWIxpIOactGz58uO56M2bMUCLMWWedJWvWrKl1e8vKytSkkZ+frz7x8NQeoJ4C+2IU9y4iUrfj7OlzZ1W3+9ogaULnsfp3yHPoC79Fs81oBWke59Bb1wRp2hiMARLSvreUHdyqPvG3LaFJx0nxzp/EEBwmwfFd7JZjHpaZy0tUWWdtIFUkBRDPYDSIHJ9gf75g+REZLFJYLnJ6J/suQ4DRIMM7Bsj61Cr1ib9twXrvb69U9ei1gfNoCQWQ+hHePVyKdxarT4PO+YjsFSn5v+eLMdQooUn2VgWhyaFqmanUpMranS+jodY2LKEAUj96h4RKuMEoxWaTnKTjZYGUuoPCwuT3khL1ib9twXrfFBSoenpZWNBZttEYAsgz2dlyYqdO0mxEEAgPSUlJMm/evJp5sMi4+eabZfv27Wo5Aqf++OOPsm7dOociSElJiURGWv+4wnDyAgLUMj1eeukl1cbrr7/u8vYuWLBA5s+fbzc/KytLpfr1JH1795SgaIog3qaid0/JzMz0St3de/WVzJZMudUQdO8V5JXz2KdXH2kZ2NLj9RJ74nvFe+232KNvHymKsH8BIx4+zkWFHj+HBQX2o4vEP4i/9EGpyEmXoJhE3eVwkwlNHiDG0EgJCLO/TwdFt5V2M18WU2mhBEUnOm3j2BeLmAXGQ8RFBip3FVswL+V/kZJbKtJVZzn4dmq47D5qkm6t9ZfPPCFYRncOlOhQkdbh9mUsu2wUQOoPLC1gpREcb53uWwMuLBF9IiQgIkACI+27gSFxIdJ9UXepKqqSkPiQOrWhQQGk/nQMDpY1XbpIXlWVJNmkcNd4qUNHSS0vd7gcbjInh0dIq4AAiQoI0G0jSGe+twWQWbGxsjMiQppVTBDE/dAD87Fcs+pw9iKEsrm5uXauLlVVVQ7rf/TRR5WrjZad5siRI+pz1qxZyjJlypQpduvceeedcsstt1hZgsACpU2bNtKypWc7Slu3/yuh/eriVUXcoXT7vxIXF+eVg7Zrx1aJO9He5JN441iHeOU8btuxTWIr6SrXEGTvyPbab3Hn1m0SEcWgZ95m57FjHj+H7maJI76DISBIpaZ1hiNxQwPiiJ5AYttGZXE+0+B6CGeD+RAuWjvxXggOMEifOOcdIUcCiiUUQDyDMdCoXEac4Ujc0IA4oieQuNMGBRDPEeVAvNAINhikW4jzc+pIINEwNIIAMrN1rMwWaT4iyIABA+T222+XqVOn1gQ0BYjj8corr6hYHWDDhg1KgHAEgqcuX75ciouLa4KobtmypWaZHosXL5bCwkKr4Kpwvxk5cqT06tXLYTYbTLom8x42tVYuNnUKLUPcPc6ePndWdQtNuZvzeaz+HfIc+sJv0UC3imZ5Dr11TRBiiSEgQOIuni/GEPfjCxz75R3J+3GFtBo+RaKGTnR7fZMLcRCI61AA8S0ogPgem70ggDQ2br+pIM4HLC8QCBXWHkOHDpWuXbuqT1hjYII1CMpBnHDE+eefr16UXnzxxZp5Tz/9tBx33HE1LjQQSGDdAdcaMH78ePW3NmlBVS+44AIZNGhQXfafEEIIIYQ0MwLDWjSOAFJWLHnZ1ZbIpP4UlJnk3u/K5MGRIXLPqSF1WN8sZ68slq2ZVbJ6aoQMbucfATObKhRAfI/NPiiA1MkSRIuzMX36dPnmm28kPT1duahA8Ojfv79aDnHEMmaIHjC/RYwPuLZ8+umnyjUmIyNDvvrqq5oy5eXlsnLlShk9erTDQKmEEEIIIcTPMBgbRQDJfP8+aWUsd3td4lgEoQDiG1AA8T02+6gAUmcRBHTv3l1N9QGpbk8//XT55ZdflMvKiBEjJMIiQAq+v/nmm8rKRA9YjGA5Uu0SQgghhJCGpaqsWCrzsyWwpf3LLeYXbv5GQtr3kbDkAbopbwv/rh78ihwwRjejTEnK31KWtk0ijztLt42GFkDKs1Ilqq/9vvgClSazvPhHdXD4a08IkkCdYCFr91XKjwcq5eqBwdK+pb0QlZZvkuWbymV4x0AZ1Tmw1jZahBhpAeJFKnIqJGd9jkT0iJDI3vbBxs1VZsn5Lkd9jxkZI4YA+3NeuL1QinYWScyIGAmKCdJthwKIZ4Cj3Vv/xcy8JCpKAnWyvWwoKpI/S4rlolZRkhBkfz6OVFTIB3nHZFBYuJysE3i00myW944dc9qGrwsgdQ6M+u+//+oug3sLgpoipocW56M2YEUCdxY9goKCdIOdaiA9r7PlhBBCCCHEe5jKSyT7s8clYfLjdsswvyxtu7LaaDfzFTsRAwJIzuqlNX+3GHiunYiS+d48qCVSmvqPbhsNLYAgO03Q7s/FF4E4ceNX/5858YbBwXYCx1kriqXKLLJ2f5X8ON2+gzXpwxL56UCVBBjKJeXmSDuhxLYNiCCN4QJTWl4p/sDBpQeleFexZBmzpMeiHnYiBgSQwysO1/zdenRrOxElZVGKINRa0Y4i6XxXZ7s2qiqrJHVxqpQeKlWZY8I7u++mVryvWFIWpqiAq0lzkiQgzD/dmkqqquShzIyavy+LjrYTOGakHZQqxOMsLpYVHe0DUc89nC6bSkokQI7K6s5d7IQSCCAPOWnDHwSQOokgH3/8sdx6661SWVlZI3wgsBpAeltkd0Hq24ULF8rMmTM9v8WEEEIIIYQ0ggASkthDxEdFkOaAJwSQ3w5VycGsPK9snz9SWVQpldmVFEB8hM1+IIDUSQSBsPHaa6+pAKjXXHONssY4evSorFixQp599lmVrWX16tVy0003qeCoWpBTQgghhBDiWxiDwyT2vNt0l2F+4T/fSkj73rquLHCB0fuugXXiLnlAWZNE9j+zaQggPgzcU/S+a8Cq45sp4crS4+qB+m4R71wYJss3VciwjgG67jK2bbyyubzBBZAz3iySTu1biD/Q4boOkrs+V8K7h+u6ssAFRu+7BtZJnpusrEmiR9hbDCjMQgHEQ4QFBMg9cfE1riq2wKpjWfsOytLjolatdOtY3DZRPsjLk4FhYbruMpb1XmLThr8IIHUSQdasWSNJSUlWgU9jY2Pl5ptvlu3bt6vlCHaKjC7r1q2jCEIIIYQQ4qMEhIRLYAv9F13Mjxp2mcN1EQPE1gXGFsQS0YsnUhsUQNwHMUBsXWBsQZwPvVgfGu1aGuW+00Lq1Ya3BZC+cQESHavfgfQ1gqKDJG58nMPliAFi6wJjC2KJ6MUTqWmjRRBdYDyEwYF7iiWI86EX60MjPihIboh1LD4gBshlfuoCY4mxLjFBEPdDD8zHci1DTEFBQf23kBBCCCGEEBehAOJbeFoA+XpyuBh1gr6SumEMMDZKDJCivCK31yH6VJhMfiWAALev2gEDBsiHH36oXF4s2bhxo7zyyitqOdiwYYPDrC6EEEIIIYR4GgogvoU3BJAWIRRAGhNPCCCZn2ZK4bFCr2yfP3KsstKvBJA6ucMgzsesWbNkzJgx0r59e0lMTJTMzEzZt2+fXHXVVSpWCKxBUG7kyJHe2WpCCCGEEEIsoADiW1AA8T08JYBkfpQpnfvbZ6ohdSPQYPArAaROIghYsGCBTJ8+Xb755htJT09XaW4hePTv37/GFcYyZgghhBBCCPFtzFUVUpGTLkExiWII0A+cWZGbLsaQCAkI13etrirOE1NZkQRFJzptwxYKIHXHZHa8LLvYJMdKRbrG6BuPl1eZZfdRk3RrbZTgAH0Liz05JokKFYkNd90AnQJI3TBVmqQ8o1yC44PFGKh/vMsyyiQgPEACW+h3AysLKqWquEpC4kPq3Ia3BZC4CXESke04LoYvkVtZKfkmkyQF68fSKTebJbW8XC0PNuj/BrG8pdEo0YH657xVYGCjCCBVzm4+TVEEAd27d1cTIYQQQgghGe/eK2UHt0pIh76ScNmjdgek4K8vJefb58UQHCZtr3hGgqLbWi2vyD0sh1+bJebyEok583ppcfxYh22ExneqmUcBpH5kFlbK3pwg6WIjdOzNMcmAFwulsFzkhXNCZeYJ9p2wM98slvWpVTIiKUC+v8K+U7r0j3K57otSiQwW+fvaSLs29LpAFEDqDkSG4p3FEt4jXDrfaW8pkbMuR9LfSBdjqFG6PNBFQuKshY6yzDLZO2+vmEpNknh5osScHuN2Gw0hgMSdFyfyivg8B8rLZUJKihSbTTIvPl4mRtkHNL3m4AH5vaRETgwLk9c7Jtktf+dYrjyQkSHhBqN8lJwsHW3ElAPl5VJpMjW4AFJkqpLDhY0XP9T9SDaEEEIIIYRYYDZVqVS2AJ/425bS1M3VZctLpDxjr91yzMMyy7KO2jCbq7vPFEDqDwZj/zpif742Ha5SAghYt79SdxT3xwPV6+FTb1RXWw/16LWhnUcNCiD1A6lstU+zzvko3FEdRwMiR2lqqd3y0pRStcyyrNX5MplrbaNBBBA/YXtZqRJAwK/F1cfdkiqzWf4sqb5n4hN/26Kth3p2lJXqttEYAsiMg2lSXml/X2nSIsiff/4pZ511lnKDCQoKksDAwJrp9ttv9/xWEkIIIYR4kMrKSnn44Ydl4MCB0rdvX7nllltczmqHjtvEiROV+++6det4Xv5Ldxs98koJjGmnPvG3LS2HXCRBbZIlrNsQCetygt1yzMMylEFZZ20YjUYKIB4iNMgoY7vZG4ef0z1QxvcMlH5xRrljmL1rRIDRIIvOCJHurY3qE3/bgvWwPurRa8NoYb5PAaT+JExMkOCEYPVp0Dkfbc5pIyHtQ6TFwBbS4rgWdstb9G+hlqEMytqCOmtrwxIKIPVjRESkjIqMlO7BIXJNjH0q4wCDQW5tEyfJQcHqE3/bgvWwPuo5NSJStw2jAzcabwogu8vLJDEqSpqNO8zRo0fl7LPPliuuuELatWsneXl5Mm7cOHnttddk7969Mm3aNO9sKSGEEEKIh4Do8f7778vLL78sLVq0kBtuuEG2bdum4p3VxpNPPikHDhyQ1NRUKdYZnfNXWp44Xk2OCEnoKolXPutwuTEoVOIm3ONSGxnLr5W8H1dIq+FTJGroRLe31VRWLJnv3yflWakSf+mDEpLYQ/yVmDCjhAfZd4Iwb9Wl4U7XnX1yiJocMbBtgGy+zr7jZQsFEM8Qe1asmhwRlhwm3R7q5nC5McQoSbOS6tWGBgWQ+hNmNMqSdu2dlpkWE6MmR/QODZWPO3Vy2kag0djgAsjy9h3k1SD92FFN0hJk7dq1atRk4cKF0rt3bzUKAkEEIyEdOnSQnTt3emdLCSGEEEI8QFZWljz//POyePFiGTt2rAwfPlyJId9++61s3LixVmtYiCAvvfQSz0UjUlVeQgHER6AA4ntQAPE9lnpYADkuLEwaE7dFkLS0NCV+gMjISGUJoioyGpWLzKZNmzy/lYQQQgghHuKnn36Sqqoq9d6iceKJJ0rr1q3l+++/d7ge3GXgBvPCCy8ol2DSeAQEhzWaBUhFeZnb6xAH50NEzl5ZLFszq2T11AgZ3M79eBG/HaqSM94skr5xAfL15HBpEeK6aT/xPBRAfI+lPiaA1MkdxmQySUBA9Q2qW7du8sQTT0hpaamEhITI77//Lqeddpo3tpMQQgghxCNgQAdxzCB6WAJh49ChQw7Xmzlzppxxxhly7rnnSnZ2dq3tlJWVqUkjPz+/5l0KkyeBT7dRN9eGd8EgWGO0GxgW6Xa7pvISyf5wvlQePShtJz4oIW2R5dC9OsoO75Kyo5keP3/AYDCKqRFyFhiMjdMuOFZqlpRis3x7eQs5ITFAiSLu8Ht6lZy9skT6JQTJF5PCJSLE4FId6lh74RwCowFXZmP9Fo2N2i4EkAOLD0h4h3BJml3HIKifZUr2x9mScGGCxI1zHATVYDB47RziN2F20UXE08eyqbW77Gi2PJuTI/+Li5MZrWPdvrIhgMw8lC57Kytkecck6RcWVlOHuvd4+By6Wp/bIkh4eLhERFSnwBo5cqTyo+3SpYuEhYWpeCEYHSGEEEIIaargJQkiiC0I9g4LET0Q+wzWru5YvC5YsEDmz5+v646DASRP0r1LspREN3zHq12fHtKyGbRrriiTYz+9LXHtoyVq0o0SFNPObQGkIueQHNu9SmL69ZPMzEzxNO0795DMlu6PstaXbr0rJLNl4/jm9+hTJncNC5Kk1kZx94juOmqSe7eWybjTjDJ/RIiUBIlU58monfadW3vlHIJunbqJObDhfxNRvaKkTWCbRmu3LKNMMtdmSsKoBGkzvo0Yg93vzOf9lichB0Kk7419pdXgVk7LGjoZvHYOO3brJkVV3hFYnNE9trUU1cHSwlvtflWQL79ERsh9/frJmBYtpcjNektNVfJc9lGJaNVKlsfGSnJwiFUdHQOMHj+HrgY4d1sEuf76661Uox9++EE++ugjNbpx/vnnS/v2zoO3EEIIIYQ0JrGxsUqEQFBTDO5owLqjTRv9TsTHH38sR44ckV69elmNNl111VVqUOidd96xW+fOO+9UAVg18K6E+Gloo2XLlh7dp117U6SwXcO7ARz+daO0ijxOwrueZLeseM+vkrv+dQlLPl6iT79ajdxaUllwVLI/X6S+x46bK4GRre2y8OSuWy4lKX9J9IhpVm1kbdspbQYYXLYAyfxgfrULzCXzJdvQXiRX3LYAyXjvPglukySdTUESF+f5NJ1p+3ZKXJd0aWh2by+TuCEhcijfJJNXVcsIb10QJoktjXbn45ZvyuTbfZXy6KgQGdfDXjj5bGeF3LG2TM7sHChPnBVid85t2zi8p1yGjQwVqTaScssC5PwVRdInLkCWjAiXFiUG1xUQdawTvXIOwe79u8Vc2fAiSOqOVEmqtA5qmv9Xvhx5/4hE9omUtpe1tTsfFTkVcnDZQfW9w7UdJCg6yO6cH37rsBRuK5SEixOk5fH2962U7SkStytOUhb/lwZ3SpLkG/NFKt23AMlclSlxF8RJ2cAyyax03jk27Dd47Rwe2L1bIioaPn3rjrSDct1/z5aFbRMlPsj+fDyamSm/FBXJ7DZt5PQW9hl+1hUUyJNZWTI0IkLuiIuzO+dHKirktsPpVm3sOpQmETYBWGEBsiQ7W26KjZWLlEBy2G0LkJsPpsme8jJZ1r6D9CmwT7l8ICjQ4+cwNDTUOyKILbAKmTp1an2rIYQQQghpEAYPHqw+f/nlFxk9erT6npKSolxhEBtEj2XLllllgsnNzVWB4mHtgeCqesBVGJOu+biHTZ5NZrOYpOFFkKqqSjn63WsS2nWI3bKj370qlTmHpDz7oIT3P1uCYztaLc//5xspObC1+vvf30rUsMuslpcfPSh5f3z6X13WbSiXIhf21zYGSFDbHm67XJSl75SMd+9VAkjshfeJbHzB4+cPmM1wSmn40WezqbrdlzeVyo8pFWrey5uMct9p1tfu9uwqeebXagum21eb5Pwe9q4Ot60uURYa/2ZVyrWDAqR3G+sytm0EGsTtfUYMkDP/iwHy1aRQaRECwcE90SE7r9Ar5xCYzKZGcYep/k1YH8v0d9Ol/Ei5lKaXStRpUUqksCR7fbYU/lvdOT26/qjEjbfukGK97NXZNXVFHm+f6aeitEL2Ldyn6u4wu4MYwuCS5N45zfw0UzI/ypS4CXESOy7WpfUNZoPXziF+EwYvudo4o7iyUraXVKt5H+bmyg2x1tYZe8vKZEXOUfX9icwMGfWfd4YlizMyJKWiXPaVlcolLVtKV5tn0Ie5OfJnUZFVGyab/bWMAXJtdAwuLrcFkGstY4BgG3TqUPceD59DV+trHAdAQgghhJBGAjHNIH7MmzdPBXhH3I477rhDOnfuLGeeeWZNuWHDhskDDzygvmO0ChnxtAkWHdr8hIQEvz6XYZ2OdzB/oPoMaBkngS3tLWxC2veBU7iaQtpXB923BOtgXWdteDsIqqUAEnfxfDGGOE8Z25wZ3jFQAgyipmEd7QWOjq2MktSqWng6s4v+OOpZ/81HOZR3t42GCIL60A9lkp3nH6mtI/tWixZBrYPUZEtEj4jq3qBRJLy7/bVtuZ5Wly1VxVXVFiBz6hgDxEIAiTvPO5YdzYUgg0FwBDEN1Ake2jYoSBL/c+UcGm4vgIBT/hNGUA7lbRkUFu60DV8MguoVSxBCCCGEkObGihUrZPLkyco1BfFBevToIZ999pkEBwdbBVDNyclp1O1s6gRFtJLoUTN0l2F+5IAxSswwBtu/CIclD5B2M19R3wN1YmFgncSrnpPK/CwJal0tOrkKBRD3GdU5UFJuru7otrdxhQGRwQbZen2kHMgzSa9Y/XHUp88OkZknBCkBBOVrbWOdfgwebwog935XJkP7+Ed2p7aT20rMyBglZASE2gsUkb0jpceianEwKMa+w4x1uj7cVSqOVkhIor1VGzAEGiiAeIhgo1FWd+6ivifoCBgRRqN80qmzHK6okC4WzypL7oqLk0ujopQAgvK2nBwR4bANfxFAAEUQQgghhPgdyASzZs0aOXbsmFRUVOjGAkEqXcuYIZbExMTI/v37mSrXGGjnc66B+bYuMLboiR+2QkhtddhCAaTu6IkflkDYsHVxsT3nzpa70oa3BZAHR4bIb6W+a9Fjez5sXWBs0RM/bIWQACepi4MigxrFAgRuR76InvhhCYQNWxcX23PubLmjNpb6kQAC6A5DCCGEEL8lKirKYTBUBHuH2OHI7xhuMciOR5oOFEB8D08LIPec6ryDSNzDkQjqTQGkqqRKjmUcc3s9ok9RVZVfCSCAIgghhBBCCGn2UADxPSiA+B6eEEBSF6dKZXnDZ2/xZRFklh8JIIAiCCGEEEIIadZQAPE9KID4Hp4SQEoPlUp0QrRXttEfiQgI8CsBBFAEIYQQQggh9aY8c58c+3Gl+nQkVORteE+Ktq93WAeWoQzKOmtDzP+fgpQCSP0oqXSczvWdrRWy4McyKSjTL/PPkSq577tS9akH1sP6qMcdKIDUjZIDJZKxKkN9OhIRsj7PkmMbHbuSYBnKoGxd2mgIAST51mQJCnYeO8NX+DI/X5YdPaoEBz3+LS2VJdlZ6lMPrIf1UY8jwuqQptYTAkhphXv3BU/CwKiEEEIIIaRemM0myXj7bjGVFkjBps+l/ayVYkD6Wwty178mhX99qb4HREZLaMfjrJaXpm6W7M8Wqu+VBdnS+szrHbYREpes5lEAqT+5xVXyfUqlnJZs3S34bn+lTPqwuqN7MN8kz59j3ckxmc1y+hvFklNilmd/r5CsWyPFaBMf4vY1pfLCH9UdnYRIg10bJh1thQJI3Ul5LEWqiqokZ02O9FzSUwxG6/OR8X6G5KyrzngV2CpQIntZp70t3FEoaUvT1PeKnApJvDzRarnZZLZrozEEkPDO4SLfi8/za3GRzD2crr4fqayQefEJdr/B6QcPSJ7JJG/l5srPXbvZ/QYXZ2XJO8eqRa/YwAAZbJNa99fiIqk0mRpcANlcUiLpZfrCTUNASxBCCCGEENL4uBlfkQKId6lDvMt6QwHE9/CKAOInGJpgG0UeEkCuTjsowYGNZ49BSxBCCCGEEFIvYPURP+lhKd61QcK7n2xnBQKiR1whgS1iJbBVvJ0VCMC82HG3SmVehrQYeK7TNir2/iqZ798n5VmpEn/pgxKS2MPtbS5L3ykZ794rwW2SJO7i+WIM8Z/OlSXR4QF2FhoA896+MEz255rkxsHBdssx4rzu8nBZ9W+FXNAzyG4EGjw2OlQ6tDRKp2ijbhuWhgoUQOpP8u3Jkv9nvrQc1NLOCgTEXxyvUuIGxQbZWYEAzGs/s71UZFdIzCj7zFios7Y2LKEAUj9gtbGobaKkVVTI5Ogou+X4zb3aoaOsKSyQ0ZEtdH+Dc9q0kYTAIGkfFGRnBQIwL9BYbR3UkAJIt+AQad3Kfp8aCooghBBCCCGk3gTHdVaTIyAytDr5Eqd1RPQe4VIbh7etlYriIxRAPEBYoOOO7MS+zuMu9E8IUJMjkM72zuG1p6SlAOIZwjqGqckRAWEB0uZc/ZTgGlFDourVhgYFEM8wtmVLp8t7hoaqyRERxgCZ0bq10zqMLph9eVoAWdahvdxTi4jmTegOQwghhBBCmhXmqioKID4CBRDfgwKIb1HkBQEE4kxjQhGEEEIIIYQ0KwLDWzaaC0xRvuPMGsQ9yqvMcsabRdI3LkC+nhyuLEfc5aEfyuTe78rkwZEhcs+ptVudEO9CAcS3KPJBAQTQHYYQQgghhDQrDAGBjSKAHPvlHQnOy3V7PaJPTnEVBRAfggKIb1HkowJIk7AEOXz4sLz//vvy2WefSUFBQa3lTSaT/Pnnn2qdjRs3qr8JIYQQQkgjYKoUs1knz6lKaWuW8uwDYiqvTrOqR2V+tpocVl9WrOpw1EZDCyB5P66QiFbR4quk5ZvU5IiCMrNsz6pyes6xvLDc7FIbgQEGWoB4EZwPZFWpKq1yWAapcDE5y86COmr7DVIA8QxHKirU5EyY2FNW5vQ3iOVFTvrIR2ppw9cFkEa3BFm5cqXMmDFDTj75ZMnNzZW0tDT56quvZODAgbrlf/nlF1U+NDRUOnXqJL///ru0atVKrZOYaJ3HmhBCCCGEeJeKojzJXbtMYkZfa7cM8wv+/EwCWsZJ4lXPiTHY+iW6JOVvyXxvnvoed8kDEpY8wE4ASX/lRqnKz5QWg8bpttHQAkir4VMkouKQ+CJr91XKWSuK1fdvpoTLqM6BdgJIvxcKJTXPLDcNDpZnxtgHY/zf12Wy5LdySWplkK3XR0pksMFpGzHhAY3iAmMy1U9Uay4cXnlYctbkSFDrIOn6cFcJCLXuhBZuL5SURSnqe/LcZInsHWkngOy5Z49UHK2QmNExkjjFvr9lqjJRAPEQ5SaTnLFvr/q+rH0HOTkiwk6YOH//fkmvrJTJUdFyd3y8XR2PZGbKymO5khgYKJ906iwRRmubhw1FRTIj7aDDNvxBAGlUS5DMzEwlaDzyyCOyZs0aZd1x2mmnybRp0xyuU1VVpSxA/vjjD/X577//SkBAgMyePbtBt50QQgghhFRTsv8v3UPxf+2dB3QU1dvG3930QgghCS30XkRFbAgoiChix4KKqNgVRMEGYkexoCiWPyIqFuyi2MEGgoqg2Oi9lwQSSE822fnOc3H2m52d3WSTTTbZfX7n7NnszJ2SuTN37n3uW4q2rFDfEDHKcrM81pfsXCWiOdWnZOdqj/VlefvVtr6OUdsCSHKf4RKqLN5eJuWaqM+S7Z6WAztynUoAAQs2lVnuY/5/y1Fu+yFnhceoykCkugIIxJyd+w9JOJC/Ml99Q8TAx0zBugIRVJNTpHD9YXHKw0rkv+30fZkpKy6TzLmZkn5BuqSfk+73OUJo2fb0NmVt0ubONhLfLjxTVQOHpgmePHxWFHla0O1xlCkBBPxSWGC5j58LDi9HuT0W1h5/FBX6PEY4CCBBFUE+/fRTZa5z3XXXuZbdeuutsnLlSvn3338tt+nXr5907drV9RsWIYMGDfJanhBCCCGE1Bw2e4Q0OuUqy3WNTrlaIlMylBVHVOOWHusTe54uMRnd1CfxyMEe67ENtsU+vB3DFxRA/OPaXtHSt1WE+lzbyzM1btdUu7IA6dzYLk8MshYfnhwUo9ajHMr7e4zaEEDOmFMoJQ7v7iGhRNOLm0p002hlxRHT3PN6pZycIvGd4tWn0cmebl7YBttiH9iXFc4SJwWQABFrt0uvuDj1ubBhQ4/17aOjlQVI2+hoGZdqnep4fFqaWo9yKG/mwobJXo8RLgJIUN1hIHbApSU+/v/Vvh49erjWHXHEERXuA/FAvv32WznyyCO9likpKVEfndzcXNe2gY4nYrfbxS7hYV4XTHCdayoWjNp38EPlhAU1VY+Hn0PWYSg8i5rJhJPUjzpkrK7wIjIhWeI7nmC5Dsu9rVPbJqVK08uf9LreZrPVGReYULYA0clIssviqz1N4431YeUCY+TcLlHqU9Vj1IYAsjKzXI7v4TnADEWSeiWpjzeiUqKk3cR2PuvcygXGbR/xUbQACRARNpu83aq1z/qwcoExcmqDBurjjaZRUZbHCCcBJKgiyKFDhyQ5OdltGeJ7wL0F6yrDpEmTZMOGDfLee+95LTNlyhR56KGHPJZnZWVJcXGxBJIe3bpIVCOKIDWNo1sX5U5VE3Tq2kMyk3wHCiKButZRNVKP3bt2l6RI7y98EjiadG1SY89i5x7dpSDB3TeZ1MB1LsgPeB1WJsg5ITUJBZDQI5ACyLdXJMjkVUyQGSjMcUZqywXGUcr+eqBwalpYCSAgaC1AXFycFPzns6RTVFSk4n5gXUU888wzMm3aNJk3b5507uw9T/yECRNk3LhxbpYgLVu2lLS0NElKCuxAaeXqtRJ7hP/BnYh/FK9eK+np/vscVob1a1ZK+rH/bzlEao71a2JqpB5XrVklqWWpAd8v8WT/mv019iyuW7lKEkxCOamB63zwYMDrEK6qhAQLCiChR6AFkONaRIisqpFTJbUkgBRuLpSCvdYxMYj/HCorkx0OR9gIIEEVQdq3b6+Cm8JsFua4YMuWLa51vnj22Wfl3nvvlU8++UQGD/b0ITUSExOjPpYm8wE2tVYuNkIRpKYx3jM1sm8VIYrU13o8/ByyDkPhWbQxBXq9rMOauicIqQgKIKFHjQggpN4LIFuf2irNWjarkXMMR8o0LawEEBC0nsrQoUNVWlzE9NB59913lYXG8ccfr36XlpbK7NmzZePGja4y06dPV9Ydc+fOlTPOOCMo504IIYQQQuoOFEBCDwogoUUgBZDYFrGS3ITWooEiOTIyrASQoIog3bp1k9GjR8uIESPk0UcflfHjx8vjjz+uXFyiog4HVCosLJSrr75alixZon6///77MnbsWDn//PNl3759SiDB55133gnWv0EIIYQQQuByvPxT2TXzevVtRcnejbL7tdGSOXeyOB2ecdmwDOtQBmV9HUNz/n92Dwog1SO7yCmFDs+Ydlh2/vuF0vN/+bJij3U2lWm/lkin5/PVtxV/7C5X22M/VsfwBgWQqrF//n5Zf/d69W1F0dYi2TBpg2ybvk1ldTGDZViHMihblWPUhgDSenxrsdtC3+qwyOmUMbt2ynlbtshqL7Es38jOliGbN6lvK1YVF6vtsR/sz+oYtiqcWyAEkGxTaIzaJKh3D6w6ZsyYIbt371bpchcvXiyXX365a310dLRceeWV0qFDB5eJLX5j+cKFC10fXSQhhBBCCCG1D0SJnB9fk7Kc3erbKFLo5C79SBxZW6Vow1Ip2vS7x3oswzqUQVlfx9CzEFEAqT7FDqd8taHMY/mX68vk07Vl8m+mUx5f4ilylDs1uePbEtmQ7VTf+G3miZ9L1PbYj9UxEJDRDAWQqrP3vb1Suq9UfWsW9ZH1ZZaU7CyRvBV5kvePZxDrvL/z1DqUQVkz2GdFx6gNASQirv5YHFSHRQX58n1+vqwvLZFXsg94rC/XNHkqK1O2ORzqG7/NzMo+oLbHfn4qyLc8htNiu5oWQGYc2C/Z+cETQYIeGnnYsGHqYwXS58LSQ+eiiy5SH0IIIYSQ6oDg7HfccYd8/PHHyv0WMcYwOdO0aVPL8gjcDrddTN6sXLlSUlNTVf/l/vvvl4SEqqX8DCVs9giJyegmJTtWqm/8NhPbuqcUrlsitug4iW7iGf8Ny7BOKy1SZX0dA6kiKYAEBrtN5OimnvXVq1mEJEaL5JeKDGzrOWSIsNukX6sIWbStXH3jtxls9+HqMrUfq2OgHo1QAKke8Z3ipXBdofq2WdRHYtdEyV2eK/ZYu8S29gxiHdsmVq1zFjtVWY/6stsqPIYRCiDVo1tMrMTb7FKoOeX4+HjLlLrHxMXJ8qIi9Y3fZrDd/Lw8tZ+uMbGWxwiGADJ9/345tm1bCVsRhBBCCCGktrn++utl+fLl8t1330mDBg3kqquuknPOOUd+++03j4EZ+PHHH2XBggXyxBNPSPfu3WXdunVy2WWXyfbt25U4QkSaXPKIOLJ3S1RKc8vL0eDoMyW2zVFij02UiDjPDH1RjZpJixtfFWdxvkQ1au7zGAe/nCr73r9PotNaS/pFD4k9xv/Z5YO/vCeHFr8tDfuNkOQ+w8O2CtMTI6V9iqdxOJZtHZsoOcUiHSzWgwVXxMuGA07p2Nh6/Y29o2VQu0hpFCvSON6zjPFJowBSfWBpASuN6CbRlutTBqZIQvcEiUiIkMhEz2FgTHqMdJraScoLyiWmSUyVjqFDAaT6tIqOlu/at5dD5eXSOtr6er/SspVsKy31un54ciM5MT5BGkZESHJEhOUxoiyW17QAcmtqqqwL4gQCRRBCCCGEhBU7d+5UwgWCrPfsedji4KWXXpIePXoosWPgwIEe2wwaNEh9dBDEfeLEiXLzzTcrSxK46oY7togoJUr4wpu4oQNxxEogMR+jrDCXAkiA8DWZD+GisQ99KTrCJt3TfQ+EvAkoRiiABAZ7pF25jPjCm7ihA3HESiDx5xgUQAJHshfxQifaZpOOFplQjXgTSHRsQRBAbmycKrdL8Aj9iDKEEEIIIQZ+/fVXFYtswIABrmWw7mjSpIn88ssvlb5WBw4ckLi4OFdAd1J72CIigmYB4qxEHARSeSiAhBYUQEKPf2pAAAk2tAQhhBBCSFixZ88eZbnRsGFDt+VpaWmyd+/eSu1j165d8tRTT6ksdlbuM6CkpER9dHJzc9U3gnrqgT0Dhd1mE7vU/uAcQeuDcdzohIYSGYOUjv4d++CvH0jez+9Io/5XSPKJF/u9vbO0SPKyMwNef8Bms4szCPOTNntwjgsKHCIPLHLIIwPjZGK/GPH3quaVaDL03SJZvV+TBSMbSO/mEZXah7rWNVCHAFlLtKA9i/agHhcCyI5pO6R0T6m0u7NdlYOgbn96u8S3jJfWt3sPgop2t6bqEM+EZg/Otaxrx/23qEiu371LOsXGyYyWGRJvj/D77p55YL+8kJ0tY9PT5frGqa7tVdsT4Dqs7P4oghBCCCGE6B3BSkTJz8nJkaFDh0qXLl1kypQpXsth3UMPPeSxPCsrS4q9pDusKp3at5GiRrU/8GrRvbMkBeO4Pbr6fdyCNYsltWitxF8xXhK69vNbANEcJXJwybuS0LGdZGZmSqDJaNdZMpNqf4a0YzeHZCYFx5qpVZcSuamDXS7pHiX+XtEiCCjLSiSiuVPmXR4jrRvbK72PjHaNa6QOQce2HUWLrP1nIrlrsqRFpgXtuM5Sp2R9nSUJaQmSfkN6hW43VpTsK5HM7zOl6alNJe28NLFHexcEbG1tNVaHrTp2lILymhFYfNEptbEUBMFKopOX424tLZEX9++XIc2byy2pjUXsEeJvPpev83Lll8QEeeCII2RIgyS37VtF2ANeh3l5nlmPrKAIQgghhJCwAm4viOMBy4ykpP+PP4HOGNb54uDBg3LaaaepDHZfffWVxMZ6942fMGGCjBs3zvUbx2vZsqWyODEeNxCs37RV8ltUxrM7sOxd8aekts+WyAaNPdaV5R2Q/H8WqIwuca2PtEx5m//3fPV34pGnW2aUKdr2t5TsXC2JPQe7HSNr1TpJO8rmlwXIoSVzpGHfyyW5aX+RHPHbAiTzo4ekNGubdOx+pKSnp0ug2bl5naS33y21zYbVJZJ+QoyUOTV55Q+HWnbdMVESaREs5IctZbJke5lcc3S0tEjyHKDuynXKq3+WSt9WkZYZZczH2L62WMYMiBM5bCTllwXIhe8WyqrMcvlmRIIcGxXh1z52bm5eI3UINmzZIFpZ7Ysg29Zsk9Zl7jF5HNkOyf4pWxI6JUhiN89sL1q5JtkLs9XfKaekiC3Cs87zV+dLwfoCSemfIlEpUZbHzcjLkG3T/kuDO76NFDcuFvHMiFxxGtyn/0uDO6K15Npzfe7DtsVWY3W4fcMGSXD4+Q8EgHW7dspn8YeDhV6UnCyRFlaGSwsKZEVRoVzQMFmaWrhi7nU4ZO6hg9IrLl5OsAg8WqZp8uHBg27HWL9rpyS0yPC0ANm5QzpEx8h4uMDk+Nlo/mcB8vz+/TImNVUuVCLLHrf126MiA16Hvt7JRiiCEEIIISSsOPHEE9X3okWL5Oyzz1Z/r127VrnC9OnTx+t2hw4dUql0EQPkm2++UVllfBETE6M+lubjATZ5dmqaOCsV3i6wlBUXSOZnT0rTy5/0WIflEDDEZpcWN74mkSYrh7y/vpHsb2e4zr9Br7Pc9527X/a+fz/UEinc+pfbMZRLUSX/X2MMkKQTL/Hb5cJZUiiZHz6gBBBkp4nY8EXA6w9oGpxSan/2WXMePu4rv5fK6K//s1DSYuWW49yDKe7MdcoZb+VLuSby/WaHLL7ac4B12ccFsmR7uWAsvfW2RMkwCSXmYyREid//MwSQM+cUysrMcvn2igQ5rjnuA//2UVRSWiN1CJyaMyjuMIefCffrsG3GNilcX6iiQHae2tlDxDjw4wHZ8/Ye13k3HtTYQ0TZPHWzurx5q/Ok3cR2Hsd1lDpky9NbDgsgd7aR2HaxHudRKQHkqcMCSMvbW4otzlbhPmyarcbqEM+ErYZcbXxR4HDI5L3/CQVOp1zWqJGHwHH99m1SjthW+fnydivPQNR37topK4qKBJLyt+3aewglH+bkyOTMfW7HcJr+X2MMkJdbNJcEtLV+Xg9jDJAbGqVYbq/angDXYWX3x8CohBBCCAkrWrVqJRdddJHcc889smHDBhUjZPTo0XLUUUe5ZYbp0KGDjB8/3mXFAQEkMjJS5s+fH3BLDlIzVDsIqkkAiWneuUbOk1ReADnDKIC08D9A47Jd5bIj6xAveYAoKyhzCSBVjQGiCyCtx3uPAUJqh39CMAiqFbQEIYQQQkjY8eqrr8qYMWOkV69e4nA4lIvL559/7jaLVFZWJuXlmHMT+frrr2XZsmVqfUpKitu+Nm3aJK1b+04NG6rYo+Mk9Zy7LNdhef7fh91hzFYgIPGoIZZ/62Cb9IsfPuwOc+Rgv8+NAoh/3NA7yvJvHVh1zB8Rryw9ru1lHUPkvWFxMmuFQ/q2ivCwArE6xmv/lNa6AHLaWwXSNsO3FVeo0PKmlpKzKEfiO8VburKkDEix/FsH27S5o42yJml0srtVggtNKIAEiLiICJmUftgl8+LkZI/1sOqYmdFSWXpcaArsrfN0s+by0aFD0isuztJdxrjfi03HCBcBBFAEIYQQQkjYAVeW2bNnq483IG7omV8uvvhiGTZsmGU5WIeEKxEx8RLZwLqji+XJfS/zui1igJhdYMzEtTlKffyFAoj/IAaI2QXGzKntItXHG4gT8sApMdU6Rk0LID3SI6RRqvUAMtSIahQl6ed5j7mAGCBmFxgziCViFU/EdYwGUbQACRB425hdYMycmJCgPt5oEhUlt6R6Fx8QA+Qyi2OEkwAC6A5DCCGEEGJBRESEyzIEYgjEDqsPqVtQAAktAi2AfHN5vNgtgr6SqmGPsAfFBabgkL95Sog3HE5nWAkggG9uQgghhBASElAACS1qQgBpEEMBJJgEQgDJ/CxT5HCCExIADpaVhZUAAmgJQgghhBBC6j0UQEILCiChR6AEkMy5mZKY7N1Fh/hHpM0WVgIIoAhCCCGEEEKqjVbuUFlU8O0NR85uKS/0npkD61CmomOYoQBSdZw+srnuL3TKxmzvqTFLyzVZlVmuvr2B7bEff6AAUjWcZU6VqQXf3ijZVyJleWVe12MdylTnGDUtgKRfkC4JDb3HxQglcsrKZFup9wDCpZomG0pK1Lc3sD32442GkZFBEUDKfTU+NQzdYQghhBBCSLXZ9/59UrJjpcS07CFNL3vcY33en19J9oKXxBYdJ82umi5RjZq5rXfk7JE9s28VrbRIUgbfLA2OPtPrMWKbtHUtowBSPTLzy2RTdpS0T3GfG92U7ZSjXs6X/FKR/w2NlRt7ewY0HfxWoSzaVi4nt46QhVd5Dkpn/F4qN31ZLInRIn/dkOhxDKshEAWQqgORoXBdocR3jpd2E9p5rM/+IVt2v7lb7LF2af9we4lJdw9iW5JZIpvu3yTOYqc0H9lcUgam+H2M2hBA0s9JF3lNQp7tpaVywdatUqg55f4mTWR4smdA0+t2bJflRUVybFycvNHKM0vZewdz5OF9+yTeZpe5bdpIq2j35xjHKHP6J2gFQgApcJbLnvw8CRa0BCGEEEIIIdVCc5arVLYA3/htpnjbP4fLlhZJ6b5NHuuxDOuMZb0dQ/tv1pMCSPXBZOyfez3ra8WeciWAgB+2lFnO4i7efng7fFvN6urbYT9Wx9DrUYcCSPVAKlv9W7Ooj/w1+eobIkfxtmKP9cVbi9U6Y1m3+nJqFR6jVgSQMGF1SbESQMBvhYevu5FyTZM/ig63mfjGbzP6dtjPmpJiy2MEQwC5fsdOKfVhnVLTUAQhhBBCCCHVAuluGw0YJZEpLdQ3fptJOuFCiUprI3EdT5C49r091mMZ1qEMyvo6BrL2UAAJDLFRdjmzo6dx+NBOkXJel0g5It0u9/T1THsbYbfJ1NNipFNju/rGbzPYDttjP1bHsP+XghpQAKk+TYc3leim0erbZlEfaUPTJCYjRhr0aiANejbwWN/gyAZqHcqgrBnss6JjGKEAUj1OTkiUUxMTpVN0jFyX4pnKOMJmkzvT0qVNVLT6xm8z2A7bYz/9ExItj2G32K6mBZANpSXSPDlZggXdYQghhBBCSLVJOvY89fFGTNMO0nzUC17X26NiJf2CSZU6xr5ZN8ihxW9Lw34jJLnPcL/P1VlSKJkfPqDiizS55BGJad5ZwpWUOLvER3kOgrDsk0vifW57+4kx6uONXs0i5J+bKg5gSQEkMKSenqo+3ohrEycdJ3f0ut4eY5fWt7au1jF0KIBUnzi7XZ5vkeGzzJUpKerjjW6xsfJp27Y+jxH5Xyr42hRAZmW0lNejoiRY0BKEEEIIIYTUK8pLiyiAhAgUQEIPCiChx4wACyA94+IkmFAEIYQQQggh9YqI6LigWYA4Sr1nziB+1oeInDGnUFZmlsu3VyTIcS38jxexbFe5nPZWgfRIj5BvLo+XBjGVN+0ngYcCSOgxI8QEEEB3GEIIIYQQUq+wx/h206gpAaRk9zopytrr93bEmuzCctlcSAEkVKAAEnrMCEEBBNAShBBCCCGEhDSBEkCQojcyyjNVLKkaZeUaLUBCBAogoceMEBVAAEUQQgghhBBSJcryc6Rww1LLdVi+65UbJPu7lz1Soaptc/fL3jl3qU9Z3n6P9dgG22If3o5R2wJIdFpraZjaVEKRnblO6fd6gfrsyj2cltNcH7d+XSydX8iXeWsdlvvAcqxHOas6Nx8jJT6CLjA1SO6KXFl/z3rZ/fZuy/pwZDtk82Ob1ceR41mn2AbbYh/YlxUoQwEkMCDF7Yjt29Rnn8O6Ph7dt0/O3LxZvs/Ls9wHlmM9ylnV+V6Hw+cxwkEAARRBCCGEEEJIldA0p+QsnG25Lmfh61KWvUvy/vhcHAd2eKzP/2e+lOxcrT75fy/wWI9tsC324e0YtS2ApF/0kNgrSAtaX5m1olSWbC9Xn1krPAdHa/Y75fllpbL+gFPu/s46Lspd35Wo9SiH8hUdIzrCFpQYIAdyCyUc2PvBXindWyrZ32VLyW7POstelC2F6wvVJ2dRjsd6bINtsQ/sy4ry0nLZ+tRWiW0RK63Ht5aIOP/jumR+limZczMl/YJ0ST8nXcKVYqdTVhQVqc9Hhw55rN9UWipzDubIVkepPLM/y3IfT2dlqfUoh/JmPjp00OcxwkEAARRBCCGEEEJIlYlre7SX5b3Ud0RSukQmpXmsj8noLmKzq09MRjeP9dgG2/o6Rq0LIFWIRVJf6NcqUqBJ4NO3ledAtlVDu7RueFhwGNzeOqzg6f8tRzmU9/cYtSGATP6pRPYfCg8RJLHH4fTEUY2j1MdMQueEw6NBu0h8J89727idvi8z5YXlFEACRJTNJngq8OllIRw0i4qS5pGHn7E+8QmW+zgp4fBylEN5M8fExfs8RjgIIICBUQkhhBBCSJWISmgojU693nIdliceNUSJGfZoz45wXJujpMWNrx3ukCZ5draxTfNrXpSy3CyJatzSr/OiAOI/p7aLlK23HR7oZiR5ChiJ0TZZeXOibD/klK6p1vOoz50RIzf2jlICCMpXeIwfymtdALnvxxLp072JhAPNLm8mKQNSlJAREespOiV2S5TOUw+Lg1EpngNmbNPh0Q7iOOCQmOYxlsewRdpoARIgou12+bZde/V3UwsBI8Ful3lt28keh0PaR1vHJpqYni6XJCcrAQTlzZyYkOD1GOEigACKIIQQQgghpGrYI8Vmsx6MYnl0aivfHVEL8cNt99FxFe7DDAWQqmMlfhiBsNEtzbsFB+rc1/rKHKOmBZBHBsTIsuLQtegx1wfcVHxhJX6YhZAIH6mLoxKjguIC49Q83a1CASvxwwiEjQ4x1oKUXue+1ns7xowwEkAA3WEIIYQQQkhIQAEk9Ai0ADKpv+8BIvEPbyJoTQog5UXlcnDfQb+3I9YUlJeHlQACKIIQQgghhJB6DwWQ0IMCSOgRCAFk29PbpKy0rEbOL1xFkFvDSAABFEEIIYQQQki9hgJI6EEBJPQIlABSvKtYGjVtVCPnGI4kRESElQACKIIQQgghhJBqU5q5WQ4unqO+vQkVh379QApWL/K6D6xDGZT1dQzRNLf9MgtM1Skq+/9raea9lQ6ZsrhE8kqsy/y9t1we+LFYfVuB7bA99uMPFECqRtH2Itn3yT717U1EyPoiSw4u9e5KgnUog7JVOUZtCCBt7mwjUdG+Y2eECl/l5srMAweU4GDF2uJieX5/lvq2Atthe+zHG3EWAVRrQwApdvjXLoRcYNScnByJjo6WhP9S+tTUNoQQQgghRgoKCsThcEhycnKNbhPqaJpT9r17rziL8yRvxReScescsSH9rYGcRbMl/8+v1N8RiY0ktlVPt/XF2/6R/Z8/pf4uy9svjQff7PUYMelt1DIKINUnp7BcFm4tk1PauA8LftxSJpd+fHiguyPXKS8NdR/kODVNBr5ZKNlFmryw3CFZdyaK3RQf4u7viuV/vx8e6DRNtHkcw2mhrVAAqTpbn9gq5QXlkv1dtnR5vovY7O71se/DfZL9Q7b6O7JhpCR2dU97m78mX3bO2Kn+dmQ7pPnI5m7rNafmcYxgCCDx7eJFFkrI81thgdyxZ7f6e2+ZQ+5v0tTjGbx6x3Y55HTKOzk58nOHjh7P4NNZWfLewcOiV2pkhBxnSq37W2GBlDmdtS6A/FNUJLtLrIWbkLcEWblypfTq1UuaN28ujRo1knPOOUeys7MDvg0hhBBCiJGsrCwZMmSI6ks0bdpUjjvuOFm7dm3AtyF+4Gd8RQogNUsV4l1WGwogoUeNCCBhgq0OHqMgQALItTt3SHRk8Owxgnbk4uJiOeuss6R///7yyy+/SH5+vgwaNEhGjRoln376acC2IYQQQggxM2LECDl06JDs27dPYmNjZeTIkXL22WfL6tWrJcpLisKqbBMuwOqjyaWPSuH6XyW+04keViCg0clXSWSDVIls2MTDCgRgWerZd0rZoX3SoNdZPo/h2PQbXWACRKP4CA8LDYBl7w6Lky05Thl9XLTHesw4/zAyXj5Z65Dzu0R5zECDJwbFSssku7RtZLc8htFQgQJI9WlzdxvJ/SNXko5J8rACAU0uaqJS4kalRnlYgQAsy7gxQxz7HZJyaorHeuyzomMYoQBSPWC1MbVZc9npcMjljTwtD/HMvd6ylXyXnyeDEhtYPoPj09KkaWSUZERFeViBACyLtGfXugDSMTpGGjdMDj8R5IsvvpAdO3bIk08+qToS+DzwwAMybNgw2blzp2RkZARkG0IIIYQQIxs3bpQFCxbI/PnzlVUHQN+iXbt2avnQoUMDsk24EZ3eTn28YY+Jl4YnXuxzHwndTq7UMfas+l4chXulySWPSEzzzn6fa8nudbLv/fskOq21pF/0kDq3cCUu0vtAdngP3+LekU0j1McbSGc7oV/FKWkpgASGuFZx6uONiLgISTsrzec+kk9IrtYxdCiABIYzk5J8ru8SG6s+3kiwR8j1jRv73Ie9EmZfgRZAZrbMkEkViGgh6Q6zbNky1XGAOalOv379RNM0Wb58ecC2IYQQQggx9yfASSed5FrWtm1badGihWtdILYhNYdWXk4BJESgABJ6UAAJLQpqQACBOBNMgmYJAr/a1FT3VDwpKSlit9vVukBtU1JSoj46MGMFBw8eFKefQWAqRhMpKQjwPonVdUb91QSIz3WwJHiqZDihrnVN1WOR90j3JLDU5LOYZ8j+QOrPc5j7XwR6TFDUVdBngDWpObg6+hi++iD+blObfRCIAsHog9iC1PeJik+SmMYZfh+7ZO9GyfzkMYlJayVp594t9iqcf8Gh7Bpp+8rKtaD0QZxiC1rfp6RcZPDbhdI1PVLeuzBeytW5+LePqb+UyGOLS+Xe/rEy+viYSm2vrnVNvb/KtaD0Q2x4GuvAcbO+zpKsz7NU/I+009L8PifEANnx4g4p2VMibW5tI3HN4qz3UV5zfZByTQtKH0Sz2erccQuc5XL7rt2y2VEq0zNaStvYWL/PcVVxsYzdtVPax8TK0y2ai9NmV/vAdQ5aH0QLEqNGjdJ69+7ttqy0tBRnq82aNStg2zzwwANqPT+8BrwHeA/wHuA9wHug9u6BHTt2aHWV6dOna1FRUR7Lu3Xrpo0ePTpg27APwjaHbQ7vAd4DvAd4D0id64MEzRIE8TvgQ2sEgcYATEsDtc2ECRNk3Lhxrt+YeUE2mcaNG4stGGGv6xBQylq2bKnirCRV4G9G6iasw9CA9Vj/YR3+P5h9ycvLU1nc6iroTyDF7YEDB1R/wNin8NUH8Xcb9kG8w2cmNGA91n9Yh/Uf1qH/fZCgiSDI8PLwww+r1HJduhzOMf3NN99IdHS0nHDCCa5/YteuXcrlJT4+vlLbmImJiVEfI8nJwYtEWxeBAEIRpH7DOgwNWI/1H9bhYRo2bCh1GcT1iIiIkG+//VaGDx+ulv3+++9K4EBfQ2fv3r0SFxen/p/KbmOEfZCK4TMTGrAe6z+sw/oP67DyfZCgiSADBw6Uvn37ypVXXinTp09X1hkTJ06UMWPGuEQK+M7CUuH111+Xq666qlLbEEIIIYT4Ij09XW644QYZP368supITEyUm266SU499VTp06ePqxwmWM477zx59tlnK70NIYQQQuo2QRNB4Ioyb948uffee2XkyJFqtmTs2LFyzz33uMog4ClMTPUgZJXZhhBCCCGkIiBspKWlKZfZ0tJSGTx4sEyePNmtTLNmzdwmWSqzDSGEEELqNkETQQDcXP73v//5NOnZuXOnX9uQygMR6YEHHvBwFyL1B9ZhaMB6rP+wDusfUVFR8uCDD6qPN3799Ve/tyGVg89MaMB6rP+wDus/rEP/sSE6ahW2I4QQQgghhBBCCKlX2IN9AoQQQgghhBBCCCG1AUUQQgghhBBCCCGEhAUUQeoZixYtkltuuSXYp0GqSFlZmRw8eNBtGYLrIfaNr09BQYHlvrKystR3RTgcDlXW6XR6rMvMzFTHMK/DMffs2VOl/zPUQL5xvS6QMrOwsLBS28HbEOkzS0pKfO4T19lbPerlzPcA6gvLcf/4U4548ttvv8k111zDS0MIIYQQEgZQBPGD/fv3y4svvqhS5KHD/Mgjj8iSJUukNtmyZYvKkOOLlStXyvDhw1UKYSPPPfecSjW8e/fuGj5LYuaff/6RAQMGqJSK7du3VxkH7rvvPpUGetWqVSoNo/7p0qWLtGvXzm3Zhx9+6DZgO+OMM6RBgwbSrVs3lQsb2zz99NMeA+ClS5fKaaed5iqLYMM4j++//95V5swzz1SpqF977TW3bd966y054ogjWJn/PTtt2rRRdXHMMceo7BCtWrWS0aNHWwpFEJyuv/56lUazQ4cOkpqaKp06dZLnn39eCVJW+0QWrEGDBsnGjRvd9oVyqJ9LLrnEQ7zC8mXLlvlVrq6Rk5MjM2bMkBtvvFFGjRolDz/8sCxcuLBWz2HHjh3y8ccfe10PEevSSy+VX375xW35O++8o9rarVu3ui2fOnWqPPHEE1JboK3HeeDdZBYzH3/8cZk5c2al97Vt2za1L4hqhBCiA6H+7LPPlu+++44XpR6Dd9OkSZOCfRqkGjCcZ2CgCFJJPv30UzUw/eKLL6RHjx7Sr18/KS8vVw2JedARbDBT/f7777tmq/Gw3H777epcr7jiCmnevHmwTzGswABqyJAhatCMwTEsA/7991+Jj4+X33//XY4++mg3q48RI0ZIz5493ZZBvAJff/21uveOPPJINXDD/jBYwX25b98+tV+db775Rvr37y+9e/dWA3WUxWAJ4p1ZSIM4gmwHRUVFtX596gtNmzZVdbFr1y7Jz89XbcKaNWtU/RkHwbjOxx9/vBIzfv75ZzXIz83NVfWxefNmVW/mfUKYxH4hiqH+zUA8w/Y//fSTz3OsbLm6As4V7SpEvq5du8rJJ5+s2qtHH31Uhg4dKnUp6jqEzI8++sht+SuvvKLugwULFrgtf+aZZ6S4uLjWzg9tPdp8iB1vvvmm2zoISv4IYLhfsS8r6yVCSHiAPoNZ9I2MjFTvry+//DJo50X8ey9gwkufeNFBP2/OnDm8lPWUYcOG1eokSygT1BS59QUMdDAzdvfdd8tDDz3ksd44AIK7ygcffCC33Xab6pBicIPZXwyMkI4WxMbGSseOHdVMMWaUzdveeuutqoHCy6Zv375y9dVXi93urldhMIXOLgZQGORed911EhERYancY3YVg+cff/xRlQUQcN599121DC+2008/XS644AK3bSsqgwYW+7733nvVzAAsGiCw4Pwx820sc+edd6qBGcpg4Icy0dHRKt3x+vXrlSXD2LFj1bXRwfXB4BH/O2ay8eAfd9xxHse///77VUOPQQr2jdn5Jk2aSF1h9erV6j6AEAWLDIDrM2HCBL/2gxcZZnrPOusstwYQ1wfWBk8++aRbveP6oeyUKVNcy3HNcU/hYwQDbwgjzz77rN/nFY7YbDbp1auXSxSdOHGisgoAuB8hZsydO1eSk5Nd5THYnzZtmtd94p649tpr5aabblIWPbAM0cH9DEHrrrvuUtY93qhsuboA2k0807AAgRWT1Xqj9RPaU1hPoc1Au/fUU0+pZwLtsi5U4DnANYSllXlbtFNoM2HpgHYEbSbaNSNoc19//XXZvn27EhpxbkiJCmBBhbZQByIHrjGOh+V43sDatWvVAALlIVDiOAD7geXPlVdeqc4TQPDB9iNHjlQCkA5ENmyH+wrWWLDugDCB4+AZRh1ffPHFHtds8ODB6v7D+8rYlhrxtS+IpPfcc4/6G/8P1sNSCe8zQkh4sGnTJtVG4b31xhtvqMkzHViWoj9J6h5o2/H+gdUvwGQMLMPRvqMvr48jUIcYy2CiRn8XkfoDxkMQItH3/Pbbb1U9X3bZZcE+rXoJLUEqwQsvvCCNGjVSHVIr0LE1uqvArQAuBunp6cp0MC4uTpnFn3feeeqDzu7ff/+tbmC42Ji3hUk8BkBYjwHpmDFj3I4HSwIMbjGgRkcd5uPjxo3zOC+oveeff74SV+C2owsgAMIKBkrdu3dX5w8xQR9MVLYMYgygMz1w4EDV8B577LFKjDjxxBNdVih6GVwPDAzwsGLgiAHCKaecokSOPn36qBetbu2gA+sJXC/MCKPxPvXUU9Wsq/n46Pjj2qGzjpkLDPDrUvwDDMggUGEAVp3ZYczmYpB28803V1gWAz+UxYC6MuAehSUIxBXcX0Qqfd3gJoEXkm6eCGsBDEJ1AcQfIH6g42kWPQGec7QbZmuEqpYLNhAzMMi2EpbN7SruZbhnoW1EW4x2Fe0fXMH0dhVtyrp161QH0Ojyp2+LwT5ED7SZkydP9ogBgjYLbQnEAwhcEGYgguigncN11d0Mf/31V9XGo90yuu9AXMB9gfYIwox+fuh44tnC8SHYAtQ12mkI5UY++eQTNdCAWI6OLcRnuNjAQgwWMxDUIZ6YwXsAwpB5fzoV7QvnjetobH/RrhPiL5hEAXjf4x0Olz8Il6TusGHDBjV5AsEVE1O6hQdcdlu0aKG+0e+DoK+DdgyTWYwXVvf4/PPPlWgOaz6AsQbekXgnoY71Pgr64bD+pVtT/QP9GYyzMKbDs4uJX7StpIpopEJ69+6tnX766ZW6Uq+//jpaGW3p0qUVlu3bt6/22GOPeWz7+eefu5YtXLhQs9vt2vr1693K/Pvvv64yb7zxhpacnOz6/e2336oy3bt317p27art2LHD7bi//fabZrPZtD/++MO17LPPPtMiIyO1TZs2VbpMTk6OOs4111zjKlNUVKQ1bdpUe/rpp93KPProo64yS5YsUcueeeYZ17JvvvlG/Z8lJSVer9fUqVO14447zvVb3/cjjzziWpadna32g+tWl5g5c6aWmJioxcbGaieddJI2YcIEtzo0csMNN2jHHHOMx/K33npL/b+bN292LSsvL1f1q38OHTrktaw3cKzx48drZWVl6n657bbb1PL//e9/WuPGjavxX4cOuMdatGhhue7VV19V1xr3Y15ense97WufeFZQb9u2bdO++uordYwrrrjCo1z79u3V33feeafWsWNHzeFwaHv27FHHWrx4sV/l6gqnnHKKehYqw4cffqj+h++++67CsmeccYY2ceJEj23fffdd17Lly5er9u2vv/5yK/Prr7+6ynz88cdadHS0esbA/v371TZz585Vv++77z5t5MiR6rlJSkrSVq9erZZfdNFF2qBBg7ye37XXXqu20/niiy+0mJgY7eDBg65leN+MGjVK/f3OO+9oGRkZWn5+vms97hm0cytXrnT91usYz22jRo1UW6jvS2+jK7OvP//8U+0rKyurwmtNiBWZmZlaw4YNtdGjR2vHH3+89sEHH2gzZsxQ9zneTSS4oJ91/fXXq3bipptuUv3Kt99+29VvA1deeaV24oknqncZ2kH00UBhYaGqxzfffDOI/0F4g/cU+onPPfec23K8QyIiIrSPPvpI/f7kk09UW/7111+r53Hs2LGusmeffbZ2wQUX1Pq5E0/wLKK/YdWOzpo1S3v++ee1DRs2qGWlpaVqjIhnEvVKqgfdYSoBTJMR1NAIZvW/+uor129YcCDGg+6XD6XVzB9//KFmZ+H7D0sFKOmYuTQCk2lYTehAycWMMmYdMSsIYFUCKxEdmLMh4whmFDGTpwOTfGyL2VYjUBDbtm2rZjt1YFmCcrAggNl+ZcroYLZQB7OoOH9sb7ROMZp6Y78AM7PGZZilRFwLmHrpAR1nz56tZitgVm51vQAsSnQwS4zrU9eCv0KFv/zyy9WMMUzo4XqCWTHMUMNqoDLo7k5GX33MLGPGGeDajR8/XgVC1M38/fHrx/4fe+wxFeMGrkmkcuj+tnh2rerIF3CTQ/3p2Xug6PsKYgnLsFmzZqkyZve1qpQLdrtqjk8Ed0DjrONLL70kKSkp6m/c08ZnXQdWFdgOMyS47rAKM7oS6Zx77rmuv2EVh3YGMVtgmaG3XcZ2G+0q2mk8Y3BbRLsC6wk8w7Cwwzes5VDnJ510kvoNywpY3hndR2Ba/vbbbys3HFiboA2DhYgO3AwxK4d3A6xT8Bxjhg5mrnrcFABXOMzk6bN5aIvxv8NSzwisOuB2hWfZPPPu774I8QXe0X/++ae6n2GBqb939OcF9z2eS/SJdNdQBEG2intEag+4uq1YsUK5entzHYbFB+oPbQYsdtHmzZ8/X8UkQ3uH9snoJkNqD8R+e/nll5UlIZ49tOcAVh+w3MP7A66msOrD+wnvMIxXUKd4FmEJib/hOgmLLStXehJ4YOkLq3mz5QbCG6DfY+yr4R2OvjwsUHW3X1i+YxyGD/qNeAaRJIFUHbrDVAKYG8FH3AhMzjD4z8jIUDem0f0CDZEZdHB1Nw0M/rFt69at1UDACLY1m8JjYG90mzH7euvldfNTnc8++0ytQ0OIjrUO9qUPLHTQmBqPU5kyxvPzdb7A2OnXz9dqmf4/QMTANcYgBYEnzznnHPXQm6+Xt+thvhZ1AYhkEIjgrvDXX3+pxg0vo8qiD1DQkdRBZ1MPngrTVR3dJxRmq/6gm78zcnjlgc8tXJ4w8Ma9CEGvstddD4yK5xPb4L73de3xbEHgwD1k9Sz4W66utasQEXAPQoAwBncGuL7mzhpcRhDfAyIp2ldsC4HW/D+jXowCsVU7hfYIbZyvdlWPCwLBGWKwLspA5MVy1CEGhni2deEbgjX+T7RfOD/8Np4fBo7w2dYD1b333nvqftKFY7jfIKgyOj5wA0JbiA9il1iJ7dgfAsvCjdN8ff3dFyFWQMjABAkmZvQYNHgOMVjWwSALbaEugAAMpDEJYE4TTwIHRGC4tqKtQnsK92Q9XhVADDa4H6N98BU7Da6HmJhCuwaXObjWot1AMHfULV0pagYI0xAW4Y4JV0tMSpmDmOL6A8SSQgw843qs0wV0jCfwXKKu4HYOd3K4QsKNAuXwHKI+Sc2DSWkIw5iAgSuwuT6NGRshNiLbH+4DxHLB+BHvdDyDiPdnrmdSdSiCVAL4iaMzi8GKDgbmePGbA0x6A5YiaKzQsGGmDttaqa9QbI2pCSGaYGAEwcRfMED+4YcfVOcenXXdhxP7wqykMZUiBhvIKqMfpzJldMzpIfESrsr5GoEfMRpwWEwgBgaC/lR3n8HCSpDBYAvWRf7ELsEsNDqeUIfNaTCtykJt9lbWl0iEuCBoeNEAE9/gmUIHBHFBdOBDDcsEc6pbHW91hw4rYjlgBkCPGWEFYgRh5t4qmGhVygWzXYVoYLxOED7RNlpZfFiBwTusMdBhgLWVt4CgiMVjFIJx/0Mg8LdNwcACohc6kxBxdKs2nC8sQdDeIlaJPtODewODiVdffVXNvuL8zKIxwMw4LEhgJYht0N7pIgwsVjC4xLbmj9Eiz8iFF16oLFwwQDVSmX0ZhSAS3mBAhv6HOWsYApmjrUJ/BWIgZjLRruF9o4NOOgbcxnccBmLoj+A5IYEH72489xB3EQgaVhywNjVaMmNiCVQkekIgQXusix14j0CsheUa2j68+/ydZCG+wbsQcabQfqMfjfEF6gHvB6OFKOJlYdILZSFmQSzR4+XhfQPrQ2wPjIIV/obIjnsDzyAmcSlm1Q4Yz6DdxDsY8XdgZaWDOsPYSs/uiD4AnjVYiGIsBMtsCM/ox+jPHOoS5bEdqToUQSoBBuFoiKC6mt0sKmv2jtk5Yyd88eLFbsqfsdNhHLRMnz5dderxkFQFKI84DmZj0FHH+WMGEGZZ6JjrQBlGWT0oXmXK6KAR1gN+wkoBD61V5gJ/wPWCGKTPBGMGE9eiPoKGCh0OuPZAWIA5HEwZIYz5a06KDg3MizG7g+CJMGfFNUejiRloPZOFsSw6LUjhiY4qAsfi/vJlQgdTV8z44PyIuA2cIYTimqJO8VLC7D46mJh500EwYVh7wRoAdY6BAAJqYjYOgwB0ULwBc0jsU8/QYQXaAwQThbuLLypbLljA9QMdObSrEFwD0a5iVgvtjxVG1xCktsXgzOh6WBlgnQFxAmmmjUINRA8MFDHDowdg1c8Pbk662wnq3pzCFqDOIajAcmf58uVu7gLIJqO7ERj5+OOP1eySrwER2gCjC2Fl9oU2Hpit+Uh4gcERhDEMuBAk0xj8F/c+3hO4vxGwG88W+gtGiwO0f3gm4Mqro2cj4sCr6qCtRL8MwiXqQAftCixLkYUPfTL0GSHywqrOGBRfnwAxWtl5A/swzjaj34LBF0zz0Q5yJrpqoL+MILQQ7/EuQp8Q4B0A1wdMlKIO8Y1+LywIzZMZurgBVxi4PuJ+QH2gb4j+vl43qEP0W/RjYF/o22Egjncg67D2QJ1BBIabLyZv0IfXJ9WRHVCvC0xUoO8Id2HcI/hG24o+vu6+i2caE93e+jukklQzpkjYgMCFgwcP1uLj41VwzvPPP18F9UNgqTFjxqhgNQABpqwCKP70008qMCYCTZ166qlaWlqadtRRR2nDhg1zlcG22B8CiZ1wwgkqcCoCUM2ZM8etjHn/CJKEqkRQRmNgVGNAVARMwn47dOigls+ePVv9L/3791fLGzRo4BaQFVRURg9MikCEbdq0Ud8oM2LECI8yCESoowdqNAYGRdAfLNuyZYv6jf+lR48ear9nnXWW1qRJE3UeCPrka98AZeta8DWcI4ITHn300VqXLl3UvYS6dDqdHmXvuece7cwzz/S6LwRLQlBG3Ett27ZVgXsRjBGBlRCk0ci+fftUkEjcs+3atdP69eun3X333aoOdHAsY3BZgCCPCKB4xBFHBOT/r+8gABmeO3xatWql7s0LL7xQBSVG8FEzWPbKK6+oekawUtQ7Ap4i4LBxn6g7Mz///LO69r///rurHNoCIwjWibrE+SxbtsyvcnWJ3bt3q+c7Li5OO/bYY1W7ivNFoGcEfkMQPj1wKQK7WT1XWI5tTzvtNC01NVUF+jUGssa2ejuG633yySeroGKoH2MZ8/7RPqF9MT4rAPvA8tdee81tOeoayxHAWQeBiRH8FvcL2kcEGsZzi6DVZu6//361fc+ePS2D7+L9gXfG0KFD1XOPZx6BqM2BUY3g2TYHr65oXwD3EQLrXnzxxdq0adM8zoeEDgUFBeo5w/2H+xT3BYLrov4RGB3vlFtuuUW9i/UgwQDB2vGstWzZUgXTRuBT3Gt6EHeA5/Lee+91Ox6Cb6IfQioH+jkIxnzzzTerOkEbMnz4cPUcb9++3VUO/QoEOK8IvF9QTwjEXREIvGjuS6Kvi3YDyxHQkfgH2lP08/EeuuOOO1R/D2MInXPPPVcbOHCg2zZILICg+kbQ30Ogbj2ANZ4zvOeQeAD1gzZdry+09wiuaeSll15Sddi8eXPLPgypOnhu0EdHXSOgus78+fPVNd+6davqJxgDDl9yySWqjwAmTZqkxojGAOY6xgDqaLPT09O1Sy+9VLUNn376KavNTyiC+AleOl9++aU2b948bcWKFR43KTq9WOdt8AoRATc9bmR04NFgmQUONEgYACGyszmzi9X+8ZAh84HekO3du1f91gcQOsgc8t5772m//PKL63yQ8QX/D1603s7ZWxmjCLFx40ZVTh+46aABxrnomQoAOttYZnyYIXpgmfF6YtsffvhBXQf83xiMvP/++z73DdAQoJEhhNQPdu3apTrleHYx8NIFXR20g1bR0/X2D+0TtkdbgOwmxuxQusCBwRzabLQn5vbBav9on9C+GMUBACELyw8cOOC2HPvGcrNokpubq9p8tP1om9etW2cZ1V1vt40Zuczt94IFC1Q2GYjyRtDWY1tzRheUw3KzAOZrXwDvkkWLFqn21viOIqEF7ks8G8gScd1116nsS5dffrl6rxszhaAfgAkI43MFURHimt7vwLOH7V588UVXGV2AN4IJjylTpngI9sQTZGbBxBIE1qeeekrr1q2b2ySTDtooXHsII2ZWrVqlBPKrr75a/cZ1b9asmc/MIPrEEgQyTMShb2o+nrlvSioGg1v08c39ZCMvvPCCuuZ6/x2ZfCBKn3POOR79bzyT6NPrQEzB84wJl5SUFJdoiYkGiCtm0G+3mogjlQfPQnFxsasNxCQJJjogbiEDHiYf9WdFz6ykT8BAxIBwhUlyZIHB36hv9E+QidOcZRBiqC5uAdQv3tGYsP77779Zl1WAIkgdwpsVSV3FmyUGIYTUFbxZkRASLkBge/bZZ5UosXTpUtdyiGaYTUZn3diphyWW2QIIYobRqgMWTkh9qwMBE/2B8847z7UMQhqsQTjT7Btcc1gVQoDCgHXcuHHKKhaDKn2ABe666y4lYJjBhBAGxE888YTbcljotG7dWhsyZIiqGwx6AazY8Pvll1/2mOS77LLLtFtvvdW1DJajEJmJb/AsYaYfVsgQvI2TfPq1xcAWk4W+gEiOuoF1Lix7YGmFup0+fbrHIBfPJFKu62D9VVddpZ5pY98cA+WHH36YVRhgIG7AQke3foMlqzENMepjwIAByopLB1Y+sPowPqNJSUlqEgZ19uOPP6rleDZxvyCVMcqgrmF5B+tZEjgogtQhKIIQQkhgoQhCwhEMnmEmDaECJtNwocDsPzrWGHDr9OrVy8OtAZ10uEIZgQBitOrAerj5waoJpvYQRWBtAHcaUnlgOQYXuD59+qh6wSDonXfesXRf1F2dja7EOtiH2Y1Wt2KDRQdM7+HmpDN58mR1L0CkGjVqlHInxH2CwTKtAyoHrA4x6IV7I4RDDHhhcQGX2YSEBLfnDLP1qDujqOUNbI8PhBBY7MFKAC40GGTrrvf6MwmRywgsfWChhTqFFRAJDKg/swsZRCbUKQQvWHBArIJFBpY/9thj6n6AK5LRCgdWcHDb1Z8xfKOu4OYGt2AI1UbrUjyPsBiBwMbnMvBQBKlD+HKlqYt4c0chhJC6gi9XGkJCAbyLMdA1g3hE6JjDBUwHgkVUVJRrRhEz/RiAG4G5trGjDjA4t9vtrvc9Zr5huYC4URBY4CZmjBlCKgdm8iFEVMY9CKIGZp7NZvIAgy6IGsaYLEYQg8LsAoO2EVYhcJeB9YLZhZr4BvH69Pgqxnsff0McwfOiuxPCzQVlze6VVkDEMMeGwuAaQghiken3CtzTsE+4mBH/gfvYgw8+6LEc9WduyyBeQOgyuoFBAEZbCtD+oi4geiC2Eqw3YEFlDpkAVyiUg8BhPB5EZSw3xjMjNQ9FEEIIIYSQegZ8x2HtgY44TOAx82iMd3PjjTeqgbHRHQUDKARehiUAQBwQlEGcGON+0SE3xqfBTCdmtz/66KNa+//CAcT7QCDLyjJo0CDLwOkYbCHg7JFHHukWMBWiFYIwwsqDdRdYEHgez50xLodxYNu1a1dVvwBCCZ4pPSafLzC5iP1i/0YgNGKQjfg9uviJ/TG+TsX8888/yi3QCERB3X0P1huwrEFMD7SHEBshZOlCMOoTyTDw/OnL4PqEwLI6nTp1cnN90UH7qwdIxX5g9fHkk0+6lUFdMq5H7cMUuYQQQgghQQQpS99//31ZtWpVpcojJe2QIUMkISFBpWHeunWrSr2J9OdIfamnZCwrK1OpanUiIiJUquelS5eq33379lWp1X/88UdXGaRW7dixo1v6TKS3/eGHH3ymVyf+g3T033zzjYwfP15efPFFlW54ypQpKuUtUmWaQZ0uWrRIpVI1gvvg+++/VymukXocabuRThMpcpGmfcmSJTJs2DBWUQBJT0+XI444wjLNLFIIX3XVVapOUI945pC6ds6cOV73t3fvXldaW2BOJX3cccfJypUr5dlnn1W/8dwiJS6eaeIdpKLt2bOn3HbbbW7LkeLb6XSqv3/77TeVyh7PH1LRoj1Ee4y093p94plEu4n0xXoaedwDOrfccou88cYb8ueff7qWZWdny8UXXyyLFy927eemm25S6W2NoC5xjjabjVVZmwRBeCGEEEIIIQa3CMTnMM7qYvYSqaIx+4h0msjSYpwtRqYBY4wBmGpjFlNPEa9nkDCnToSFiNHsGvEgkAbXyJtvvqmsREjNgplhpEBFvBWY0eMbH8xEI+2l0UIHwDoHXXcEnfXlWo26Q1wRurjULAheao7LoYOU5airX3/9Vf1G2lTEZkE2ECOwskL8Dz2DD0CAXHMadlIxyE5lDBYL0P7hWULGHGNgaAQBtrLCwjOJZwftLtz9jMycOVPF7lizZo2K34G202jNARdBZHkZNmyYiuECt0K40pC6CS1BCCGEEEJqicsuu0weeeQR12/MEs6ePVteeeUV16wuZi9POukkadmypVxxxRVqRvmEE05QM4tgz549atY/JibGtZ+MjAw14zx//nz1Ozk5WVkEPProo1JSUqKWwVoEM9ennHKKa7vzzjvPYwYSxzz11FNr+EoQzAxPnDhRzUT/+++/6huf33//XTZt2iRvv/2220U6+uijJTU11dL6QKdt27aq7o499liJi4vjRa5BYJmzbds22bBhg8e6pKQk9Q1rLHD//ffLueeeq7a55pprZNq0aXL77bdLmzZtlAWYbmEAPv/8c7n66qtZdyaeeeYZN0sLM507d5ZZs2a52kCANrVBgwYyYcIEGTdunNvy8vJy129Y0D344IPSrFkzGTFihLLywDO5a9cuV5nrrrtOPVtoH2G5Y7TogDUHntcvvvhCtd3nnHOOrF69Wu655x7WYx2FIgghhBBCSABAJ3jt2rU+y6DzjY4ygDhx/fXXy9ixY6VXr16uQROWTZ48We69914ZOXKkMo2HW8sTTzyhymDgBCEkJyfHbd9whdDN6gEGXMuXL5fevXvLmDFjlIsEhBXjYODmm2+W559/nvVfh+jatasauMHVyQjEqvPPP9/DHYYEh/79+ysh0uy6ApYtW6bW9ejRw+V+8cEHHyiBAwLlmjVrpGnTpvLTTz/Ju+++K/Hx8UH4D+oXuH4ffvih1/UQKOBidscdd7gEDrS3eF7Q/kHQ0LdHfRhFEAhS8+bNk6+//lrWrVsnM2fOVMvNdQuRBe6HcG0yu7WAAQMGqH3BHcpqPak7UAQhhBBCCAnQTOV7773nswyECcz0Q8CAlQasMx5++GHXenTU4W/ep08f17KUlBQZNWqU8lPXO/uxsbHy2muvue0bAgyEEOOxMAjAcbp06aJ83OHvjhgfpO7Gh7nrrrvUwA3xBMxgcPb4448H5dyIO7C0wXNqHij/9ddfysIHHwgeRvBMPv3006oe7777bhW3hVQOXDvdCiovL09effVV9SwYhWfE9cBvvW1E+wdhGYIU2kFYZkB8NsYEARCm8dzpYvT27dvVt7lumzRpIq+//rqK7QGLHlJ/oQhCCCGEEBKgTrreaUbH+8svv1SuLrDa0EHgQ3S+EQgTlh0zZsxwmwXWzeiNFh0A7g0wvYdFCMqgMz9p0iQVjA8zlzC3h/n2nXfe6doGgRMhlmBAjcB9AwcOZD3XMXbs2KEsPzCDjQF18+bNlck/3KSwnNT9Zx5BgyFczp07V7m7QaSE+wWeSRLYa71ixQpZsGCBcn2BIIK/0TbqQaUh9t5www1y3333SX5+vssSBKBuGjVqpCzfzO4wCAYNN0S4xWzZskVZ4yHwrZWVD55VuDNhPam/2BAYJNgnQQghhBBS30Gn/Mwzz5RffvlFLrnkEmnVqpUcOHBA9u3bp2I9IF4DgIk8/MXhRw4hBAIF/tZB5xoxQBAnRAcm84gTgMww6MCj+zZ16lR5+eWXlfUAzLAhipgHzkOHDlUuMBBbSN0Ebi+YvUa9YjBGM/r6A6y6MAhHhp5+/fqpuB/Dhw/3sAAh/oE2DQJhu3btXG0jxAyIGIilgzYW7SjaQbR9KKdbf0CQgoUNXGAgOsM1BcIGgCXcBRdcoDJdweoO7kgA4srZZ5+tssNAZL711ltVu4zf3bp1Y/WFIsGOzEoIIYQQUh85ePCg2++ioiKV2SM5OdmVBcLhcGi9e/fWbr75Zle5sWPHahkZGdrUqVO1hg0bquwF8+bNc62fNWuWFhUVpS1YsED9RpaQ448/Xrvmmmv8Psdp06ZpxxxzTDX+S0KIN5BNZOHChSrLC6kev/32m7ZhwwaVSSchIUFlwGrSpIm6vjpnnnmmaguNzJ49W2vcuLGqC50nn3xSZXJBJq0WLVq4lcc+kEnrpJNO8qjLXbt2uWXpIqEL3WEIIYQQQioBAuJh9hCuLAhqiNlezBIiBgCA6wkyA2A2H7PCAL7nyCoAU3mjWffOnTvVjOTGjRvVb/wNM/q///5b+ZojYClmK7t3764sSDCzWZUApvBdR5BGQkjggVUCsjIxzk71QUBTWK7BvQjt4qFDh5QLCzKtwBUQwLIDlnVG0H7C4s6YOQaWHGijH3roIVeGHp0nn3xSOnXqpNxazHUJdzQ9SxcJbSiCEEIIIYRUAri4IKUs3FTgi464HUhDC7FCj+GBDrk5a8vgwYPVesTsABg0wcQb7jNIeQpR5Z9//lEDKQTmg2iCzDAQXV566SVZuXKlyjxTlZSnCAiIzj0hhAQTtIsfffSRyoaFQM/I9mIEbef69euVcAEBA20Xgsgiba2eQhhl0C7iowPhAm6AxtTR2FYPmmrOpgRhGTFEmL42vOFbkRBCCCFERAXFW7hwoTzyyCMqUwBmJK0yu0yZMkV69uypMgXADx0WIMi8os9UYhYTFiM6SGmLrC16kL3ExEQVtNTYaYdFCdIz4pg33nijEkcQywOCCQYBhBBSn4AFxqJFi9TfCDqKdhAZXRo3biwZGRlK7H3uuefc2ldgjKeCtNAQnz/77DP1G3FAIJAY2059W/MyZFdCSlv9HAgxQhGEEEIIIWELMqsAZHJBQEpkV0GgSmQWgGk20iGaO+nohOvAouPSSy9VM5zg6KOPVp18c1YBcycd1iEHDx70OB+IHrAuQeefEELqEshOhVTdyJ6CINAXXnihm0XH7t27lfCAwKUQfpEGGBYgQ4YMUe0qhF5kzUFa8GeffVZZfUB8BggwCwFEdy/UQdBTowsMhGaz4HH66acriw9zvg+4FkI4IcQMs8MQQgghJGRA+lmkF/3+++9V3A2kn0XGBl3AQIcc69FBHzlypGRnZ6vMAIjdgZlLZB/QmTlzpuqww6pDT7WI9ci0MmLECFc5pMGFiTd82PUZSJhjv/XWW64yH3/8sUrbCDNsChyEkPoGhAdkqEJ8oosuusiVNQVi7vnnn6/+RhYsZLaCpcecOXOkf//+bvtAGwlhed68ecqqDllg4FqIeEgAsZGQUhwiit5OwjUQ4gnaaYC04OPGjVOiCl39SFWJrPKWhBBCCCF1iD/++EOlQywvL5dhw4a5hA+kWtRBfA3E9WjdurUKkIfOvJGSkhL58MMPVScd6RQRcO/nn39WnXlYfcBSA6KHUQTB/uG6ooPjQjwxgs49zokQQupj24oUsnD7gxDijd69eytrDsQ2MgsgmzdvVgGj4Uo4evRoZb2huwXqIgisPJCaFjFDENQZcZTgMgNBRQeCyymnnEIBhFQLiiCEEEIIqfcgAB46xhMnTlQB77xZW2CWMiEhQc1imgUQmHIPGDBAdeLRCYe5NtxW0EnXO/QQOG6//XZl5o3OOjrp06ZNU2WNnXR09GGarZ8HrT8IIfUVxEmCQOFLAAGwmBs4cKDLxcUIsltBHPn8889dyxAM2ug6qAvXBQUFMmbMGOV6iHgi+nKQlJSkPoRUB4oghBBCCKn3wLccPuUTJkzwWU635oD7ixkIGa1atVLm2bpoAcEEnXQMAoDeGcfMKGY10UmfPn26m2UIgpriQwghocDSpUtVwGYzcGeBeyECSk+ePFmJIGgjEfPDylURVno6cJ1Zs2aNsr6DxR1iKSG9OKz00Laas8cQEkgYGJUQQggh9R5kAEDH2QysMf7++29lUq0HzUMn3Rx8T+/QQ/TQBRC4zsDSY/ny5cWclGYAAAMTSURBVK54H0iviJSMiPuxbds21ZE3CiCEEBKK5OXluf1++eWXVYYsWN8hHS1ifABYjCCuB+IfGbniiitU9iu4Iw4fPlwFVn3nnXeU1R0sP3xleiEk0FAEIYQQQki9B0FNkbnACAKTwlIDWV5uu+02JYbonWwIGBs2bHArj1SMyBJz7bXXKp91uMa89NJLyhceAVR10MmHtQghhIQDEH8RrNQIMsNA7IBIjIDRulsLAqciHa5ZyEC8EIjG+IbLDKxAIIi88sorygJPB+0zLEsQiJqQmoIiCCGEEELqPZ07d5Zly5a5LUOMECzbunWrNGjQwNUpRzyQFi1aeKSxhSUJliUmJiprD5iAwwcePult27Z166RjRhPm3YQQEuogsDMEDFjG6cB9BW0lMrRAMDaKHmgjze0rOPLII2XSpEkqxW56errlsbDtiy++yPaV1ChMkUsIIYSQes/UqVNVQNR169ap9LdmYM2BYH3z589Xv5FFJjc3V+bOnev3sTIzM1WaRliLxMXFBeT8CSGkrlJcXKwEDKQchxVcWlqaWg4XQ6Qjh6hx6aWXyqOPPqqWw90QKWyNAU0JqUtQBCGEEEJISPird+3aVQkgX331lYrtoYPZS3TQTzrpJOXHDpYsWSI7d+5UvumEEEJ8gxS3yKi1ceNGZWWHIKiIAwJXFgSkhtshIfUFiiCEEEIICQkQ8wM+5ogPgqB76KTrQU2R0vaaa64J9ikSQki9BZYfcBNEPA9YwSG+BzK6EFLfoAhCCCGEkJAB6RZhCWLspPft29eV8YUQQggh4Q1FEEIIIYQQQgghhIQFzA5DCCGEEEIIIYSQsIAiCCGEEEIIIYQQQsICiiCEEEIIIYQQQggJCyiCEEIIIYQQQgghJCygCEIIIYQQQgghhJCwgCIIIYQQQgghhBBCwgKKIIQQQgghhBBCCAkLKIIQQgghhBBCCCEkLKAIQgghhBBCCCGEkLCAIgghhBBCCCGEEELCAooghBBCCCGEEEIICQsoghBCCCGEEEIIISQsoAhCCCGEEEIIIYQQCQf+D0xl+diF5TkKAAAAAElFTkSuQmCC",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "names = [row[\"model\"] for row in rows]\n",
+ "aggregates = [row[\"aggregate\"] for row in rows]\n",
+ "colors = [\"#1f77b4\", \"#ff7f0e\", \"#2ca02c\", \"#d62728\"]\n",
+ "hatches = [\"\", \"..\", \"//\", \"xx\"]\n",
+ "x = np.arange(len(names))\n",
+ "width = 0.8 / len(HORIZONS)\n",
+ "\n",
+ "fig, axes = plt.subplots(1, 2, figsize=(11, 3.8))\n",
+ "\n",
+ "bars = axes[0].bar(\n",
+ " names, aggregates, color=colors[: len(names)], edgecolor=\"black\", linewidth=0.6\n",
+ ")\n",
+ "axes[0].set_ylabel(\"aggregate RMSE\")\n",
+ "axes[0].set_title(\"Aggregate test RMSE\")\n",
+ "axes[0].grid(True, axis=\"y\", alpha=0.3)\n",
+ "for bar, value in zip(bars, aggregates, strict=True):\n",
+ " axes[0].text(\n",
+ " bar.get_x() + bar.get_width() / 2,\n",
+ " bar.get_height(),\n",
+ " f\"{value:.3f}\",\n",
+ " ha=\"center\",\n",
+ " va=\"bottom\",\n",
+ " fontsize=9,\n",
+ " )\n",
+ "\n",
+ "for i, h in enumerate(HORIZONS):\n",
+ " values = [row[f\"h{h}\"] for row in rows]\n",
+ " offset = (i - (len(HORIZONS) - 1) / 2) * width\n",
+ " axes[1].bar(\n",
+ " x + offset,\n",
+ " values,\n",
+ " width=width,\n",
+ " color=colors[: len(names)],\n",
+ " hatch=hatches[i % len(hatches)],\n",
+ " edgecolor=\"black\",\n",
+ " linewidth=0.5,\n",
+ " )\n",
+ "# Neutral proxy patches: hatch encodes horizon; bar fill still encodes model.\n",
+ "horizon_handles = [\n",
+ " Patch(\n",
+ " facecolor=\"white\",\n",
+ " edgecolor=\"black\",\n",
+ " hatch=hatches[i % len(hatches)],\n",
+ " label=f\"h={h}\",\n",
+ " )\n",
+ " for i, h in enumerate(HORIZONS)\n",
+ "]\n",
+ "axes[1].set_xticks(x)\n",
+ "axes[1].set_xticklabels(names, rotation=15, ha=\"right\")\n",
+ "axes[1].set_ylabel(\"RMSE\")\n",
+ "axes[1].set_title(\"Per-horizon test RMSE (color=model, hatch=horizon)\")\n",
+ "axes[1].legend(handles=horizon_handles, fontsize=8, title=\"horizon\")\n",
+ "axes[1].grid(True, axis=\"y\", alpha=0.3)\n",
+ "\n",
+ "# Call out best model at the shortest and longest reported horizons.\n",
+ "for h, ypos in ((HORIZONS[0], 0.92), (HORIZONS[-1], 0.84)):\n",
+ " best = min(rows, key=lambda r: r[f\"h{h}\"])\n",
+ " axes[1].text(\n",
+ " 0.02,\n",
+ " ypos,\n",
+ " f\"best h={h}: {best['model']} ({best[f'h{h}']:.3f})\",\n",
+ " transform=axes[1].transAxes,\n",
+ " fontsize=8,\n",
+ " va=\"top\",\n",
+ " )\n",
+ "\n",
+ "fig.suptitle(\"METR-LA test RMSE by model\", y=1.02)\n",
+ "plt.tight_layout()\n",
+ "plt.show()\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "22-13",
+ "metadata": {},
+ "source": [
+ "## Interpret the results\n",
+ "\n",
+ "Read the table and plots together with the protocol caveats:\n",
+ "\n",
+ "- **Saved ranking (this run).** Aggregate test RMSE (z-scored speed, lower is\n",
+ " better): GraphKoopman **0.6551**, STGCN **0.7076**, Graph WaveNet **0.9036**,\n",
+ " DCRNN **1.0754**. GraphKoopman leads on aggregate RMSE.\n",
+ "- **Short vs long horizon.** On this run STGCN is strongest at the shortest\n",
+ " horizon (h3 RMSE 0.5726 vs GraphKoopman 0.6226), while GraphKoopman wins at\n",
+ " the longest horizon and on aggregate. That pattern fits a rollout-trained\n",
+ " linear latent model: competitive when error compounds over many steps, not\n",
+ " automatically best at one-step skill.\n",
+ "- **Unequal budgets.** GraphKoopman trains with ODO + rollout ramp + validation\n",
+ " early stopping (`EPOCHS`); GNN refs use a shorter interactive budget\n",
+ " (`GNN_EPOCHS`) and a shared teaching width. Parameter counts are printed\n",
+ " above — capacity is **not** equalized, so treat the ranking as a teaching\n",
+ " comparison, not a capacity-matched bake-off.\n",
+ "- **When to prefer GraphKoopman.** Choose it when you want multi-horizon\n",
+ " forecast skill **plus** an inspectable linear latent operator/spectrum.\n",
+ " Prefer a pure GNN baseline when you only need short-horizon point forecasts\n",
+ " and do not care about a linear \\(K\\).\n"
+ ]
+ },
{
- "data": {
- "image/png": "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vo/mSnDw4MGa5wbEUbi54bmB+8t5552nrNJs/cAfeugh+fLLL2XgwIHqPuWKkAKLx759+6p7a58+fZQVmy0FBQVyyy23SFJSkno+4D6FET6M5uu5G8LVB/fioKAg9V3PT/3ss8926E5hOYrpbtuu7I8lf/zxR43IjGeetg14LmtUVVUpk3ut3ujoaCUk4PlsCcSJ/v37q2cQRAO8O/z+++8ut+MqeF4nJycraw09wsPD1fbZPoPgWol3CTyf3HkGHXfccTXvH54C5wXXNJ7ntsDlE1YuOJbYFzzP4Zas/SZcvS4cUd/rua5Y1vnee++p3zqsckaPHl3zPgMXJVib4jobPHiwrmucq/sOdxEcN1yvuG9MmjRJcnNzdbfN1WvcE7jyG8VvSPuN4Ljjep89e7bad1d/T65cR+5sU0Psty/ea08//XT5+eefda2jPYqZEFJvfvjhBzN+Tj/++KP6+9tvv1V///7773Zl7777bnNgYKD5mWeeMR89etT877//ms877zzz6NGjza1bt65z2ZNOOsk8aNAg89lnn23euHGjOTMz0/zWW2+Zq6qqzOecc445NjbW/M4775hzc3PNu3fvNo8ZM8bctm1bc0ZGhlq/uLjY3KtXL3OXLl3MP/30kzkvL8/82WefmadMmWIOCQkx/+9//3O4//n5+eYvv/xS1Tdt2rSa+QcPHlTHYcmSJXbruLpdenii3qVLl5qDgoJUGWx/dna22t/LLrvMpXb0qKioUOUvvfRSq/klJSXmVq1amXv27Klb/qqrrqq5ZrZu3aqWmUwmc6dOncw333yzedWqVWoZPjW++uorNW/ixInqXNVluwghzYf3339f/Y7ffvttq/m492L+bbfdpv6+4oorzJGRkebly5er+1pqaqq6r+EetGfPHlWmoKBArTNu3DjzpEmT1Pxdu3aZV69erdu2di+86KKLzNddd52q88iRI+rv4OBgtVyjvLxcPY/i4uLMX3zxhbo/ff/99+akpCT1jEHblnWef/756h6YkpJi3rJli3qeatu3YMECp8dk+vTpqtybb75Zp7Zd2R89srKy1PoLFy60W4Z79wUXXGCOjo42r1y5Uj2D9u7dq57d2K5Dhw6pcq+99po5ICBAbTu2E894nMtLLrnEpXYcgTpxTC3BcWnTpo05OTlZt/zkyZNr3mP++OOPmmV4Zs2cObPmeWN57X333Xdq3oQJE9Q+1mW7XAXbj/cQnCu9441j27JlS/NLL72kziOmN954w3zXXXe5dV3oXXeeuJ4B3q2wvLYJbWlodY4fP948a9Ysc3p6unn//v3qXW/AgAHqneD6669X1xSu4cGDB5u7du1qrqysdHv7y8rKzMcff7y5Q4cO6tyi3Oeff26++OKL1b3j2muvdfsad/V3rEd9fqNo95tvvlH7YvneU9vvtrbryN1tqs859+d77Vf/3W8+/vhjszehCEKIB5g6daq5X79+VjfT7t27m2fMmGFVDi+keJBfc801VvPx8hMREWElbLhTFuBmhJvG33//bTX/3XffVfPxAm3JsWPHzFFRUebbb79d/f3CCy+ocuvXr7cqhxdpzHcmgmg8++yzZqPRqASV2kQEV7dLD0/UC/EAN2pn1FcEwXVw4MABJSRh/iuvvKJbHg8liDd4YM+dO1ctW7dunVr2zz//6Iog4N5771UPEVwnI0eOVILJp59+qkQXvXYcTa6cW0JI0xJB8BK6YcMG1VHFM2Hnzp3q/q13z0IHBy+nmkitvfgmJiaqempDuxf2799f3dc0Dh8+bDYYDOaHH364Zh46DSj73nvvWdWhdZq1DohWJ56VlnVabp+zF/MHHnhAlZk9e3ad23Zlf/Rw1pn65JNPrDoLlvsEcV6730KsgtBd13ZcFRvS0tLMV155parn+eefdyiCaJ22G2+8UX3/5Zdf1Dq//vqrrggCHnroIXNoaKh6Do0YMUJ11NFx0d4BbNtx9Ayy7GDrgX1AOZxzWz744APd56slrl4XetedJ67n+naIIU5Yguc85p988slWbaEzivlr1651e/tff/119TfOtSUQ62zPkavXuCdEkLr+Ri3fX/H+V9vvyZXryN1tqs859+d77d9//63Wefrpp83ehO4whNQTBAeDSdn1119fMw+mX0gnhhgPmnsIgCtLWVmZXRR2mHeecsopVvPcKasBkzSY8VkCczOYqcEc2hKYOiKw5vr169XfiD2BuBLwxbPEdj2Nb7/9VvkRt27dWsWz0FxF4LO7b98+3XXqsl3u4mq9OE47duxQbj5//vmnMu30FO+++646HjgucIOB7zTMzBEc1REoO3XqVOWTjW2B/zNM1GFW7IgHHnhA0tPTlQkzAtRhP2C2DDcgmH66GhgVgVUJIc0DmKjj/gL3FTwf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9VBPr5BQ3TYaQwB5JjtbYiIjpNnFBKmoqFD+t+Hh4ZKcnOzZrSKEEEIIsSAnJ6cmBshff/1l9bc2z13Wr18vH374oZSXl8uZZ54pEyZMqPXd55133pHff/9dKisrpU+fPnL55ZdLixYteK4IqScUQOoPLC1gpREcH6y7POb0GInoEyEBEQESGGnfDQyJC5Hui7pLVVGVhMSH1KkNDQog9adjcLCs6dJF8qqqJClY/3i/1KGjpJaXO1w+MSpaTg6PkFYBARIVEKDbRpDOfG8LILNiY2VnRETzcof55JNPpF27durh/+yzz6p5//zzj4waNcrT20cIIYQQokSOM844Q0233Xab1d/aPHd46aWXlPARGRkpSUlJcvXVV8ucOXOcrnPOOefIvHnz1OBP3759VR1DhgyRokb0aybEF6AA4hmMgUblMoJPR0Dc0BNANLDMkQDiahsUQDwHhAtHAgcINhikW0iI+nQE1tcTQDQMjSCAzGwdK42J25Yghw4dkiuuuEKWLFkie/fulYKCan+y/v37S2BgoBqVGT16tEt1HT16VO68805Zu3athIaGyqWXXip33XWXqscZq1atUuLL/v375fjjj1cR4rt16+burhBCCCGkGTB8+HA5ePBgreXwLuEKJSUlcuutt8pDDz2kPkHPnj3loosukuuvv166dOlitw5S8K5evVq++eYbJZ5ooggEkZ9//rlmHiHEPSiA+BYUQHyPzT4mgNRJBPn+++/Vg37KlCmyePHiGhEEnHjiifLjjz+6JIKYzWYZN26c+v7uu+9Kbm6uTJ06VX0++eSTDtd75plnlHDy1FNPqZGff//9Vx544AF588033d0VQgghhDQDQkJCpH379h6rD+8qeXl5cskll9TMO/fcc5WL75dffik33XST3ToxMTHSsmVLSUtLq5kHYcZoNErHjh09tm2E+BNH84vl3g1l8uDIELnnVMfWB44oKDPL2SuLZWtmlayeGiGD2/lHwMymCgUQ32OzDwogdRJBjh07Jq1atVLfDTZmN/n5+RIb69qOwfpjw4YNSsTo0aOHmvfII4/IzJkzlalpdHS03ToZGRnK3HXRokVyzTXXqHkYgTnrrLPc3Q1CCCGENCMweIIJooPG5s2bZcWKFcoCZNq0aboWHHrs27dP1dOhQ4eaeUFBQdK2bVu1TA/E/YBAcsMNN6jBGwgz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",
- "text/plain": [
- ""
+ "cell_type": "markdown",
+ "id": "22-14",
+ "metadata": {},
+ "source": [
+ "## Takeaways\n",
+ "\n",
+ "- **Goal revisited:** a rollout-trained `GraphKoopmanModel` can compete with\n",
+ " STGCN / DCRNN / Graph WaveNet teaching baselines under shared METR-LA\n",
+ " evaluation — the missing nonlinear-GNN side of KoopmanGraph's motivation.\n",
+ "- **This run:** GraphKoopman leads on aggregate RMSE (0.6551 vs 0.7076 /\n",
+ " 0.9036 / 1.0754 for STGCN / Graph WaveNet / DCRNN) and at the longest\n",
+ " horizon; STGCN wins the shortest horizon. Prefer GraphKoopman when you\n",
+ " want competitive multi-horizon forecasts **plus** an inspectable linear\n",
+ " latent operator/spectrum. Unequal budgets: do not read this as a\n",
+ " capacity-matched bake-off.\n",
+ "- **Protocol:** same chronological split and horizons; GraphKoopman uses\n",
+ " ODO + rollout ramp + early stopping (`EPOCHS`); GNN refs use a shorter\n",
+ " interactive budget (`GNN_EPOCHS`) and shared teaching width (capacity not\n",
+ " equalized — see parameter counts).\n",
+ "- **API:** `from koopman_graph.baselines.gnn import STGCNBaseline, DCRNNBaseline,\n",
+ " GraphWaveNetBaseline`.\n",
+ "- **Honesty:** GNN numbers are teaching baselines, not published SOTA\n",
+ " reproductions; spectral tools remain Koopman/DMD-family.\n",
+ "- **Next steps:** compare against notebook 03's DMD/EDMD rows; try notebook 24's\n",
+ " chaotic benchmarks if you want a nonlinearity stress test instead of traffic.\n",
+ "\n",
+ "## Next steps\n",
+ "\n",
+ "- See [`03_traffic_network.ipynb`](03_traffic_network.ipynb) for DMD/EDMD rows on METR-LA.\n",
+ "- See [`24_nonlinear_chaotic_benchmarks.ipynb`](24_nonlinear_chaotic_benchmarks.ipynb) for nonlinearity stress tests.\n",
+ "- See [`07_koopman_spectrum.ipynb`](07_koopman_spectrum.ipynb) to inspect the learned operator spectrum.\n",
+ "- Explore the [API reference](https://koopmangraph.readthedocs.io/en/latest/api.html).\n"
]
- },
- "metadata": {},
- "output_type": "display_data"
}
- ],
- "source": [
- "names = [row[\"model\"] for row in rows]\n",
- "aggregates = [row[\"aggregate\"] for row in rows]\n",
- "colors = [\"#1f77b4\", \"#ff7f0e\", \"#2ca02c\", \"#d62728\"]\n",
- "hatches = [\"\", \"..\", \"//\", \"xx\"]\n",
- "x = np.arange(len(names))\n",
- "width = 0.8 / len(HORIZONS)\n",
- "\n",
- "fig, axes = plt.subplots(1, 2, figsize=(11, 3.8))\n",
- "\n",
- "bars = axes[0].bar(\n",
- " names, aggregates, color=colors[: len(names)], edgecolor=\"black\", linewidth=0.6\n",
- ")\n",
- "axes[0].set_ylabel(\"aggregate RMSE\")\n",
- "axes[0].set_title(\"Aggregate test RMSE\")\n",
- "axes[0].grid(True, axis=\"y\", alpha=0.3)\n",
- "for bar, value in zip(bars, aggregates, strict=True):\n",
- " axes[0].text(\n",
- " bar.get_x() + bar.get_width() / 2,\n",
- " bar.get_height(),\n",
- " f\"{value:.3f}\",\n",
- " ha=\"center\",\n",
- " va=\"bottom\",\n",
- " fontsize=9,\n",
- " )\n",
- "\n",
- "for i, h in enumerate(HORIZONS):\n",
- " values = [row[f\"h{h}\"] for row in rows]\n",
- " offset = (i - (len(HORIZONS) - 1) / 2) * width\n",
- " axes[1].bar(\n",
- " x + offset,\n",
- " values,\n",
- " width=width,\n",
- " color=colors[: len(names)],\n",
- " hatch=hatches[i % len(hatches)],\n",
- " edgecolor=\"black\",\n",
- " linewidth=0.5,\n",
- " )\n",
- "# Neutral proxy patches: hatch encodes horizon; bar fill still encodes model.\n",
- "horizon_handles = [\n",
- " Patch(\n",
- " facecolor=\"white\",\n",
- " edgecolor=\"black\",\n",
- " hatch=hatches[i % len(hatches)],\n",
- " label=f\"h={h}\",\n",
- " )\n",
- " for i, h in enumerate(HORIZONS)\n",
- "]\n",
- "axes[1].set_xticks(x)\n",
- "axes[1].set_xticklabels(names, rotation=15, ha=\"right\")\n",
- "axes[1].set_ylabel(\"RMSE\")\n",
- "axes[1].set_title(\"Per-horizon test RMSE (color=model, hatch=horizon)\")\n",
- "axes[1].legend(handles=horizon_handles, fontsize=8, title=\"horizon\")\n",
- "axes[1].grid(True, axis=\"y\", alpha=0.3)\n",
- "\n",
- "# Call out best model at the shortest and longest reported horizons.\n",
- "for h, ypos in ((HORIZONS[0], 0.92), (HORIZONS[-1], 0.84)):\n",
- " best = min(rows, key=lambda r: r[f\"h{h}\"])\n",
- " axes[1].text(\n",
- " 0.02,\n",
- " ypos,\n",
- " f\"best h={h}: {best['model']} ({best[f'h{h}']:.3f})\",\n",
- " transform=axes[1].transAxes,\n",
- " fontsize=8,\n",
- " va=\"top\",\n",
- " )\n",
- "\n",
- "fig.suptitle(\"METR-LA test RMSE by model\", y=1.02)\n",
- "plt.tight_layout()\n",
- "plt.show()\n"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "22-13",
- "metadata": {},
- "source": [
- "## Interpret the results\n",
- "\n",
- "Read the table and plots together with the protocol caveats:\n",
- "\n",
- "- **Short vs long horizon.** On this run STGCN is strongest at the shortest\n",
- " horizon, while GraphKoopman wins at the longest horizon and on aggregate.\n",
- " That pattern fits a rollout-trained linear latent model: competitive when\n",
- " error compounds over many steps, not automatically best at one-step skill.\n",
- "- **Unequal budgets.** GraphKoopman trains with ODO + rollout ramp + validation\n",
- " early stopping (`EPOCHS`); GNN refs use a shorter interactive budget\n",
- " (`GNN_EPOCHS`) and a shared teaching width. Parameter counts are printed\n",
- " above — capacity is **not** equalized, so treat the ranking as a teaching\n",
- " comparison, not a capacity-matched bake-off.\n",
- "- **When to prefer GraphKoopman.** Choose it when you want multi-horizon\n",
- " forecast skill **plus** an inspectable linear latent operator/spectrum.\n",
- " Prefer a pure GNN baseline when you only need short-horizon point forecasts\n",
- " and do not care about a linear \\(K\\).\n"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "22-14",
- "metadata": {},
- "source": [
- "## Takeaways\n",
- "\n",
- "- **Goal revisited:** a rollout-trained `GraphKoopmanModel` can compete with\n",
- " STGCN / DCRNN / Graph WaveNet teaching baselines under shared METR-LA\n",
- " evaluation — the missing nonlinear-GNN side of KoopmanGraph's motivation.\n",
- "- **This run:** GraphKoopman leads on aggregate RMSE and at the longest horizon;\n",
- " STGCN wins the shortest horizon. Prefer GraphKoopman when you want competitive\n",
- " multi-horizon forecasts **plus** an inspectable linear latent operator/spectrum.\n",
- "- **Protocol:** same chronological split and horizons; GraphKoopman uses\n",
- " ODO + rollout ramp + early stopping (`EPOCHS`); GNN refs use a shorter\n",
- " interactive budget (`GNN_EPOCHS`) and shared teaching width (capacity not\n",
- " equalized — see parameter counts).\n",
- "- **API:** `from koopman_graph.baselines.gnn import STGCNBaseline, DCRNNBaseline,\n",
- " GraphWaveNetBaseline`.\n",
- "- **Honesty:** GNN numbers are teaching baselines, not published SOTA\n",
- " reproductions; spectral tools remain Koopman/DMD-family.\n",
- "- **Next steps:** compare against notebook 03's DMD/EDMD rows; try notebook 24's\n",
- " chaotic benchmarks if you want a nonlinearity stress test instead of traffic.\n",
- "\n",
- "## Next steps\n",
- "\n",
- "- See [`03_traffic_network.ipynb`](03_traffic_network.ipynb) for DMD/EDMD rows on METR-LA.\n",
- "- See [`24_nonlinear_chaotic_benchmarks.ipynb`](24_nonlinear_chaotic_benchmarks.ipynb) for nonlinearity stress tests.\n",
- "- See [`07_koopman_spectrum.ipynb`](07_koopman_spectrum.ipynb) to inspect the learned operator spectrum.\n",
- "- Explore the [API reference](https://koopmangraph.readthedocs.io/en/latest/api.html).\n"
- ]
- }
- ],
- "metadata": {
- "kernelspec": {
- "display_name": "koopmangraph",
- "language": "python",
- "name": "python3"
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "koopmangraph",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.13"
+ }
},
- "language_info": {
- "codemirror_mode": {
- "name": "ipython",
- "version": 3
- },
- "file_extension": ".py",
- "mimetype": "text/x-python",
- "name": "python",
- "nbconvert_exporter": "python",
- "pygments_lexer": "ipython3",
- "version": "3.12.13"
- }
- },
- "nbformat": 4,
- "nbformat_minor": 5
+ "nbformat": 4,
+ "nbformat_minor": 5
}
diff --git a/examples/38_operator_factorization_ablation.ipynb b/examples/38_operator_factorization_ablation.ipynb
index 3b15229..99354a6 100644
--- a/examples/38_operator_factorization_ablation.ipynb
+++ b/examples/38_operator_factorization_ablation.ipynb
@@ -7,17 +7,20 @@
"source": [
"# Operator factorization ablation: factorized vs joint latent map\n",
"\n",
- "The networked operator\n",
+ "The default networked operator (one-tap degree $P=1$)\n",
"\n",
"$$\n",
"I \\otimes K_{\\mathrm{self}} + \\hat{A} \\otimes K_{\\mathrm{nbr}}\n",
"$$\n",
"\n",
- "ties parameters across nodes. This notebook measures what that tying costs on a\n",
- "small path-diffusion surrogate: rollout MSE, trainable (or fitted) parameter\n",
- "count, and spectral agreement versus an **unconstrained** joint map on the same\n",
- "flattened latents. Results are for $N=4$, $d=2$ only — do not generalize\n",
- "beyond this setup.\n"
+ "ties parameters across nodes. This notebook first measures what that tying\n",
+ "costs on a small path-diffusion surrogate: rollout mean squared error (MSE),\n",
+ "trainable (or fitted) parameter count, and spectral agreement versus an\n",
+ "**unconstrained** joint map on the same flattened latents. A later section\n",
+ "retrains hop-matched models at $P \\in \\{0,1,2\\}$ on the same surrogate.\n",
+ "Results are for $N=4$, $d=2$ only — do not generalize beyond this setup.\n",
+ "Raising $P$ is **not** claimed to explain the historical joint least-squares\n",
+ "gap.\n"
]
},
{
@@ -38,11 +41,12 @@
"name": "stdout",
"output_type": "stream",
"text": [
- "koopman_graph 0.10.0; torch 2.13.0; joint state dim = 8\n"
+ "koopman_graph 0.15.0; torch 2.13.0; joint state dim = 8\n"
]
}
],
"source": [
+ "import os\n",
"import warnings\n",
"\n",
"from tqdm.std import TqdmExperimentalWarning, TqdmWarning\n",
@@ -50,6 +54,11 @@
"warnings.filterwarnings(\"ignore\", category=TqdmWarning)\n",
"warnings.filterwarnings(\"ignore\", category=TqdmExperimentalWarning)\n",
"\n",
+ "import matplotlib\n",
+ "\n",
+ "if os.environ.get(\"PYTEST_CURRENT_TEST\"):\n",
+ " matplotlib.use(\"Agg\")\n",
+ "\n",
"import matplotlib.pyplot as plt\n",
"import torch\n",
"\n",
@@ -58,13 +67,15 @@
"from koopman_graph.analysis import dynamical_similarity\n",
"from koopman_graph.baselines.base import fit_row_operator\n",
"from koopman_graph.datasets.synthetic import SyntheticDynamicGraphBenchmark\n",
+ "from koopman_graph.nn import ReceptiveFieldMismatchWarning\n",
"\n",
"SEED = 0\n",
"N = 4\n",
"FEATURES = 2\n",
"LATENT = 2 # joint state dim = N * LATENT = 8\n",
"STEPS = 8\n",
- "EPOCHS = 80\n",
+ "EPOCHS = 80 # historical one-tap vs joint-LS protocol\n",
+ "EPOCHS_P = 40 # hop-matched P-arm only; not comparable to EPOCHS\n",
"LR = 5e-3\n",
"torch.manual_seed(SEED)\n",
"\n",
@@ -90,19 +101,21 @@
"source": [
"## Motivation and background\n",
"\n",
- "Factorized graph Koopman operators keep two shared $d \\times d$ blocks\n",
- "($K_{\\mathrm{self}}$, $K_{\\mathrm{nbr}}$; $2d^2$ free parameters in the\n",
- "default symmetric adjacency mode) and rebuild an effective\n",
- "$(Nd) \\times (Nd)$ map through the adjacency\n",
- "([Li et al., 2020](https://openreview.net/forum?id=H1ldzA4tPr)). That is a\n",
- "strong inductive bias: every node shares the same blocks, and coupling enters\n",
- "only through $\\hat{A}$.\n",
+ "Factorized graph Koopman operators keep shared $d \\times d$ blocks and rebuild\n",
+ "an effective $(Nd) \\times (Nd)$ map through the adjacency\n",
+ "([Li et al., 2020](https://openreview.net/forum?id=H1ldzA4tPr)). At the\n",
+ "factory default $P=1$ those blocks are $K_{\\mathrm{self}}$ and\n",
+ "$K_{\\mathrm{nbr}}$ ($2d^2$ free parameters in symmetric adjacency mode).\n",
+ "Opt-in $P>1$ adds globally shared extra hops, for $(P+1)d^2$ factor\n",
+ "parameters. Every node still shares the same blocks; coupling enters only\n",
+ "through powers of $\\hat{A}$.\n",
"\n",
"An unconstrained joint operator on flattened latents has $(Nd)^2$ free entries\n",
"and can represent couplings the Kronecker form cannot. At small $N$ we can fit\n",
"that joint map by least squares on encoded trajectories and ask how much\n",
- "multi-step accuracy and spectral structure the factorization leaves on the\n",
- "table — without claiming the gap holds for large graphs or other systems.\n"
+ "multi-step accuracy and spectral structure the **one-tap** factorization leaves\n",
+ "on the table — without claiming the gap holds for large graphs, other systems,\n",
+ "or higher $P$.\n"
]
},
{
@@ -112,12 +125,18 @@
"source": [
"## Minimal example: shared encoder/decoder, two latent maps\n",
"\n",
- "1. Train `koopman=\"graph\"` end-to-end (factorized operator + GNN codec).\n",
+ "1. Train `koopman=\"graph\"` end-to-end with **explicit**\n",
+ " `koopman_filter_degree=1` (the factory default) and a two-layer GCN codec.\n",
+ " Encoder hops (2) exceed operator hops (1), so `fit` emits\n",
+ " `ReceptiveFieldMismatchWarning`; training still proceeds.\n",
"2. Freeze the codec; encode the training trajectory.\n",
"3. Fit an unrestricted $K_{\\mathrm{joint}} \\in \\mathbb{R}^{Nd \\times Nd}$ with\n",
" `fit_row_operator` (row convention $z_{t+1} = z_t K^{\\top}$).\n",
"4. Roll out both maps from the same holdout origin and decode with the same\n",
- " decoder.\n"
+ " decoder.\n",
+ "\n",
+ "This historical protocol uses 80 epochs. Do not retune it when reading the\n",
+ "later $P$-arm (40 epochs, hop-matched codec).\n"
]
},
{
@@ -132,7 +151,8 @@
"text": [
"K_joint shape: (8, 8)\n",
"factor params: 8\n",
- "joint fitted entries: 64\n"
+ "joint fitted entries: 64\n",
+ "receptive-field mismatch warning (2-layer encoder vs P=1): True\n"
]
}
],
@@ -152,8 +172,14 @@
" latent_dim=LATENT,\n",
" time_step=1.0,\n",
" koopman=\"graph\",\n",
+ " koopman_filter_degree=1,\n",
+ ")\n",
+ "with warnings.catch_warnings(record=True) as caught:\n",
+ " warnings.simplefilter(\"always\", ReceptiveFieldMismatchWarning)\n",
+ " _ = graph_model.fit(train, epochs=EPOCHS, lr=LR, device=\"cpu\")\n",
+ "hop_warned = any(\n",
+ " issubclass(w.category, ReceptiveFieldMismatchWarning) for w in caught\n",
")\n",
- "_ = graph_model.fit(train, epochs=EPOCHS, lr=LR, device=\"cpu\")\n",
"graph_model.eval()\n",
"\n",
"with torch.no_grad():\n",
@@ -170,7 +196,8 @@
" \"factor params:\",\n",
" sum(p.numel() for p in graph_model.koopman.parameters() if p.requires_grad),\n",
")\n",
- "print(\"joint fitted entries:\", int(K_joint.numel()))\n"
+ "print(\"joint fitted entries:\", int(K_joint.numel()))\n",
+ "print(\"receptive-field mismatch warning (2-layer encoder vs P=1):\", hop_warned)\n"
]
},
{
@@ -180,10 +207,10 @@
"source": [
"## Progressive deep dive: rollout, counts, spectra\n",
"\n",
- "Parameter counts compare the **trainable** factorized blocks to the **fitted**\n",
- "joint matrix (the joint arm is not end-to-end trained). Spectral agreement uses\n",
- "`dynamical_similarity` between the factorized effective spectrum and\n",
- "`compute_spectrum(K_joint)`.\n"
+ "Parameter counts compare the **trainable** one-tap factorized blocks to the\n",
+ "**fitted** joint matrix (the joint arm is not end-to-end trained). Spectral\n",
+ "agreement uses `dynamical_similarity` between the factorized effective spectrum\n",
+ "and `compute_spectrum(K_joint)`.\n"
]
},
{
@@ -248,16 +275,16 @@
"id": "205667f3",
"metadata": {},
"source": [
- "## Results\n",
- "\n",
- "Bars: multi-step MSE (left) and operator degrees of freedom (right). Expect the\n",
- "joint LS fit on frozen latents to achieve lower rollout error at an\n",
- "$(Nd)^2 / (2d^2)$ parameter premium ($64/8 = 8\\times$ here). Look for a\n",
- "large left-bar gap together with the $8\\times$ right-bar DoF ratio. The\n",
- "printed spectral distance (default Wasserstein comparison via\n",
- "`dynamical_similarity`) quantifies disagreement between the factorized\n",
- "effective spectrum and the joint spectrum — not a claim about ResDMD\n",
- "residuals.\n"
+ "## Results (historical one-tap protocol)\n",
+ "\n",
+ "Bars: multi-step MSE (left) and operator degrees of freedom (right) for the\n",
+ "**80-epoch**, $P=1$, two-layer-encoder run. Expect the joint LS fit on frozen\n",
+ "latents to achieve lower rollout error at an $(Nd)^2 / (2d^2)$ parameter\n",
+ "premium ($64/8 = 8\\times$ here). Look for a large left-bar gap together with\n",
+ "the $8\\times$ right-bar DoF ratio. The printed spectral distance (default\n",
+ "Wasserstein comparison via `dynamical_similarity`) quantifies disagreement\n",
+ "between the factorized effective spectrum and the joint spectrum — not a claim\n",
+ "about ResDMD residuals, and not a claim that encoder hop order caused the gap.\n"
]
},
{
@@ -329,18 +356,200 @@
"\n",
"On this seeded path-diffusion surrogate with a shared GNN codec, unrestricted\n",
"least-squares on flattened latents substantially reduces multi-step MSE relative\n",
- "to the trained factorized operator, at an $8\\times$ increase in operator\n",
- "degrees of freedom ($64$ vs $8$). Spectral distance between the effective\n",
- "factorized operator and $K_{\\mathrm{joint}}$ is nonzero, so the two maps are\n",
- "not merely reparameterizations of each other.\n",
- "\n",
- "Caveats: the joint arm is a **post-hoc** linear fit on frozen encodings, not a\n",
- "second end-to-end trained model; the factorized arm carries whatever the\n",
- "encoder–operator–decoder training achieved in $80$ epochs. The comparison\n",
- "therefore measures expressiveness *available in the latent coordinates the\n",
- "factorized model learned*, not a fully optimized joint neural baseline. Do not\n",
- "extrapolate the numerical gap to larger $N$, other topologies, or systems\n",
- "where the Kronecker structure matches the physics.\n"
+ "to the trained **one-tap** ($P=1$) factorized operator, at an $8\\times$\n",
+ "increase in operator degrees of freedom ($64$ vs $8$). Spectral distance\n",
+ "between the effective factorized operator and $K_{\\mathrm{joint}}$ is nonzero,\n",
+ "so the two maps are not merely reparameterizations of each other.\n",
+ "\n",
+ "The historical factorized arm used a two-layer encoder (two hops) with a\n",
+ "one-hop Koopman factor. Encoder neighborhood mixing does not compensate for a\n",
+ "one-hop $K$; the warning above records that mismatch. That observation does\n",
+ "**not** identify hop order as the cause of the MSE gap: the joint arm is a\n",
+ "post-hoc linear fit on frozen encodings, not a second end-to-end trained\n",
+ "model, and the factorized arm carries whatever the encoder–operator–decoder\n",
+ "training achieved in $80$ epochs.\n",
+ "\n",
+ "Do not extrapolate the numerical gap to larger $N$, other topologies, higher\n",
+ "$P$, or systems where the Kronecker structure matches the physics. The next\n",
+ "section retrains hop-matched $P \\in \\{0,1,2\\}$ models for $40$ epochs on the\n",
+ "same surrogate; those numbers are a different protocol.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "p-intro",
+ "metadata": {},
+ "source": [
+ "## Degree-$P$ ablation (hop-matched encoder)\n",
+ "\n",
+ "Factory `koopman_filter_degree` stays $1$; here $P$ is passed **explicitly**.\n",
+ "For $P \\ge 1$ the GCN codec uses `num_layers=P` so encoder hops match the\n",
+ "monomial operator radius. A GCN cannot use zero layers, so $P=0$ (self term\n",
+ "only) is trained with `num_layers=1` and is expected to warn.\n",
+ "\n",
+ "Each arm is trained for **40 epochs** from the same seed on the same\n",
+ "path-diffusion data as the historical comparison. That is **not** the\n",
+ "80-epoch historical protocol: do not treat a $P=2$ win on this seed as the\n",
+ "cause of the joint-LS gap, and do not splice these MSE values onto the\n",
+ "previous figure.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "p-code",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "P=0: encoder_layers=1 active_factors=4 allocated_params=8 holdout_MSE=1.0900 hop_warning=True\n",
+ "P=1: encoder_layers=1 active_factors=8 allocated_params=8 holdout_MSE=3.3018 hop_warning=False\n",
+ "P=2: encoder_layers=2 active_factors=12 allocated_params=12 holdout_MSE=1.3187 hop_warning=False\n",
+ "On this seed, hop-matched P=2 has lower hold-out MSE than P=1. That is not evidence that hop order caused the historical joint-LS gap (different epoch count, different encoder depth, and the joint arm is post-hoc least squares).\n"
+ ]
+ }
+ ],
+ "source": [
+ "def _hop_warned(caught):\n",
+ " return any(\n",
+ " issubclass(w.category, ReceptiveFieldMismatchWarning) for w in caught\n",
+ " )\n",
+ "\n",
+ "\n",
+ "def _allocated_params(model):\n",
+ " return sum(p.numel() for p in model.koopman.parameters() if p.requires_grad)\n",
+ "\n",
+ "\n",
+ "P_VALUES = (0, 1, 2)\n",
+ "p_rows = []\n",
+ "\n",
+ "for degree in P_VALUES:\n",
+ " torch.manual_seed(SEED)\n",
+ " n_layers = max(degree, 1)\n",
+ " model_p = GraphKoopmanModel(\n",
+ " GNNEncoder(FEATURES, 32, LATENT, num_layers=n_layers),\n",
+ " GNNDecoder(LATENT, 32, FEATURES, num_layers=n_layers),\n",
+ " latent_dim=LATENT,\n",
+ " time_step=1.0,\n",
+ " koopman=\"graph\",\n",
+ " koopman_filter_degree=degree,\n",
+ " )\n",
+ " with warnings.catch_warnings(record=True) as caught:\n",
+ " warnings.simplefilter(\"always\", ReceptiveFieldMismatchWarning)\n",
+ " _ = model_p.fit(train, epochs=EPOCHS_P, lr=LR, device=\"cpu\")\n",
+ " model_p.eval()\n",
+ " mse_p = rollout_mse_graph(model_p, hold)\n",
+ " allocated = _allocated_params(model_p)\n",
+ " active = (degree + 1) * LATENT * LATENT\n",
+ " warned_p = _hop_warned(caught)\n",
+ " p_rows.append((degree, n_layers, active, allocated, mse_p, warned_p))\n",
+ " print(\n",
+ " f\"P={degree}: encoder_layers={n_layers} active_factors={active} \"\n",
+ " f\"allocated_params={allocated} holdout_MSE={mse_p:.4f} \"\n",
+ " f\"hop_warning={warned_p}\"\n",
+ " )\n",
+ "\n",
+ "mse_by_p = {row[0]: row[4] for row in p_rows}\n",
+ "if mse_by_p[2] < mse_by_p[1]:\n",
+ " print(\n",
+ " \"On this seed, hop-matched P=2 has lower hold-out MSE than P=1. \"\n",
+ " \"That is not evidence that hop order caused the historical joint-LS \"\n",
+ " \"gap (different epoch count, different encoder depth, and the joint \"\n",
+ " \"arm is post-hoc least squares).\"\n",
+ " )\n",
+ "else:\n",
+ " print(\n",
+ " \"On this seed, hop-matched P=2 did not beat P=1 on hold-out MSE. \"\n",
+ " \"Either outcome still does not attribute the historical joint-LS gap \"\n",
+ " \"to hop order.\"\n",
+ " )\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "p-results",
+ "metadata": {},
+ "source": [
+ "## Results (hop-matched $P$ arm)\n",
+ "\n",
+ "Bars below use the **40-epoch** hop-matched protocol. Active factors in\n",
+ "$K_{\\mathrm{eff}}$ are $(P+1)d^2$ ($4$, $8$, $12$). At $P=0$ the unused\n",
+ "$K_{\\mathrm{nbr}}$ block is still an allocated `nn.Parameter` (8 entries);\n",
+ "the map itself is self-only. The $P=0$ fit is allowed to warn (encoder hops\n",
+ "$1$ vs operator hops $0$). Compare $P$ values **within this figure only**.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "p-plot",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/png": "iVBORw0KGgoAAAANSUhEUgAAA0cAAAFfCAYAAACFoLdcAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlcelbwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAaO1JREFUeJzt3Qm8jOX///GPnciefSklyZIslS2yl62NSItoVxFFloqIhEq0qWihRYqiL4qISCrtKEVI1uxkifk/3pffPf85c2bOmXPOzFlfz8djHpx7Zu659/v63Nd1fa5sPp/PZwAAAACQxWVP6wUAAAAAgPSA4AgAAAAACI4AAAAA4BRqjgAAAACA4AgAAAAATqHmCAAAAAAIjgAAAADgFGqOgCxu06ZNtnv37lT5rY0bNyb7t06ePGl//vmn7d+/P+rLBSRXrI5LDUGo+e7bt88/TeeOztdQjh8/7t7btm1bnOlHjhxx592uXbssI187ElqGQ4cOWVb3119/xWQfJ4WOQR2z+hfIyAiOkGEdPnzYXYjD3Rh109b7J06csMxMN8TNmzcn+/vnn3++PfLII5YaqlSpYo899liyvqv9edZZZ9mkSZMsK9Bxq+PXe6mA+++//6b1YmVZ4QKTWB2Xuq5pvhMmTPBP07mj8zXQ0aNHrWvXrlawYEFr3Lix3Xvvvf7rQuvWra1o0aLWpEkTe/zxxy3aatasmWrXjlA++eQTO/fcc+MFaAoodc5s3bo15Pe2b9/ugonU4i3Pzp07YzL///77z8455xwbO3asxZqOq3DBT/bs2a1Vq1Y2atSomC8HEEsER8iw/ve//7nCw+zZs0O+r4KE3g93g8wsHnjgAatXr15aLwaibPny5e74vfjii61p06bWoEEDK1CggNWtW9e+/vprtncqUxAQHJikNgU6FStWjDNNQdmMGTPs559/dgH0e++956argPrtt9+6IEAF86effjrqy1OhQgUrVqyYpVWNXd++fe3222+38uXLx3lP03XunHfeeS54DKZzqVOnTqmynKq5q1+/vlueu+66Kya/sWbNGreeF154YUzmv3TpUrv++uutcOHCVrVqVTv99NPddWjevHlxPpcjRw53njz55JP2999/x2RZgNRAcAQA6ZAKtt7TcRVut2zZ4gpBqr3o2LGjHTt2LK0XEalMBc9ffvklzrSVK1dauXLl7Oyzz443vUaNGi6gipUff/zRhg0bZmnh448/dtvizjvvDHnuFCpUyDV1nD9/fpz39u7da+vXr3eF+9QwcOBAV7MTS99//737t3bt2jGZv4I6PWRUkKTaL9XUaftdccUVNnPmzDif7dy5s+XJk8eef/75mCwLkBoIjpAl6Wali/2BAwfCNrsIfPKlpgQHDx5M0vxVoPXmr2YIoZpU6HPqI6CnoKGWwWtSpWZzoZoP6rtaruAmWMHz09+aX2LNstTPQZ8L9bQ1+Gmoto/+TYgKInolV1K2uwpC2qcJNaP01s9bbq2ntlfgdtF67dixI84y/PPPP/Hmo88ETw9F6x9uHyfkm2++sZw5c7ontR41Ibrmmmvcev72229hv6sAyjsWFFSFC6RUqxB4XGqdvCZK+q6377RN9Z7WO9RxlZzCX0LbRftDvx+4n/TZ4N8PR+ddYJNaHUPh+mNo/QPPs1DHm9Zd8/T6AXmvUMeapmlZk9PvQsdTuGtScNM+r3+Hjlc1Z/KWSeupf7XMXn8ovbQNtW6h+oR4149Qv619EK45WEJ9jjQvHaehjg1v2b1rmv7WNktKE2jVmKmmJLg2T8uroEnnSaVKlfw1aYHnldSpU8dibdGiRfbss8/ayy+/HNX5ansFbtsffvjB1eaoaV0s3H333bZw4UIXbMtpp51mzz33nAu8x40bF+ezuXPndtv+lVdeyfRN2pGJ+YAM6r333lNJyff222+HfL93797u/c2bN/unHTp0yHf33Xf7ChQo4CtUqJAvR44cvrp16/qWLl0a57utW7f2XXDBBb5Vq1b5zj//fF+RIkV82bNn93Xs2NG3a9euRJdtw4YN7rdfeOEF36hRo3wlSpTw5cmTx1e5cmXf999/7z7z+OOP+8444wxf3rx5fWXKlPEtXLgwzjxuuukmX8WKFd2rVKlSblnr1avn++qrr/yfue6663z58+d3y+Z9Vq99+/a59/Wv1lfrqs+dfvrpvqZNm/q+++47/zw0vVevXr4PP/zQV6FCBfdZLeuAAQPirdeaNWt8l19+uS9Xrly+YsWK+XLnzu275pprfDt27IjzubVr1/oaNmzoy5Ytm9t2jRs3dvtB89V+SYzWsWbNmv7vt2/f3vfbb7+5bTpmzJg4n505c6avRo0avpw5c7rPFi5c2PfII4/4Tpw4EedzH3zwga98+fLuc0WLFvXdfvvtbptrnrNnz/Z/rlq1ar62bdv6Fi9e7Dv33HPdNuvZs6d77/jx475HH33U7Tdtt3z58rl9qm0X7J133vGdd9557ve0TPpN7fOTJ0/6IlGlShW3LMF0XGiZV69eHfa72h7eseDtpw4dOvi2bNkS53N6X8fQvHnzfJUqVXLnhfaP1lO/MXjwYN8rr7ziK126tO+0007zlS1b1rdo0SL33UmTJrnp2g7a7tOnT49ovT766CN3Tmm7aNvqu/od/aZn7ty57ve1fx544AH/Ouj39V5iJkyY4L7/yy+/+K666ir/+avt+eWXX8b57F133eXfVlofLdeFF17oW7Jkif8zPXr0cMuq4zHwPNu6datv586d/uNS20TnqrajPv/MM89EtE10ndFvesd7mzZt/NeQ4cOH+z+nfaPtLb///rtbBh2DWmZvmfR5/atzVNcWb/rXX3/tmzJlipunzuNAOjc1XdvNo22va6DmUbJkSXcM9+nTx7d3717/Z3St0LUjkLabrqm6Xul9bQtdg3Tt9fz000/u9958803fE0884c4nrYf2s6Yl5ujRo+7zWp5g2r+a9/PPP+/r37+/r2DBgr4jR47439f1WO9rGULROaJtn9gr+FwKpu2k6+kdd9zh/tZv6lqZErqe33bbbW6b6p6iY0X7rHnz5u4aG8o///wT0froFXzNTIyufWeffXa86VOnTnXru3LlymSvK5CWCI6Q4YOjZ599NuSF/pZbbokXHKlgr5v8p59+6r+BXXnlla7QrgJKYHCkwqIKlN739b4KZ/Xr10/0JuIVbC699FLf008/7fvvv/98hw8f9l122WW+c845xzdx4kRXmFKB8N9///W1aNHCV65cOXfTD0eFsM6dO7vC1+7du/3Tb775Zld4CaZ5qZCiZVZBR4VyLbcK/YGFNhW2GjVq5G7iBw8edNO0TYODBhXGdDNu2bKl76+//nLTtG30G7Vq1fIXbrVNtS61a9f2bdq0yU1TQKiCgQq4iQVHGzdudAUabRMvENUya18EB0feTXjQoEFuO4oK79rHKlQHFthUOFbBQvtB20KFeRUqQgVHWh8tr7a51ssLXLt27eqWTcGQ5qH9qsKd5h0Y3L700ktuviNGjPDvUwUgKtQMHTrUl5j9+/e7eer3Ah04cMAF0meeeWbEQZb8+uuvbp10PAZSoVn7TwHSnj17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MG0DbYtAhQoV8t1xxx0Rr+8DDzzg9tn48eN927dv923ZssV39913+/LmzeuWx6PPXH755b4rr7zSt3btWrd/Zs+e7fv000/d8l199dW+oUOH+rZt2+ZbsGCB77fffvO99957vmzZsvluv/1236ZNm9y8dRzo86+99pp/3jfffLNb52uuucY3d+5ct82nTJkSdpkfeeQRN49//vkn5Ptz5sxx78+bN8/93aRJE1+xYsXifa5AgQLxtp8sWbLEff/5559PcNstXbrUbadrr73WbZP9+/f73nrrLTffRx99NN76aRvNnz/ft2/fPt8nn3ziK1WqlK9Bgwa+//77z//ZSLfZ4sWL3T5p27at7/vvv/cdOHDA9+233/q6devm9k3g795www3ud7V82mc65nr37u2f14kTJ3wVK1b0tWzZ0u23f//917d69WrfoEGDfO+//3689a5Tp46vbt26CW4bIKNr1KiRO788zz77rDvngq87119/vS9fvny+lStXxpn++eef+44fP+7+P3jwYHcOe38H0v0p8HzUfUH3oCNHjsT5XLNmzXxnn3227+TJk+7vCRMmuHm+/fbb/s9o/rq36nw+evRogut33XXX+cqWLRvRtrjlllvcPcy7VmkZdA0oV66cb/369XE+u3DhQv//dU3XMgfTNStnzpzuXu+tj+bdpUsXX+7cud311KNtrt/WtdGj+4zuXbquHzx4MM68df9JyK5du9x2GzJkSLz3dH3Uey+++GKc6dOmTXPTH3rooXjfOXz4sLs/B77atWvn7g3B0z/77DNfJHRvD7XdAmnbaRt620/Gjh3rjsVly5b5p+keonuSln/NmjX+6ffdd5/bP4GvESNGRLR8SDmCo3QaHCX0Cg4mKlWq5KtWrVqckzCw0OldxEMFR2eddZbv4osvjjNNF0Gd+MHBUaS/E+vgSPPQxTDQ8uXL3fSRI0fGmb5hwwZ3gQoMSCIJjiKdXyyDo59//tkVxAML8qLtX7NmzTgFA+3XggULugJ2IC84at++fbx56AatZQzenw0bNvSdccYZvmPHjvkL8ZqHAulIKLjWDTQUbS8FHTfddJN/WqjgSL+t31QBIdiPP/7o3nvssccSXI4aNWq44zW4wKMbjLaXbsKB6xd4c5cZM2a46dOnT0/yNqtatarv3HPPDVnY8ni/+9FHH8WZftttt7kAyfuN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KE6ePOlqb2TTpk22ZcsWfzCkJmzq/xOJTp062dy5c+3CCy90gdZVV13lUnJrzCKPapEGDBjgAiPPww8/bPPmzXOpwC+44AL7+eefrW3btkldDQAAAABIWc1R48aN7d5777XHH3/c1d60a9fOcuY8NZtly5bZ0KFDI55X2bJl3SCu4Sg4UrO6YA0bNnQvAAAAAEizmqPKlSvbpEmTXO2NMsqNGzfOTVezuMKFC1uTJk2itnAAAAAAkK5TeXfr1s29AtWqVStOogQAAAAAyBLjHG3fvt0WL17sxiQqXbq0a27H+EEAAAAAslRwNH78eBs0aJDLEKdMchrryMssl5Q+RwAAAACQYfscrVq1yvr372/PPfec7d2719atW2d79uyxd99918aOHWvz58+PzZICABBjGrfP5/OxnQEgi0pycLR8+XK7+uqrrXv37pYjRw43LVu2bHbllVe6zHNffPFFLJYTAICY0IM+tYg477zz3NASH3/8ccjPaRy+ChUquAytGmLik08+YY8AQFYPjtSvSE/WQtF0+h0BADKSl156ydavX2+TJ08O+5m33nrLHnzwQRcgaQDyLl26WIcOHWzNmjWpuqwAgHQWHDVr1sx++uknmzhxoh05csQfFE2dOtVmz55tHTt2jMVyAgAQExpsXDVH559/ftjPaNgKZWnVPa5gwYI2ePBgO/PMM10TcwBAFg6O9PRs69atbiDY/Pnz2xlnnOEGa73xxhtt165dVqdOHStXrpx7jRgxIjZLDQBAKlHCIY3lFzyO32WXXeaamgMAsnC2ukaNGrknbJGoXr16cpYJAIB0QxlZT5486R4GBipRooQb1iKco0ePupdn//79MV1OAEAaBEfVqlVzLwAAsjIFTEpIFM6oUaNs2LBhqbpMAIBUblbnOX78uK1evdr+/PPPFC4CAADpl2qINK7fjh074tUoaay/cDT23759+/yvzZs3p8LSAgBSPTj68MMPrWzZsq4GSYkZ5IcffrDmzZunaGEAAEhv1K9W/WkXLVoUZ/rChQutQYMGYb+XJ08el7wh8AUAyGTB0ZYtW9wYR0899ZQNHTrUP/2CCy5wYz8sWLAg2ssIAEDMaNBXZV09ceKEv7mc/ta/HqXxfvvtt92A56oxevjhh939UMmJAABZODhavHixtWrVym644QYrUKBAnPfq1atnS5cujebyAQAQUzNnznS1QyVLlnSDm2ugc/2tZnGeTp06uTGOhgwZ4lJ4z5071w0WW7lyZfYOAGTlhAwaSbxQoULu/8EdUZWJp3jx4tFbOgAAYkzBULjBzQPdfvvt7gUAyLySXHOk2qH58+fb4cOH4wRHGzdutGnTpiXY/hoAAAAAMk3N0UUXXWQNGza02rVruyw9aqPds2dPmz59ujVu3NhatGgRmyUFAAAAgPSWrW7q1KnWu3dv9/9t27bZL7/8YoMHD7ZZs2ZFe/kAAAAAIH3WHC1btswOHDhgd911l3uFeq9NmzbRXEYAAAAASH81R19++WXYdN16T+M+AAAAAECmrzlKaJwINa+rWrVqkr7z448/2vr16618+fJukL3gDHiBlE3onXfeiTddSSAqVaqU7GUHAAAAgIiDo+eff94eeeQR+/fff11Q89prr8V538te9+2330Y0vxUrVtgdd9zhvnPWWWfZN998YyVKlLA5c+ZY6dKlQ37nyJEjduONN1rr1q3jpAyvWLEiwREAAACA1AmOmjdvbkWLFnXBy759+6xbt25x3i9YsKDVrVvXBTiROHr0qEv9Xb16dfe3gq769evb/fffH7J2KNDQoUPtkksuiXTRAQAAACB6wVGVKlXcS03YFNikdFTwJk2axPk7X7581qpVKxd8Jebrr7+2LVu22Nlnn20XXHBBgk3xAAAAACAmCRkqVKhgK1eu9Def27lzpzVt2tSNeTRs2DBLrpMnT7pkDjVq1EjwcwqEXn/9ddesr2XLli5Y++uvv8J+XoHc/v3747wAAAAAIMXBkZInjBo1ymrWrOn+Hj16tEuUMHz4cJswYYLrS5Qcjz76qK1du9Y1mQsnV65ctnjxYtc/afbs2bZu3TrXHO+2224L+x0ta6FChfwvJX4AAAAAgBQHR0uWLHF9gxSoiJrBPfHEEy5AueWWW+zzzz9P6ixt/PjxNmbMGHv//fcTzHaXJ08eu/TSS/1/Fy5c2PVRUmpx1RCFMnDgQNdHyntt3rw5ycsHAAAAIPPLmZzmb4cOHXL/37Rpk+v74yVHUPBy4sSJJM1v4sSJNmDAABcYJWfw2Pz587uaq71791rJkiXjva9l0gsAAAAAolpz1LhxY/vwww/t8ccfd7VF7dq1s5w5T8VYy5Yts4YNG1pS0oM/8MADNmPGDGvbtm28948fP25Tp051Tfnk77//dmnEA+m7SuUdKjACAAAAgJjVHClL3aRJk+zFF190Acm4cePc9O+//941cwvOQhfOe++9Z/fcc4917drV1fooCJLcuXNb586d3f9VQ6VxjaZMmeLGMfr000/dbyuQ0m/973//c32Qpk+fntTVAAAAAICUBUeiMY6CxzmqVauWzZw5M0nN866//nr3/3nz5sVpJucFRwqU9DtK2S0333yz+x01wfvpp59cIPbyyy+HHTQWAAAAAGIaHEXDdddd514JOe200/w1Sh6Na6QXAAAAAKRpnyMAAAAAyIwIjgAAAACA4AgAAAAAkllzpDGFlEzBs3XrVpdFbsOGDUmdFQAAAABkzODoySeftIIFC1qxYsVcGm1lp1OK7VatWrl/+/TpE7slBQAAAID0kK1u3bp19uijj9pjjz3mssg9+OCDdvToUXvuueesefPmtmLFCuvRo4dLw92gQYNYLjMAAAAApF1wtGzZMuvYsaMLiuTXX391TeoUEEnFihVtyZIltnz5coIjAAAAAJk3OPr333+taNGi/r/VtE41R4GKFy9uhw4diu4SAgAAAEB6Co7OPfdc1+dox44dlidPHtfnaPfu3bZr1y4XFB08eNDef/99GzRoUGyXGACANLB27VqbM2eO/fPPP1auXDnr1KmTlShRgn0BAFkxIUOzZs2sVKlSVqZMGRcM1a1b11q2bGlVqlRx751zzjmudumaa66J7RIDAJDKpk6dajVq1LA1a9ZYoUKFbPbs2e6+98MPP7AvACAr1hxly5bNFixYYLNmzXI1R1dddZWdOHHCzj77bFu1apU1atTIevfu7d4DACAzeeaZZ+ymm26yV1991f09YMAAFyy9/PLLNnHixLRePABAagdHkj9/fuvWrZv/7xw5cli/fv2itSwAAKRLaj6n5uOBY/6ptYRaVAAAsmhwBABAVvTiiy/aHXfc4ZqTq8XE119/ba1bt07wAaGSFgUmLtq/f38qLS0AILkIjgAASMTvv/9uv/zyi1122WVWoUIF27Bhg3311VcuSZGGsghl1KhRNmzYMLYt4ql70SX299ZtbJlUUqZ0Kftm5Qq2NyJCcAQAQAJ8Pp9rUt69e3cX8IgysypQ6tOnj82cOTPk9wYOHGh9+/aNU3NUvnx5tjVcYHTOpbewJVLJ70umsK0Ru+BIba6ViEHZegAAyOz27t1r27Zts3r16sWZrqytyloXjhIUkaQIADJpKm/PpEmTbPjw4bFZGgAA0pkiRYq4YSw++eSTOAkZFi1aZNWqVUvTZQMApHHNkW4QK1eujPJiAACQfr300kt2/fXXu3GOzjvvPPviiy/swIED9s4776T1ogEA0rLmqH379vbzzz/bjBkzXDtsAAAyu3bt2tn69evdeH61a9e2MWPG2Lp169xAsACALFxzpFHCN2/ebJ06dXJtqYsXLx7n/TvvvNOGDBkSzWUEACDN6X539dVXp/ViAADSU3DUqFEjGz9+fNj3q1evntJlAgAAAID0Hxyp82k0O6AqC5DGi1B60+BaqHCOHz/umvblzZvXqlatGrVlAQAAAJB1JbnPUWCAsnr1avvzzz+T9X11alUb7kqVKlmPHj3szDPPtKuuuirREcSVHUiBlJo2NG7c2C644ALbuHFjMtcCAAAAAFIQHH344YdWtmxZV4M0ceJEN+2HH36w5s2bRzwPdWS96667bPfu3fbdd9/ZH3/8YT/++KM98MADYb+jwEl9nW6++WZX27R161YrVqyY3XjjjclZDQBABqYx95LzHgAAUQuOtmzZ4kYJf+qpp2zo0KH+6arByZkzpy1YsCCi+XTo0MHatm3r/7tkyZKuNkjpURMKyhQgadRxyZUrlw0YMMCWLl1qv//+e1JXBQCQgWkwcg1MntT3AACIWnC0ePFia9Wqld1www1WoECBOO9p9HAFKsn1zTffJJgWVTVMaoZXuHBh/7SLLrrI/14oR48edQFV4AsAkHlpmAkN0qqMqgAAxDQhgxIo6ImcZMuWLc57CjwiTaoQ7MUXX3S1RkuWLAn7GTXBUzO6QAqUsmfP7t4LZdSoUTZs2LBkLRMAIP2ZNGmSHTt2zPV91f9z587tf+/kyZOumXeFChVc6wIAAGIaHKl26Mknn7TDhw/HCY6UFGHatGn27rvvJnWW9v7779t9993nRiCvX79+2M/pRnfkyJE403SD1M0w8OYYSE3w+vbtGyeAU0IHAEDGpHvFoUOHXHCk/wfei9S8W4HRa6+9lqbLCADIIsGRmrE1bNjQjRBeqlQp1+m1Z8+eNn36dJc9rkWLFkma38yZM+366693iR2UtS4hFStWtNmzZ8frAyW6GYaiZhU0rQCAzOPbb791/6qJt/qi5suXL60XCQCQlbPVTZ061Xr37u3+v23bNvvll19s8ODBNmvWrCTNRze1Ll262LPPPmu33357vPcVeKmp3Y4dO9zfLVu2tO3bt9vKlSvjzEN9nxKqcQIAZD6ffPIJgREAIG1rjvbs2eP6+SgNt17Jpax2nTt3drVGSgnuZalTk4hLLrnE/f/AgQOuNmrKlCkuQ97FF1/sxkLq1q2bjRgxwvUzevjhh+3RRx+10047LdnLAgDImJYvX26ff/65ux8oEUOgkSNHhm1yDQBAVIKjV1991dX0KKhRxrrq1atbcij1tvovabyjhx56yD/99NNPt7lz555auJw5XRM+pfn2vP322/bMM8/Y5MmTXXM5tTdXsAQAyFrGjx9v999/v9WpUydesh5Rf1QAAGIaHN10002u86uSL4wePdqNb6QgScFSmTJlIp7PnXfe6V4JUXO54HGPFBBpbCO9AABZ13PPPeeSAGlwcAAA0qTPUYkSJaxfv362atUqW716tbVr186ef/55lwFOyRjUvAEAgFhT9tImTZqwoQEAaZuQwVO1alXX9+ePP/5wtUgaADY4mxwAALHQoEEDHsgBANK2WV2gtWvXuuZ1emmco8suu8zVJAEAEGs1a9Z0Q0CsWLHCqlSp4gYED6REPuq7CgBApJJ819i5c6e9+eabLiBS07patWpZr169rGvXrknqcwQAQEroPqREDBpIPBT1hSU4AgDENDh6/fXXXSdYBUMa70hN6wAASG0aYw8AgDQNjm699VaXkEEZ6wAAAAAgywZHGgDWc/z4cdu6dasbh0gptgEASC1PPfWUHTt2LOz7epCXK1cudggAIGLJ6qn622+/uYH35s+fbydOnHCdYBs1auQGZ73wwguTM0sAAJJk1qxZdvjwYf/fPp/PJQf6559/rEaNGta7d2+CIwBAbIOjAwcOWNOmTa169eo2Y8YMN77Rtm3bbPLkyW76mjVrSMwAAIi5JUuWxJumB3aqMVJrhnz58rEXAACxDY4+/vhjK1u2rM2dO9dy5Mjhn962bVu74oor7L333nNP6wAASG26L2n8vXPPPdeNvwcAQEwHgd2xY4drOhcYGHnq1Knj3gcAIK38999/tmvXLleLBABATGuOzjvvPBs7dqy78RQvXtw//eDBgzZz5kzr379/UmcJAECSzZkzxwVCgfbt22evvPKK1a9fP+RDvGjQb2jeBQoUiMn8AQAZKDhq2bKlnX322W58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FYzu2Z7tDADI2s3qNADsDTfcYIsWLXKZ6c4880y76aab3Mjkw4YNsy1btsR2SQEAAAAgPQRHgc455xw3ntGmTZtswIAB7v9PP/109JcOAAAAANJjKm/P8ePHbc6cOa6J3YIFC6xFixZ27bXXRn/pAAAAACA9BkerV6+2yZMn25tvvmkFCxa0Hj162KRJk6xMmTKxW0IAAAAASE/BkYKgu+++21q1amXPP/+8NWnSxP/erl27/P9nnCMAAAAAmTo42r9/v504ccLmzp3rXuH069fPxo4dG63lAwAAAID0FRx17tzZatWqlejnKlasmNJlAgAAAID0GxxVqFDBvQAAyMoWL15sEydOdMmI7rzzzrReHABAWmerAwAgK9q5c6d1797djh07ZoULF07rxQEApIdxjgAAyGp8Pp/dfPPN1qtXLzcAOgAg8yE4AgAgAuPGjbMjR464xEMAgMyJZnUAACTim2++cZlY9W/27JE9Vzx69Kh7BWZ9BQBk4uBIF/3jx4/HmZY7d273AgAgMzhw4IB16dLFJkyYYOXKlYv4e6NGjbJhw4ZFdVnq1Klrf/+9NarzRHhlypS2b7/9hk0EZCHJCo6effZZGz16tP39999RGefo5MmTri13jhw5LCPg5pS6uDkBSEszZ860bdu22bvvvutesnbtWtuyZYtde+219sYbb7gB0IMNHDjQ+vbtG6fmqHz58ilaFgVGRcpUSdE8kJTt/SubC8hikhwcrVy50gYMGGBPPPGE1a5d23LlyhXn/TJlykQ8r2XLltkLL7xgM2bMsOrVq7vmConZuHGj3XXXXfbZZ59Znjx57LrrrrNnnnkm5I0pVrg5pS5uTgDSUpMmTey1116LM+2XX36xsmXLuhql4PugR/covQAAmTg4WrVqlQtIevfubSltkte/f3+74447rECBAhEFRv/9959dccUVLkvQn3/+aXv27LH27du7YOn1119P0fIAABBucPPgAc71UO7MM890NUcAgCycrU5PyjS+Q0rpaZpqjm666SbLmzdvRN+ZO3eurV692p577jkrVaqUVa1a1YYPH25Tp0617du3p3iZAAAAAGRdSQ6Omjdv7gKU+fPnW2pbvny5nXXWWVahQgX/tGbNmrk+S1999VWqLw8AIGsaMWKEa7UAAMjizerefPNN27Bhg7Vp08YKFixop59+epz377zzThsyZIjFgmqHzjjjjDjTihcvbtmyZQtbc0QqVQBAtDVt2pSNCgCZUJKDo0aNGtn48ePDvq/ECmlBAVJqpVIFAAAAkPkkOTiqVq2ae6WF0qVL26JFi+JM27lzp0sDrj5IocQilSoAAACAzCfJfY5SmzLUKfiRhg0buix1enkWLlzoxke6+OKLwyZ+UPO/wBcAAAAARGUQWPXjmTx5sn3//fe2e/duf/AiHTp0cBnoInHixAn3Xe/7CoTcQuU8tVh79+61IkWK2JQpU6x79+7WunVrq1mzpkv/PWnSJPfb6t+k94L7IgEAAABATIMjBTQaEO/AgQOWP39+Fygpg9yCBQtcINO1a9eI51WnTh37+eef/X97Kb337dvn5q1+RKoVyp79VAWX/v/xxx/bvffe64IkbxDYMWPGJHU1AAAAACBlwZGCIPXb+eGHH1xihm3bttnYsWNt8+bNdskll7hAKVKqeUpIoUKF/LVJnnLlytnMmTOTutgAAAAAEN0+R+vWrbPLLrvMcuXKZblz57bDhw+76UpycMMNN9i8efOSOksAAAAAyHjB0ZEjRyxfvnzu/2XKlLE1a9b431OgdPz48eguIQAAAACk14QMnpYtW1rPnj2tR48err/Ryy+/bJ9++mn0lg4AAAAA0mtwpIQLXu2Q+gQpGBo9erTrc6TgqHHjxrFYTgAAAABIX8FR2bJl4/x90UUX2fvvvx/NZQIAAACAjDMIrGqPVq9eHWdAVgAAAADIUsHRhx9+6GqQqlWrZhMnTnTTlNq7efPm0V4+AAAAAEifzeq2bNli3bt3twkTJtgff/zhBoOVCy64wHLmzOnGQWrRokUslhUAAAAA0k/N0eLFi61Vq1ZuTKMCBQrEea9evXq2dOnSaC4fAAAAAKTP4Gjv3r0uS51ky5Ytznv79++3HDlyRG/pAAAAACC9BkeqHZo/f74b8DUwONq4caNNmzbNGjRoEO1lBAAAAID01+dIqbsbNmxotWvXtlKlStmJEyfcQLDTp093YxzR3wgAAABAlslWN3XqVOvdu7f7/7Zt2+yXX36xwYMH26xZs6K9fAAAAACQPmuOJHv27HbXXXe5FwAAAABk6UFgAQAAAMCyes3RsmXL7Nlnn7UNGzbYsWPH4rx388032/333x+t5QMAAACA9Bkc/frrry7pwpVXXmlXXXWV5cqVK877StQAAAAAAJk+ONIgsG3btrW33347NksEAAAAABkhOCpYsKDlz58/NksDAEA6dfLkSVu3bp37f6VKleK1nAAAZMGEDKo1+vLLL23p0qWxWSIAANKZsWPHWvny5a1jx452xRVXuP/PmDEjrRcLAJAeao46d+5sl156qZUoUcJOP/30OO/feuut9tBDD0VzGQEASFP79u2z7777zt33vGCpW7duVqdOHTvrrLPYOwCQVYOjr776ykaNGuWCoJo1a8ZrVlCrVq1oLh8AAGlu+PDhcf7WOH8PPvigff311wRHAJCVgyPdCDp16mQvv/xyin98z549NnjwYFu4cKHlzZvXrrvuOhswYIDlyJEj5OcPHz5s5557brzpTz/9tFsmAABSg+6Fcs4554T9zNGjR93Ls3///lRZNgBAKgZHZcuWjUonVJ/PZx06dHDjJL3++usuUNIYSbt373bNFcJ1ht2yZYt99NFHduGFF/qnFy1aNMXLAwBApE3s7rzzTtf3KKHhK9TKYtiwYWxUAMjMCRmaNWvmEjLMnz8/RT+8aNEi++KLL+y1116zSy65xC6//HIbOXKkTZgwwfbu3Zvgd8844wwrV66c/3XaaaelaFkAAIjEoUOHrF27dpYvXz6bOnVqgp8dOHCgC6S81+bNm9nIAJDZgqN33nnHduzYYW3atLFChQrFCVL0GjFiRETz+fzzz93nq1at6p+meaomacWKFQl+95ZbbrHKlSu7z8+cOTOpqwAAQLICI2VsVfO4BQsWWJEiRRL8fJ48eVwSo8AXACB9S3KzukaNGtmzzz4b9v3q1atHNJ+//vrLSpUqFWdayZIl3b9qOheObkx9+vSx0qVL29y5c61Lly721FNPWa9evUJ+njbfAICUUp9X3X/UBFz9ZIsVK8ZGBYBMKMnBUbVq1dwrpdR/KGfOuD+vRAzZs2e3EydOhPyOBp+dM2dOnGX5559/7NFHHw0bHNHmGwCQ0vuV+sj++OOPNmXKFFu7dq3/PQ0GW6ZMGTYwAGTVZnXRon5Du3btijNNyRh0E9J7oWTLli3eNPVXUoC0c+fOkN+hzTcAICX+++8/O3LkiJ1//vk2ZswYN5af91q2bBkbFwCycs1RtFx00UU2btw427Ztm7953dKlS10AVLdu3Yjns379epc9r0CBAmHbfOsFAEBy5M6d2yUQAgBkfmlWc6RsP+XLl7f+/fu7fkGqRXrsscdc0wVNF3V6VdKG9957z/2tzECzZs3yjxuhm5WazWmUcmUOAgAAAIAMFxxp0NfZs2fbTz/9ZIULF3ZtthUITZ48Od64RsoQJJdeeqkLlNTsTll/OnbsaLfeeqs9//zzabUaAAAAADKJNGtWJzVq1LDvvvvOZf8J1TROqcI1LoQ3yGuFChVs2rRpLmHDwYMH3fsAAAAAkOGDI0+4sSLU/0i1ScGU1Y7ACAAAAECmaFYHAAAAAOkJwREAAAAAEBwBAAAAwCnUHAEAAAAAwREAAAAAnELNEQAAAAAQHAEAAADAKdQcAQAAAADBEQAAAACcQs0RAAAAABAcAQAAAMAp1BwBAAAAAMERAAAAAJxCzREAAAAAEBwBAAAAwCnUHAEAAAAAwREAAAAAnELNEQAAAAAQHAEAAADAKdQcAQAAAADBEQAAAACkg5qjI0eO2MiRI6158+bWtm1be+ONN2LyHQAAUmrFihV2/fXXW9OmTe2uu+6yTZs2sVEBIJNJ0+CoS5cu9vrrr7ubzJVXXmm9evWyJ554IurfAQAgJb766itr0qSJlS1b1vr3729///23NWjQwP755x82LABkIjnT6oe//PJL+/DDD+3rr7+2unXrummHDh2yIUOG2L333mv58+ePyncAAEipRx55xFq2bGljxoxxf7do0cLKly9vEydOtEcffZQNDACZRJrVHC1cuNBKlSrlD3KkY8eOLthR04VofQcAgJT477//7PPPP7f27dv7p+XOndvatGljCxYsYOMCQCaSZjVHGzdutDJlysSZpuYK3nvR+s7Ro0fdy7Nv3z737/79+5O97CdPnrQTJ/5L9veR9O2dkv2V2Lz/O36EXZIp9uUJ+/fQwZjMG/G3dXL3o/c9n8+XYTbr9u3b3X0k+P6jvxctWhT2e9x/Mr5YXbO496Qu7j2Zx8lUuP+kWXB0/Phxy5MnT5xpuXLlsuzZs7v3ovWdUaNG2bBhw+JNV3OIlNixY1uKvo+kKVSoUMw22fb36LOWWfbl/VfUidm8Ed39eODAgZgeC9Hk3V+C7z/58uULe+8R7j+ZQ6yOU+49qYt7T+ZRKMb3nzQLjooWLWq7d++OM23v3r0uui9WrFjUvjNw4EDr27ev/299VvPQ57Nly2ZZhaJlBYSbN2+2ggULpvXiIAXYl5lHVtyXemKnG1NwLUx6pnuPBN9/lIwh3L1HuP9k3eM8M2I/Zh5ZdV/6Irz/pFlwVLt2bZswYYLt2bPHihQp4s8GJBdeeGHUvqMnfcFP+woXLmxZlU6CrHQiZGbsy8wjq+3LjFJj5NG+Ofvss10yIGVM9ej+E+7eI9x/svZxnlmxHzOPrLgvC0Vw/0mzhAxKpKAg5fHHH3d/Hzt2zKXkVqpU3YTk4MGDdskll9jHH38c8XcAAIi2nj172ptvvmkbNmxwf8+bN89WrlzppgMAMo80qzk6/fTT7f3333dP4d59912Xca5ixYouVXdghiA9mdu5c2fE3wEAINoefPBBW7t2rVWtWtXddzQA7JNPPukGhAUAZB5pFhzJpZde6m4wa9ascc0Pzj333DjvKxjS2EaBtUKJfQehaVtpLI7gJobIeNiXmQf7MuPImTOnG4B89OjRtnXrVqtUqVKGax6YVjjOMwf2Y+bBvkxYNl9GyqcKAAAAADGSZn2OAAAAACA9ITgCAAAAgLTuc4SkO3LkiP//uXPndgPgxorGhIrl/LMyjZfy22+/uf9rvK3ixYvbWWedFZPtrXEMtmzZ4lIO0+cs+pQ4Ri/JkSOHG5g6VtQKOiuNz4b0eZzrOqX7T6x4rf051mPjxx9/tMOHD7v/656ge08shjjR8fLdd9+5ccLIKBz7MqH2JedMdFDyzUD++OMPNyK7OgHrQqb/K9W5Lj7RNHXqVHexVAdk/au/EV2vvvqqNWvWzPr06WP33nuvG8OrWrVq9ueff0btNxYtWmRt27a1Cy64wOrXr+86kSP6rrrqKitQoIA7J0877TQrVaqUf7iBaA3WN3LkSDvvvPPcza9ChQouucrx48ej9htAYm655RbLnz+/O8717xlnnOEGuY0mZaa95ppr3L1NL/3fy1aL6AWejRo1sptuusndf66//norUaKEjRgxImqbWINs6hqlpCWXXXaZDR8+PGrzxv/37bffuvNE56RXJlTSMmXVjJZly5ZZhw4d3NiiSpKmcovGe8vsCI4y2ImgpwIalV1PC5S1T09mbr755qj9xmeffeZugkOHDnXjTOkC1717d1fQRvSsWrXKGjdubCtWrHBjpfz++++2a9cuVwiO5m/cddddNmvWrKjNE6HPy8cee8ydk3o99NBDNmTIEHcuRYOGKtCwBR999JF72vvOO+/YCy+8YIMGDWJ3IFWP8379+rlj/OjRoy6NucYZ/OCDD6L2G506dXIPcfQgUC/VeGsaokctFhS8KPOi7j/K/KtrySOPPOK2dzRoH6p2UdmG9QAXsTsn8+bN6+4POi/XrVvnHibcfvvtUfuNsWPH2h133OEe3P71118uQ3SrVq2idqykV2Sry0AGDBjgbkQ6ATwqTD/88MPuKXJgkywNkKtmceEoyArVxEpPCPRdDXDoadGihXtSyHhS0aMLzJVXXukKGB498VHNQ+C2137VBTAhxYoVs8qVK4d9/4svvnCBmAavPPPMM6O0BpBt27ZZ6dKlbc6cOa6WThTA6Hx55plnrHfv3v4NdeLEiURre9QkT03zEjN48GAXJKkACcSaCl8FCxa0N954w7p16+afrnuICtZ6iObRfUf3kKQe53qYU6dOnTgFau/apffULBgp99Zbb9kNN9zgaqRV4y16kNO8efN4wYzuGdu3b09wfmqZoBqLcFR+KFeunL322mvsvihT0KJzI7Amp3///vbyyy/bnj174nxWDzQSSk4drkwYTEGYjpspU6bYjTfeaJkVfY4yEJ0EuhAFUqCkC09wXxXdUH744Yew89LBrZqKYLo49u3bN840VYuPHz8+xcuPU3RTUk1R4L5ULd0vv/xiPXv2jLOZ9IRPTR8SomruaNY4IXJe4Bq4L72HF2r+Flwoue222xKc34svvuhqaiN5MqtmDkBq+P77713QE3icb9y40QVBwcf5xx9/nGhtj2qcgq9ruveocHbxxRf7pzVs2ND1bdJ7BEfRu2ap/48XGIlqkLSdq1SpEuez7733XqI1g7quqfkc0k+ZMPiclJo1a7pzNhyVI1UuScyOHTvcg77Mfv8hOMpgJ0KvXr1c5K4Ctmpy1B/oueeei/fZr776Ksnz181PAZPakgdSe2RV1ZKgIXr7UU9w1CRSNyUVdFXLoL5karYSSB1Z9Rmk34KG2noroYaerquJyt133+1uWO3atYvzWT1li8aTNhUU33zzTRdIAal1nKvwrJpn1YyqAHbfffe5GuvOnTvH+Wz79u3jdBKPlGoodB4Fdij3ktUkVnuBpO1L9YvUfUW1CcuXL3d9JNU0OLjAq1oIvZD+qBXCTz/9ZF26dHHn2969e+3dd991za8VsAb79ddfU/ybPp/P7rnnHjvnnHOsZcuWlpkRHGUQqt5WhjONzj5mzBjXlKpq1aquOU/r1q3jfT65zeok+Hv6mwwo0b05adur34ialpQpU8Y1Z1T/IDWrCxSNZnWIHe0b1e4pQFLb74oVK7p9+eCDD8bLWheNZnXqaKvmmOpnGFzLCMTyONfDHAUqCpJ0nLdp08YlZFAT0kDJbVbnfTfUNO4/0aHCrRI46QGoau7UwV61SOpTfNFFF8X7fDSa1SE2fv75Zxfcqom1+rhqn1avXt01kWzSpEm8z0ejWV2fPn1cU9fFixdn+sy3BEcZhFdAVh+HSKozk9OsTk3zVEukatNA+lvTSesdvZqjBg0aRNRhn2Z16f+81I1JCUwSk9JmdepIrSaUKpROmjQp2csMJOc4V1ZN1XAnJrnN6tR3T8mGAlso6IGCpqmmAymnZlNqdTJ79mzXxzUxNKtL/7W52p+RpNVPabO6Bx54wPU5/PTTT928MjuCowx0Iqhdb6TtPJPTrE6U4lNPkVTg8yxcuNC1/Ub09qWankSCZnXplx4aKGOPOpFHIiXN6tSMSX3/1LlZHWF5UIHUomZ0qrFUQqBIJLdZne49qnFS6mA93JOlS5e62lbuP9HNeBtp/y2a1aXvfamaokjHG0tJs7r+/fvbK6+84gKjunXrWlZAcJRBeJl8Yk19XlQl++yzz7qnf2rDqpvVkiVLYv7bWYFqglTQTY19qbSbeq1evdr9reYUqnlUM4rgfmVIfm1urPfl+vXrXWCkwqNqlrwmS5FmFwJSQi0QVIMT6+O8Ro0advnll7u+TNOmTXPTlO1R0/QeonPNUqZUNaeLNa+vrGo21GdZf+t6RWKNjFUmHDRokLvvqEZY56H34CPSzKoZFeMcZRAq4IZqExxtau41Y8YMmzx5sstco/Sb+luDiCLlVK1dr169OBmZYkUpwdV0RftSv6f+avpb4yohOqPMKyuQ+ozF0vTp010TWCVgUU2iN+Cf+poBqREcqcWCBiGONTU9VeG5adOm7qXCX6jO5Uge1cJpYN3UoHuNXqrlVtNI/V9JH5ByelihZtYqS8Tas88+6x7IKQGDd+/RS33fMzPGOQIAAAAAao4AAAAA4BSa1QEAAAAAwREAAAAAnELNEQAAAAAQHAEAAADAKdQcAQAAAADBEQAAAACcQs0RAAAAABAcAQAAAMAp1BwBAAAAAMERAAAAAJxCzREAAAAAEBwBAAAAgDn/D58AbftKv2JNAAAAAElFTkSuQmCC",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "fig_p, axes_p = plt.subplots(1, 2, figsize=(8.5, 3.4))\n",
+ "labels_p = [f\"$P={row[0]}$\" for row in p_rows]\n",
+ "mses_p = [row[4] for row in p_rows]\n",
+ "active_p = [row[2] for row in p_rows]\n",
+ "\n",
+ "axes_p[0].bar(\n",
+ " labels_p,\n",
+ " mses_p,\n",
+ " color=[\"#293241\", \"#3d5a80\", \"#98c1d9\"],\n",
+ " edgecolor=\"black\",\n",
+ " linewidth=0.6,\n",
+ ")\n",
+ "axes_p[0].set_ylabel(f\"mean MSE over {STEPS} steps\")\n",
+ "axes_p[0].set_title(f\"Holdout rollout error ({EPOCHS_P} epochs)\")\n",
+ "\n",
+ "axes_p[1].bar(\n",
+ " labels_p,\n",
+ " active_p,\n",
+ " color=[\"#293241\", \"#3d5a80\", \"#98c1d9\"],\n",
+ " edgecolor=\"black\",\n",
+ " linewidth=0.6,\n",
+ ")\n",
+ "axes_p[1].set_ylabel(\"count\")\n",
+ "axes_p[1].set_title(\"Active factors $(P+1)d^2$\")\n",
+ "fig_p.suptitle(\n",
+ " f\"Hop-matched degree-$P$ arm on path diffusion ($N={N}$, $d={LATENT}$)\",\n",
+ " y=1.02,\n",
+ ")\n",
+ "fig_p.tight_layout()\n",
+ "plt.show()\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "p-disc",
+ "metadata": {},
+ "source": [
+ "## Interpretation of the $P$ arm\n",
+ "\n",
+ "These three fits share the surrogate, seed, and learning rate with the\n",
+ "historical comparison, but not the epoch count or encoder depth. $P=0$ cannot\n",
+ "hop-match a GCN (`num_layers` is at least $1$), and it still allocates an\n",
+ "unused neighbor block. If hop-matched $P=2$ beats $P=1$ on this seed, report\n",
+ "that number and stop there: it is one surrogate, one seed, and a shorter\n",
+ "schedule.\n",
+ "\n",
+ "The historical ~$0.71$ versus ~$0.019$ hold-out MSE gap (8 factor parameters\n",
+ "versus 64 joint entries) remains a **one-tap versus post-hoc joint LS**\n",
+ "result. This arm does not rewrite those figures and does not treat hop order\n",
+ "as their cause. Algebraic Kronecker-versus-dense spectrum for $P=2$ is in\n",
+ "`49_multi_hop_factorization.ipynb`, not a forecasting contest.\n"
]
},
{
@@ -350,11 +559,17 @@
"source": [
"## Takeaways\n",
"\n",
- "- Factorization buys a $2d^2$ operator budget and topology-conditioned\n",
- " advance; the unconstrained latent map needs $(Nd)^2$ entries.\n",
+ "- Factorization buys a $(P+1)d^2$ **active** operator budget and\n",
+ " topology-conditioned advance; the unconstrained latent map needs\n",
+ " $(Nd)^2$ entries. Default $P=1$ is $2d^2$. At $P=0$, unused $K_{\\mathrm{nbr}}$\n",
+ " remains allocated.\n",
"- On this $N=4$, $d=2$ diffusion surrogate, joint LS on frozen latents\n",
- " achieved much lower holdout MSE than the trained factorized operator, with\n",
- " nontrivial spectral distance between the two maps.\n",
+ " achieved much lower holdout MSE than the trained one-tap factorized\n",
+ " operator (80 epochs, two-layer encoder), with nontrivial spectral distance\n",
+ " between the two maps.\n",
+ "- A hop-matched $P \\in \\{0,1,2\\}$ retrain (40 epochs) is a separate protocol.\n",
+ " Encoder mixing does not substitute for raising $P$; $P=0$ cannot hop-match\n",
+ " a GCN. Neither protocol attributes the historical MSE gap to hop order.\n",
"- Report both error **and** parameter count; a lower joint error alone is not a\n",
" reason to abandon factorization when $N$ is large.\n",
"- Rebuild or refute this ablation before claiming the factorized form is\n",
@@ -369,11 +584,13 @@
"## Further reading\n",
"\n",
"- Package API: `GraphKoopmanOperator.effective_matrix`, `GraphKoopmanModel`\n",
- " with `koopman=\"graph\"`, `fit_row_operator`, `compute_spectrum`,\n",
- " `dynamical_similarity`\n",
- "- Related notebooks: `37_cross_topology_transfer.ipynb` (cross-$N$ transfer),\n",
- " `35_symmetry_adapted_operator.ipynb` (orbit ties),\n",
- " `29_scalable_operators.ipynb` ($N \\cdot d$ cost)\n",
+ " with `koopman=\"graph\"` and explicit `koopman_filter_degree`,\n",
+ " `fit_row_operator`, `compute_spectrum`, `dynamical_similarity`,\n",
+ " `ReceptiveFieldMismatchWarning`\n",
+ "- Related notebooks: `49_multi_hop_factorization.ipynb` (Kronecker vs dense\n",
+ " $P=2$ spectrum; hop warning), `37_cross_topology_transfer.ipynb`\n",
+ " (cross-$N$ transfer), `35_symmetry_adapted_operator.ipynb` (orbit ties),\n",
+ " `29_large_graph_block_diagonal.ipynb` ($N \\cdot d$ cost)\n",
"- Li, Y., He, H., Wu, J., Katabi, D., & Torralba, A. (2020). Learning\n",
" compositional Koopman operators for model-based control.\n",
" *International Conference on Learning Representations (ICLR)*.\n",
diff --git a/examples/47_benchmark_manifest.ipynb b/examples/47_benchmark_manifest.ipynb
new file mode 100644
index 0000000..fc286e9
--- /dev/null
+++ b/examples/47_benchmark_manifest.ipynb
@@ -0,0 +1,668 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "t47-title",
+ "metadata": {},
+ "source": [
+ "# Identity-bound benchmark manifests\n",
+ "\n",
+ "This notebook walks a tracked v0.15 smoke fixture through\n",
+ "`koopman-graph benchmark run` and `verify`. You should leave knowing\n",
+ "how the canonical summary digest is formed (the full identity body,\n",
+ "not a short field subset), how `run` rejects a wrong dataset payload,\n",
+ "and how `verify` rejects an inconsistent `summary.json`.\n",
+ "\n",
+ "**Honesty.** This path does **not** fit `GraphKoopmanModel`, does\n",
+ "**not** download METR-LA or PEMS HDF5, and does **not** invent forecast\n",
+ "MAE or RMSE. `run` writes `executed=False`. YAML manifests need\n",
+ "`pip install \"koopman-graph[cli]\"` (PyYAML).\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t47-setup-md",
+ "metadata": {},
+ "source": [
+ "## Setup\n",
+ "\n",
+ "Locate the checkout, require PyYAML for the tracked YAML manifests, and\n",
+ "define a subprocess helper for `python -m koopman_graph.cli` (equivalent\n",
+ "to the `koopman-graph` console script). The notebook was written against\n",
+ "`koopman_graph` 0.15.0. Stored cell outputs are teaching evidence for\n",
+ "that version; a later bump changes the live `summary_sha256` because\n",
+ "`package_version` participates in the digest.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "t47-setup",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T17:46:55.867174Z",
+ "iopub.status.busy": "2026-08-20T17:46:55.867052Z",
+ "iopub.status.idle": "2026-08-20T17:46:58.084060Z",
+ "shell.execute_reply": "2026-08-20T17:46:58.083602Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "koopman_graph 0.15.0\n",
+ "located checkout; workdir is temporary\n"
+ ]
+ }
+ ],
+ "source": [
+ "from __future__ import annotations\n",
+ "\n",
+ "import json\n",
+ "import shutil\n",
+ "import subprocess\n",
+ "import sys\n",
+ "import tempfile\n",
+ "import warnings\n",
+ "from pathlib import Path\n",
+ "\n",
+ "warnings.filterwarnings(\"ignore\", message=\"IProgress not found\")\n",
+ "\n",
+ "try:\n",
+ " import yaml # noqa: F401\n",
+ "except ImportError as exc:\n",
+ " raise ImportError(\n",
+ " \"YAML manifests require PyYAML. Install with: \"\n",
+ " \"pip install 'koopman-graph[cli]'\"\n",
+ " ) from exc\n",
+ "\n",
+ "import koopman_graph\n",
+ "\n",
+ "\n",
+ "def repo_root() -> Path:\n",
+ " here = Path.cwd().resolve()\n",
+ " for candidate in (here, *here.parents):\n",
+ " manifest = candidate / \"benchmarks\" / \"v0.15\" / \"smoke_telemetry.yaml\"\n",
+ " if (candidate / \"pyproject.toml\").is_file() and manifest.is_file():\n",
+ " return candidate\n",
+ " raise FileNotFoundError(\n",
+ " \"Could not locate the KoopmanGraph checkout \"\n",
+ " \"(need benchmarks/v0.15/smoke_telemetry.yaml).\"\n",
+ " )\n",
+ "\n",
+ "\n",
+ "ROOT = repo_root()\n",
+ "SMOKE = ROOT / \"benchmarks\" / \"v0.15\"\n",
+ "WORKDIR = Path(tempfile.mkdtemp(prefix=\"kg-bench-47-\"))\n",
+ "\n",
+ "\n",
+ "def _public_text(text: str) -> str:\n",
+ " \"\"\"Replace checkout, workdir, and home prefixes in printed text.\"\"\"\n",
+ " for old, new in (\n",
+ " (str(WORKDIR), \"\"),\n",
+ " (str(ROOT), \"\"),\n",
+ " (str(Path.home()), \"\"),\n",
+ " ):\n",
+ " text = text.replace(old, new)\n",
+ " return text\n",
+ "\n",
+ "\n",
+ "def kg_cli(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:\n",
+ " completed = subprocess.run(\n",
+ " [sys.executable, \"-m\", \"koopman_graph.cli\", *args],\n",
+ " check=False,\n",
+ " capture_output=True,\n",
+ " text=True,\n",
+ " )\n",
+ " if completed.stdout:\n",
+ " text = _public_text(completed.stdout)\n",
+ " print(text, end=\"\" if text.endswith(\"\\n\") else \"\\n\")\n",
+ " if completed.stderr:\n",
+ " text = _public_text(completed.stderr)\n",
+ " print(text, end=\"\" if text.endswith(\"\\n\") else \"\\n\")\n",
+ " if check and completed.returncode != 0:\n",
+ " completed.check_returncode()\n",
+ " return completed\n",
+ "\n",
+ "\n",
+ "print(f\"koopman_graph {koopman_graph.__version__}\")\n",
+ "print(\"located checkout; workdir is temporary\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t47-motivation",
+ "metadata": {},
+ "source": [
+ "## Motivation and background\n",
+ "\n",
+ "Ad-hoc training scripts do not pin dataset bytes, seeds, or method\n",
+ "roles. A frozen `ExperimentManifest` (`benchmark_manifest_v1`) records\n",
+ "that protocol. `run` checks the payload SHA-256 against\n",
+ "`dataset.sha256` (raw file bytes, not a hash of the YAML document).\n",
+ "It then writes a canonical SHA-256 over the summary body: UTF-8 JSON,\n",
+ "`sort_keys=True`, compact separators, `ensure_ascii=False`, with\n",
+ "`summary_sha256` omitted. The body fields are `schema_version`,\n",
+ "`manifest_id`, `manifest_sha256`, `dataset_sha256`, `track`, `methods`,\n",
+ "`seeds`, `horizons`, `metrics`, `controls`, `package_version`, and\n",
+ "`executed`. `verify` recomputes that digest and binds the summary to\n",
+ "the loaded manifest (id, track, dataset digest, method names and roles,\n",
+ "seeds, horizons, metrics, controls, and `manifest_sha256`). It does\n",
+ "not require `executed` to be `False`, and it does not require\n",
+ "`package_version` to equal the installed `koopman_graph` version.\n",
+ "\n",
+ "The payloads under `benchmarks/v0.15/data/` are tiny UTF-8 stand-ins,\n",
+ "not the METR-LA HDF5 of Li et al. (2018) or Caltrans PeMS caches.\n",
+ "Example 22 remains the METR-LA teaching-baseline comparison; this\n",
+ "notebook does not re-run it and does not download METR-LA.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t47-min-md",
+ "metadata": {},
+ "source": [
+ "## Minimal example\n",
+ "\n",
+ "`run` the telemetry smoke manifest (`smoke_telemetry.yaml`) against its\n",
+ "hashed payload into a temporary directory, then `verify` the written\n",
+ "`summary.json`. Expect exit code 0 and `executed=False`.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "t47-min",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T17:46:58.085265Z",
+ "iopub.status.busy": "2026-08-20T17:46:58.085138Z",
+ "iopub.status.idle": "2026-08-20T17:47:02.346146Z",
+ "shell.execute_reply": "2026-08-20T17:47:02.345729Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "wrote summary: /telemetry-run/summary.json\n",
+ "verified summary: /telemetry-run/summary.json\n",
+ "executed= False\n",
+ "summary_sha256= f912c7d86b7630e0d508f72de4e95dbdc515dc6b6bf79687d2b65fea56e8ec59\n",
+ "metrics (declared names, not scores)= ['mae', 'rmse']\n"
+ ]
+ }
+ ],
+ "source": [
+ "TELEMETRY_MANIFEST = SMOKE / \"smoke_telemetry.yaml\"\n",
+ "TELEMETRY_DATA = SMOKE / \"data\" / \"smoke_telemetry.txt\"\n",
+ "TELEMETRY_OUT = WORKDIR / \"telemetry-run\"\n",
+ "\n",
+ "kg_cli(\n",
+ " \"benchmark\",\n",
+ " \"run\",\n",
+ " \"--manifest\",\n",
+ " str(TELEMETRY_MANIFEST),\n",
+ " \"--data\",\n",
+ " str(TELEMETRY_DATA),\n",
+ " \"--out\",\n",
+ " str(TELEMETRY_OUT),\n",
+ ")\n",
+ "kg_cli(\n",
+ " \"benchmark\",\n",
+ " \"verify\",\n",
+ " \"--manifest\",\n",
+ " str(TELEMETRY_MANIFEST),\n",
+ " \"--against\",\n",
+ " str(TELEMETRY_OUT),\n",
+ ")\n",
+ "\n",
+ "live = json.loads((TELEMETRY_OUT / \"summary.json\").read_text(encoding=\"utf-8\"))\n",
+ "assert live[\"executed\"] is False\n",
+ "assert live[\"schema_version\"] == \"benchmark_summary_v1\"\n",
+ "assert live[\"manifest_id\"] == \"smoke-telemetry\"\n",
+ "print(\"executed=\", live[\"executed\"])\n",
+ "print(\"summary_sha256=\", live[\"summary_sha256\"])\n",
+ "print(\"metrics (declared names, not scores)=\", live[\"metrics\"])\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t47-dive-md",
+ "metadata": {},
+ "source": [
+ "## Progressive deep dive\n",
+ "\n",
+ "`verify` the other two checked-in summaries (multiphysics, topology\n",
+ "transfer) without a second `run`. Then point `run` at a wrong payload\n",
+ "so the dataset SHA-256 check fails. Next, flip `executed` on a copy of\n",
+ "the telemetry summary while leaving `summary_sha256` stale, then\n",
+ "rewrite that digest so `verify` succeeds with `executed=True`. Finally,\n",
+ "repeat the telemetry `run` through `koopman_graph.benchmark.run_manifest`\n",
+ "(deep import, not the root façade).\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "t47-verify-others",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T17:47:02.347554Z",
+ "iopub.status.busy": "2026-08-20T17:47:02.347446Z",
+ "iopub.status.idle": "2026-08-20T17:47:06.493748Z",
+ "shell.execute_reply": "2026-08-20T17:47:06.493261Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "verified summary: /benchmarks/v0.15/summaries/smoke_multiphysics.json\n",
+ "verified summary: /benchmarks/v0.15/summaries/smoke_topology_transfer.json\n",
+ "smoke_multiphysics: verified summary_sha256=01d05d9d192df7af10cb2a0f1cbb341055d2cdb9fb523df5c81c18745b7da344\n",
+ "smoke_topology_transfer: verified summary_sha256=27ebe7ba9454e52770524b06af513bbfed2330e989c12cb856341150fda861e8\n"
+ ]
+ }
+ ],
+ "source": [
+ "checked_in = []\n",
+ "for track in (\"smoke_multiphysics\", \"smoke_topology_transfer\"):\n",
+ " kg_cli(\n",
+ " \"benchmark\",\n",
+ " \"verify\",\n",
+ " \"--manifest\",\n",
+ " str(SMOKE / f\"{track}.yaml\"),\n",
+ " \"--against\",\n",
+ " str(SMOKE / \"summaries\" / f\"{track}.json\"),\n",
+ " )\n",
+ " payload = json.loads(\n",
+ " (SMOKE / \"summaries\" / f\"{track}.json\").read_text(encoding=\"utf-8\")\n",
+ " )\n",
+ " assert payload[\"executed\"] is False\n",
+ " checked_in.append((track, payload[\"summary_sha256\"]))\n",
+ "\n",
+ "for track, digest in checked_in:\n",
+ " print(f\"{track}: verified summary_sha256={digest}\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t47-wrong-md",
+ "metadata": {},
+ "source": [
+ "Point `run` at a temporary file whose bytes are not the hashed\n",
+ "telemetry payload. Expect a non-zero exit and a SHA-256 mismatch;\n",
+ "`run` must not write a summary for that data.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "t47-wrong",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T17:47:06.495069Z",
+ "iopub.status.busy": "2026-08-20T17:47:06.494964Z",
+ "iopub.status.idle": "2026-08-20T17:47:08.532444Z",
+ "shell.execute_reply": "2026-08-20T17:47:08.532023Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "error: SHA256 mismatch for /wrong_telemetry.txt: got de1f5986a8f80e6164fb0aaf605c997a3742b87affcedcaa7d51842c34617f46, expected bcb2f305aaa35fbb8a5f0b2caff14192a2d66613c5970e214b0127175b1b6aba\n",
+ "wrong-payload exit code= 1\n"
+ ]
+ }
+ ],
+ "source": [
+ "wrong_payload = WORKDIR / \"wrong_telemetry.txt\"\n",
+ "wrong_payload.write_text(\"not the hashed telemetry bytes\\n\", encoding=\"utf-8\")\n",
+ "wrong_out = WORKDIR / \"wrong-payload-run\"\n",
+ "wrong = kg_cli(\n",
+ " \"benchmark\",\n",
+ " \"run\",\n",
+ " \"--manifest\",\n",
+ " str(TELEMETRY_MANIFEST),\n",
+ " \"--data\",\n",
+ " str(wrong_payload),\n",
+ " \"--out\",\n",
+ " str(wrong_out),\n",
+ " check=False,\n",
+ ")\n",
+ "assert wrong.returncode != 0, \"wrong payload must fail run\"\n",
+ "assert not (wrong_out / \"summary.json\").is_file()\n",
+ "print(\"wrong-payload exit code=\", wrong.returncode)\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t47-tamper-md",
+ "metadata": {},
+ "source": [
+ "Copy the checked-in telemetry summary and set `executed=True` without\n",
+ "recomputing `summary_sha256`. `verify` should fail with a digest\n",
+ "mismatch. That failure is an inconsistent hash, not a policy that\n",
+ "`executed` must stay `False`.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "t47-tamper",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T17:47:08.534009Z",
+ "iopub.status.busy": "2026-08-20T17:47:08.533877Z",
+ "iopub.status.idle": "2026-08-20T17:47:10.642485Z",
+ "shell.execute_reply": "2026-08-20T17:47:10.641594Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "error: summary_sha256 mismatch: got 0b6c25dfe9139c84fcf69f3032bc969f47ea55a7dd02b5249480864c3101d31d, expected 5bffaffd130eb793539fbf71efcbcedd354514f42c4502fb3a9a62f006e5d82e\n",
+ "stale-digest verify exit code= 1\n"
+ ]
+ }
+ ],
+ "source": [
+ "checked = json.loads(\n",
+ " (SMOKE / \"summaries\" / \"smoke_telemetry.json\").read_text(encoding=\"utf-8\")\n",
+ ")\n",
+ "tampered_path = WORKDIR / \"tampered_telemetry.json\"\n",
+ "tampered = dict(checked)\n",
+ "tampered[\"executed\"] = True\n",
+ "tampered_path.write_text(json.dumps(tampered, indent=2) + \"\\n\", encoding=\"utf-8\")\n",
+ "\n",
+ "failed = kg_cli(\n",
+ " \"benchmark\",\n",
+ " \"verify\",\n",
+ " \"--manifest\",\n",
+ " str(TELEMETRY_MANIFEST),\n",
+ " \"--against\",\n",
+ " str(tampered_path),\n",
+ " check=False,\n",
+ ")\n",
+ "assert failed.returncode != 0, \"stale summary_sha256 must fail verify\"\n",
+ "print(\"stale-digest verify exit code=\", failed.returncode)\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t47-rewrite-md",
+ "metadata": {},
+ "source": [
+ "Recompute `summary_sha256` over the canonical body with the public\n",
+ "`canonical_sha256` and `summary_to_mapping` helpers, then `verify`\n",
+ "again. A consistent `executed=True` document binds to the same\n",
+ "manifest and passes. `run` still writes `executed=False`; `verify`\n",
+ "does not enforce that flag.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "t47-rewrite",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T17:47:10.644960Z",
+ "iopub.status.busy": "2026-08-20T17:47:10.644787Z",
+ "iopub.status.idle": "2026-08-20T17:47:10.653142Z",
+ "shell.execute_reply": "2026-08-20T17:47:10.652763Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "rewritten executed=True verify ok\n",
+ "rewritten summary_sha256= 5bffaffd130eb793539fbf71efcbcedd354514f42c4502fb3a9a62f006e5d82e\n"
+ ]
+ }
+ ],
+ "source": [
+ "from koopman_graph.benchmark import (\n",
+ " canonical_sha256,\n",
+ " summary_from_mapping,\n",
+ " summary_to_mapping,\n",
+ " verify_summary,\n",
+ ")\n",
+ "\n",
+ "record = summary_from_mapping(tampered)\n",
+ "body = summary_to_mapping(record)\n",
+ "body.pop(\"summary_sha256\")\n",
+ "rewritten = summary_to_mapping(record)\n",
+ "rewritten[\"summary_sha256\"] = canonical_sha256(body)\n",
+ "rewritten_path = WORKDIR / \"rewritten_executed_true.json\"\n",
+ "rewritten_path.write_text(json.dumps(rewritten, indent=2) + \"\\n\", encoding=\"utf-8\")\n",
+ "rewritten_ok = verify_summary(TELEMETRY_MANIFEST, rewritten_path)\n",
+ "assert rewritten_ok.executed is True\n",
+ "print(\"rewritten executed=True verify ok\")\n",
+ "print(\"rewritten summary_sha256=\", rewritten_ok.summary_sha256)\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t47-api-md",
+ "metadata": {},
+ "source": [
+ "The same telemetry `run` / `verify` path is available as a deep import\n",
+ "(`koopman_graph.benchmark.run_manifest`), not through the root package\n",
+ "façade.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "t47-python-api",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T17:47:10.654231Z",
+ "iopub.status.busy": "2026-08-20T17:47:10.654161Z",
+ "iopub.status.idle": "2026-08-20T17:47:10.658717Z",
+ "shell.execute_reply": "2026-08-20T17:47:10.658321Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "python run_manifest wrote /telemetry-python/summary.json\n",
+ "executed= False\n",
+ "summary_sha256= f912c7d86b7630e0d508f72de4e95dbdc515dc6b6bf79687d2b65fea56e8ec59\n"
+ ]
+ }
+ ],
+ "source": [
+ "from koopman_graph.benchmark import load_summary, run_manifest, verify_summary\n",
+ "\n",
+ "api_out = WORKDIR / \"telemetry-python\"\n",
+ "written = run_manifest(TELEMETRY_MANIFEST, TELEMETRY_DATA, api_out)\n",
+ "summary = verify_summary(TELEMETRY_MANIFEST, written)\n",
+ "loaded = load_summary(written)\n",
+ "assert summary.executed is False\n",
+ "assert loaded.executed is False\n",
+ "print(\"python run_manifest wrote /telemetry-python/summary.json\")\n",
+ "print(\"executed=\", summary.executed)\n",
+ "print(\"summary_sha256=\", summary.summary_sha256)\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t47-results-md",
+ "metadata": {},
+ "source": [
+ "## Results\n",
+ "\n",
+ "Identity fields from the live telemetry `run`. Look at `executed=False`\n",
+ "and at `metrics` as **declared names** (MAE, RMSE) rather than reported\n",
+ "scores. A passing `verify` on the checked-in JSON files is a hash check,\n",
+ "not a forecast table. The wrong-payload `run` and the stale digest fail\n",
+ "on purpose; the rewritten `executed=True` file is the control that\n",
+ "`verify` checks consistency, not the `executed` flag.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "id": "t47-results",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T17:47:10.659791Z",
+ "iopub.status.busy": "2026-08-20T17:47:10.659726Z",
+ "iopub.status.idle": "2026-08-20T17:47:10.662777Z",
+ "shell.execute_reply": "2026-08-20T17:47:10.662396Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "source executed summary_sha256\n",
+ "smoke_telemetry (live run) False f912c7d86b7630e0d508f72de4e95dbdc515dc6b6bf79687d2b65fea56e8ec59\n",
+ "smoke_telemetry (checked-in) False 0b6c25dfe9139c84fcf69f3032bc969f47ea55a7dd02b5249480864c3101d31d\n",
+ "controls= ['pernode']\n",
+ "methods= [{'name': 'graph_koopman', 'role': 'koopman'}, {'name': 'pernode_ls', 'role': 'control'}]\n",
+ "wrong-payload run exit= 1\n",
+ "stale-digest verify exit= 1\n",
+ "rewritten executed= True\n",
+ "Live package_version may differ from a checked-in summary; verify does not require those versions to match.\n",
+ "cleaned workdir\n"
+ ]
+ }
+ ],
+ "source": [
+ "rows = [\n",
+ " (\"smoke_telemetry (live run)\", live),\n",
+ " (\n",
+ " \"smoke_telemetry (checked-in)\",\n",
+ " json.loads(\n",
+ " (SMOKE / \"summaries\" / \"smoke_telemetry.json\").read_text(\n",
+ " encoding=\"utf-8\"\n",
+ " )\n",
+ " ),\n",
+ " ),\n",
+ "]\n",
+ "print(f\"{'source':<32} {'executed':<10} {'summary_sha256'}\")\n",
+ "for name, payload in rows:\n",
+ " print(f\"{name:<32} {str(payload['executed']):<10} {payload['summary_sha256']}\")\n",
+ "print(\"controls=\", live[\"controls\"])\n",
+ "print(\"methods=\", live[\"methods\"])\n",
+ "print(\"wrong-payload run exit=\", wrong.returncode)\n",
+ "print(\"stale-digest verify exit=\", failed.returncode)\n",
+ "print(\"rewritten executed=\", rewritten_ok.executed)\n",
+ "print(\n",
+ " \"Live package_version may differ from a checked-in summary; \"\n",
+ " \"verify does not require those versions to match.\"\n",
+ ")\n",
+ "shutil.rmtree(WORKDIR, ignore_errors=True)\n",
+ "print(\"cleaned workdir\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t47-interpret",
+ "metadata": {},
+ "source": [
+ "## Interpretation / discussion\n",
+ "\n",
+ "A passing `verify` means the stored `summary_sha256` matches the\n",
+ "canonical body and the identity fields still bind to the loaded\n",
+ "manifest (including `dataset_sha256` and `manifest_sha256`). It is\n",
+ "not evidence of forecast skill and is not a LibCity (Wang et al.,\n",
+ "2021) or BasicTS (Shao et al., 2025) leaderboard result. Live `run`\n",
+ "records the installed `koopman_graph` version in `package_version`;\n",
+ "that field participates in the digest, so a version bump changes\n",
+ "`summary_sha256` without invalidating an older checked-in file.\n",
+ "`verify` does not require those versions to match and does not\n",
+ "require `executed=False`. Method execution (actual MAE / RMSE)\n",
+ "remains a later increment.\n",
+ "\n",
+ "Use example 22 for the METR-LA teaching-baseline comparison of Li et\n",
+ "al. (2018) (unequal budgets, not dedicated-library SOTA). Use\n",
+ "examples 37 and 38 for transfer and factorization ablations,\n",
+ "including negative outcomes.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t47-takeaways",
+ "metadata": {},
+ "source": [
+ "## Takeaways\n",
+ "\n",
+ "- Reach for `koopman-graph benchmark run` / `verify` when you need a\n",
+ " frozen, tamper-evident protocol hash.\n",
+ "- YAML manifests need `pip install \"koopman-graph[cli]\"`; JSON does not.\n",
+ "- `run` writes `executed=False` (no training and no invented scores).\n",
+ " `verify` rejects an inconsistent digest or unbound identity fields;\n",
+ " it does not enforce `executed=False`.\n",
+ "- `run` rejects a `--data` payload whose SHA-256 does not match\n",
+ " `dataset.sha256`.\n",
+ "- Do not download METR-LA for this notebook; use `benchmarks/v0.15/`.\n",
+ "- Do not read declared `metrics` names as reported MAE / RMSE.\n",
+ "- The live digest includes `package_version`; stored outputs here are\n",
+ " teaching evidence for 0.15.0.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t47-read",
+ "metadata": {},
+ "source": [
+ "## Further reading\n",
+ "\n",
+ "- Sphinx: [Identity-bound benchmarks](https://koopmangraph.readthedocs.io/en/latest/benchmarks.html),\n",
+ " [Command-line interface](https://koopmangraph.readthedocs.io/en/latest/cli.html),\n",
+ " [API reference](https://koopmangraph.readthedocs.io/en/latest/api.html)\n",
+ " (`ExperimentManifest`, `run_manifest`, `verify_summary`; deep import,\n",
+ " not the root façade)\n",
+ "- Tracked fixtures: [`benchmarks/v0.15/README.md`](../benchmarks/v0.15/README.md)\n",
+ "- Wang et al. (2021), LibCity\n",
+ " ([doi:10.1145/3474717.3483923](https://doi.org/10.1145/3474717.3483923))\n",
+ "- Shao et al. (2025), BasicTS\n",
+ " ([doi:10.1109/TKDE.2024.3484454](https://doi.org/10.1109/TKDE.2024.3484454))\n",
+ "- Li et al. (2018), DCRNN / METR-LA\n",
+ " ([OpenReview](https://openreview.net/forum?id=SJiHXGWAZ))\n",
+ "- Related notebooks:\n",
+ " [`22_gnn_forecaster_comparison.ipynb`](22_gnn_forecaster_comparison.ipynb)\n",
+ " (METR-LA teaching baselines),\n",
+ " [`37_cross_topology_transfer.ipynb`](37_cross_topology_transfer.ipynb),\n",
+ " [`38_operator_factorization_ablation.ipynb`](38_operator_factorization_ablation.ipynb)\n"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "koopmangraph",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/48_identification_invariance.ipynb b/examples/48_identification_invariance.ipynb
new file mode 100644
index 0000000..da1a1ad
--- /dev/null
+++ b/examples/48_identification_invariance.ipynb
@@ -0,0 +1,690 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "t48-title",
+ "metadata": {},
+ "source": [
+ "# Identification reports, residual-aware gating, and finite-sample invariance\n",
+ "\n",
+ "This notebook fills an opt-in\n",
+ "`IdentificationReport` on a linear Gaussian per-node\n",
+ "trajectory, rejects a polluted RMSE-only dictionary with\n",
+ "`select_resdmd_gated`, and reads finite-sample leakage $\\eta$\n",
+ "on a rank-deficient line. One-step and short-rollout mean\n",
+ "squared error (MSE) are both near zero when $K$ is recovered\n",
+ "from consecutive states.\n",
+ "\n",
+ "**Honesty.** Default `fit(..., identification=None)` remains Adam and\n",
+ "does not import `koopman_graph.identification`. The report records\n",
+ "finite-sample latent MSE and $\\rho(K)$, not a Haseli–Cortés\n",
+ "certificate and not a certified ResDMD bound. Discrete dense per-node\n",
+ "$K$ only. Residual cutoff default is $10^{-2}$. $\\eta$ is a\n",
+ "truncated-SVD projection residual; when numerical rank equals the\n",
+ "ambient latent width it is identically zero. This notebook does\n",
+ "**not** download METR-LA.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t48-setup-md",
+ "metadata": {},
+ "source": [
+ "## Setup\n",
+ "\n",
+ "Package version, identity encoder/decoder, a shared $2\\times 2$\n",
+ "map, and the identification helpers (off the root façade).\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "t48-setup",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T18:39:20.476007Z",
+ "iopub.status.busy": "2026-08-20T18:39:20.475894Z",
+ "iopub.status.idle": "2026-08-20T18:39:23.291365Z",
+ "shell.execute_reply": "2026-08-20T18:39:23.290959Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "koopman_graph 0.15.0\n",
+ "DEFAULT_RESDMD_GATE_TOLERANCE 0.01\n"
+ ]
+ }
+ ],
+ "source": [
+ "from __future__ import annotations\n",
+ "\n",
+ "import os\n",
+ "import warnings\n",
+ "\n",
+ "from tqdm.std import TqdmWarning\n",
+ "\n",
+ "warnings.filterwarnings(\"ignore\", category=TqdmWarning)\n",
+ "\n",
+ "import matplotlib\n",
+ "\n",
+ "if os.environ.get(\"PYTEST_CURRENT_TEST\"):\n",
+ " matplotlib.use(\"Agg\")\n",
+ "\n",
+ "import matplotlib.pyplot as plt\n",
+ "import torch\n",
+ "from IPython.display import display\n",
+ "from torch import Tensor, nn\n",
+ "from torch_geometric.data import Data\n",
+ "\n",
+ "try:\n",
+ " from IPython import get_ipython\n",
+ "\n",
+ " if get_ipython() is not None and not os.environ.get(\"PYTEST_CURRENT_TEST\"):\n",
+ " get_ipython().run_line_magic(\"matplotlib\", \"inline\")\n",
+ "except (ImportError, NameError):\n",
+ " pass\n",
+ "\n",
+ "import koopman_graph\n",
+ "from koopman_graph.analysis.resdmd import resdmd\n",
+ "from koopman_graph.data import GraphSnapshotSequence\n",
+ "from koopman_graph.identification import (\n",
+ " DEFAULT_RESDMD_GATE_TOLERANCE,\n",
+ " IdentificationConfig,\n",
+ " IdentificationReport,\n",
+ " LatentPairs,\n",
+ " ResDMDGateCandidate,\n",
+ " build_identification_report,\n",
+ " identify_operator,\n",
+ " select_resdmd_gated,\n",
+ " subspace_invariance_report,\n",
+ ")\n",
+ "from koopman_graph.model import GraphKoopmanModel\n",
+ "\n",
+ "SEED = 0\n",
+ "TRUE_K = torch.tensor([[0.9, 0.15], [-0.05, 0.55]], dtype=torch.float64)\n",
+ "LINE_DIR = torch.tensor([1.0, 2.0], dtype=torch.float64)\n",
+ "SCALE_K = 0.5 * torch.eye(2, dtype=torch.float64)\n",
+ "LEAVE_K = torch.tensor([[0.0, -1.0], [1.0, 0.0]], dtype=torch.float64)\n",
+ "\n",
+ "\n",
+ "class IdentityCodec(nn.Module):\n",
+ " def __init__(self, dim: int) -> None:\n",
+ " super().__init__()\n",
+ " self.in_channels = dim\n",
+ " self.latent_dim = dim\n",
+ " self.out_channels = dim\n",
+ " self.num_layers = 1\n",
+ "\n",
+ " def forward(self, x_or_data, edge_index=None, edge_weight=None):\n",
+ " del edge_index, edge_weight\n",
+ " if isinstance(x_or_data, Data):\n",
+ " return x_or_data.x\n",
+ " return x_or_data\n",
+ "\n",
+ "\n",
+ "def linear_sequence(true_k: Tensor, *, n_nodes: int = 4, n_times: int = 16):\n",
+ " dim = true_k.shape[0]\n",
+ " generator = torch.Generator().manual_seed(SEED)\n",
+ " state = torch.randn(n_nodes, dim, dtype=true_k.dtype, generator=generator)\n",
+ " src = list(range(n_nodes - 1))\n",
+ " dst = list(range(1, n_nodes))\n",
+ " edge_index = torch.tensor([src + dst, dst + src], dtype=torch.long)\n",
+ " snapshots = []\n",
+ " for _ in range(n_times):\n",
+ " snapshots.append(Data(x=state.clone(), edge_index=edge_index))\n",
+ " state = state @ true_k.T\n",
+ " return GraphSnapshotSequence(snapshots)\n",
+ "\n",
+ "\n",
+ "def trajectory_pairs(true_k: Tensor, *, n_times: int = 24, seed: int = SEED):\n",
+ " generator = torch.Generator().manual_seed(seed)\n",
+ " state = torch.randn(true_k.shape[0], dtype=true_k.dtype, generator=generator)\n",
+ " rows = [state.clone()]\n",
+ " for _ in range(n_times - 1):\n",
+ " state = state @ true_k.T\n",
+ " rows.append(state.clone())\n",
+ " traj = torch.stack(rows, dim=0)\n",
+ " return traj, LatentPairs(z_t=traj[:-1], z_next=traj[1:])\n",
+ "\n",
+ "\n",
+ "def rank1_line(*, n_times: int = 16) -> Tensor:\n",
+ " amplitude = torch.linspace(0.2, 1.6, n_times, dtype=torch.float64)\n",
+ " return amplitude.unsqueeze(1) * LINE_DIR\n",
+ "\n",
+ "\n",
+ "def one_step_mse(psi0: Tensor, psi1: Tensor) -> float:\n",
+ " solution = torch.linalg.lstsq(psi0, psi1).solution\n",
+ " return float((psi0 @ solution - psi1).square().mean().item())\n",
+ "\n",
+ "\n",
+ "def residual_max(psi0: Tensor, psi1: Tensor) -> float:\n",
+ " return float(\n",
+ " resdmd(psi0, psi1, tolerance=DEFAULT_RESDMD_GATE_TOLERANCE)\n",
+ " .residuals.max()\n",
+ " .real.item()\n",
+ " )\n",
+ "\n",
+ "\n",
+ "print(\"koopman_graph\", koopman_graph.__version__)\n",
+ "print(\"DEFAULT_RESDMD_GATE_TOLERANCE\", DEFAULT_RESDMD_GATE_TOLERANCE)\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t48-motivation",
+ "metadata": {},
+ "source": [
+ "## Motivation and background\n",
+ "\n",
+ "Default `fit` writes $K$ with Adam. Closed-form ridge (Williams,\n",
+ "Kevrekidis, and Rowley, 2015) can instead write a dense per-node\n",
+ "$K$ on frozen encodings when the caller passes\n",
+ "`IdentificationConfig`. Two failure modes remain.\n",
+ "\n",
+ "First, RMSE-only dictionary selection can prefer a lift that\n",
+ "concatenates a leaked future coordinate: train one-step MSE\n",
+ "falls while the dictionary is no longer approximately invariant.\n",
+ "Finite-dictionary residual dynamic mode decomposition\n",
+ "(ResDMD) residuals (Colbrook and Townsend, *Comm. Pure Appl.\n",
+ "Math.*, 2023/2024) are a gate on that pollution, not an\n",
+ "infinite-dimensional spectral measure.\n",
+ "\n",
+ "Second, `fit` does not fill `IdentificationReport.invariance`.\n",
+ "Finite-sample leakage $\\eta$ is the mean residual of $K\\psi$\n",
+ "off the truncated-SVD span of the encodings, divided by the\n",
+ "mean $\\|K\\psi\\|_2$. It is not the Haseli–Cortés\n",
+ "invariance-proximity certificate (principal angles / worst-case\n",
+ "bound; arXiv:2311.13033). When that span is the whole ambient\n",
+ "$\\mathbb{R}^{d}$, $P=I$ and $\\eta=0$ by construction.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t48-minimal-md",
+ "metadata": {},
+ "source": [
+ "## Minimal example\n",
+ "\n",
+ "Identify $K$ on consecutive states of one linear trajectory\n",
+ "(`LatentPairs` with time along axis 0). One-step and\n",
+ "short-rollout MSE should both be near zero; $\\rho(K)$ should\n",
+ "match the oracle. This is not an i.i.d. cloud:\n",
+ "`build_identification_report` treats a 2-D pair's sample axis\n",
+ "as time.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "t48-minimal",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T18:39:23.292593Z",
+ "iopub.status.busy": "2026-08-20T18:39:23.292469Z",
+ "iopub.status.idle": "2026-08-20T18:39:23.327023Z",
+ "shell.execute_reply": "2026-08-20T18:39:23.326634Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "one_step.mse 4.924859309557061e-33\n",
+ "rollout.mse 8.283424690809502e-32\n",
+ "rho(K) 0.8770690632574556\n",
+ "residual_max 3.398598988250878e-08\n"
+ ]
+ }
+ ],
+ "source": [
+ "_traj, pairs = trajectory_pairs(TRUE_K, n_times=24)\n",
+ "snapshot = identify_operator(\n",
+ " pairs, IdentificationConfig(solver=\"ridge\", ridge=0.0)\n",
+ ")\n",
+ "report = build_identification_report(pairs, snapshot, gate_resdmd=True)\n",
+ "assert isinstance(report, IdentificationReport)\n",
+ "assert report.one_step.mse is not None\n",
+ "assert report.rollout.mse is not None\n",
+ "assert report.stability.spectral_radius is not None\n",
+ "assert report.spectral.residual_max is not None\n",
+ "assert report.spectral.polluted is False\n",
+ "print(\"one_step.mse\", report.one_step.mse)\n",
+ "print(\"rollout.mse\", report.rollout.mse)\n",
+ "print(\"rho(K)\", report.stability.spectral_radius)\n",
+ "print(\"residual_max\", report.spectral.residual_max)\n",
+ "assert report.one_step.mse < 1e-20\n",
+ "assert report.rollout.mse < 1e-20\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t48-deep-md",
+ "metadata": {},
+ "source": [
+ "## Progressive deep dive\n",
+ "\n",
+ "Leakage $\\eta$ is informative only when the encoding rank is\n",
+ "smaller than the ambient width. On a rank-1 line in\n",
+ "$\\mathbb{R}^{2}$, a scaling map stays in span and a rotation\n",
+ "leaves it. `held_out=False` uses the whole line so the\n",
+ "contrast is not a hold-out artifact.\n",
+ "\n",
+ "A polluted lift that concatenates a leaked future first\n",
+ "coordinate then wins train one-step MSE.\n",
+ "`select_resdmd_gated` drops that candidate when the max\n",
+ "residual exceeds $10^{-2}$. The leaked coordinate uses a true\n",
+ "$t+2$ target (pairs that lack a successor are dropped; no\n",
+ "wrap-around pad).\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "t48-deep-eta",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T18:39:23.328384Z",
+ "iopub.status.busy": "2026-08-20T18:39:23.328313Z",
+ "iopub.status.idle": "2026-08-20T18:39:23.331958Z",
+ "shell.execute_reply": "2026-08-20T18:39:23.331613Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "scale leakage 1.878117727256971e-16 rank 1\n",
+ "rotation leakage 1.0 rank 1\n"
+ ]
+ }
+ ],
+ "source": [
+ "line = rank1_line(n_times=16)\n",
+ "eta_scale = subspace_invariance_report(line, SCALE_K, held_out=False)\n",
+ "eta_leave = subspace_invariance_report(line, LEAVE_K, held_out=False)\n",
+ "print(\"scale leakage\", eta_scale.leakage, \"rank\", eta_scale.rank)\n",
+ "print(\"rotation leakage\", eta_leave.leakage, \"rank\", eta_leave.rank)\n",
+ "assert eta_scale.rank == 1\n",
+ "assert eta_leave.rank == 1\n",
+ "assert eta_scale.leakage < 1e-12\n",
+ "assert abs(eta_leave.leakage - 1.0) < 1e-8\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "1334f6ee",
+ "metadata": {},
+ "source": [
+ "The rank-1 line is the case where $\\eta$ can fail. The next\n",
+ "cell scores a leaked-future dictionary against the identity\n",
+ "lift."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "t48-deep-gate",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T18:39:23.333020Z",
+ "iopub.status.busy": "2026-08-20T18:39:23.332949Z",
+ "iopub.status.idle": "2026-08-20T18:39:23.337583Z",
+ "shell.execute_reply": "2026-08-20T18:39:23.337223Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "identity mse 9.726491598197258e-09 residual_max 0.00414914219142909\n",
+ "polluted mse 6.065363689866264e-09 residual_max 0.8553478893997141\n",
+ "RMSE-only selected identity_plus_junk\n",
+ "gated selected identity rejected ('identity_plus_junk',)\n"
+ ]
+ }
+ ],
+ "source": [
+ "def noisy_traj(true_k: Tensor, *, n_times: int, noise: float, seed: int) -> Tensor:\n",
+ " generator = torch.Generator().manual_seed(seed)\n",
+ " state = torch.tensor([0.4, -0.2], dtype=true_k.dtype)\n",
+ " rows = []\n",
+ " for _ in range(n_times):\n",
+ " rows.append(state.clone())\n",
+ " state = state @ true_k.T + noise * torch.randn(\n",
+ " 2, dtype=true_k.dtype, generator=generator\n",
+ " )\n",
+ " return torch.stack(rows, dim=0)\n",
+ "\n",
+ "\n",
+ "traj = noisy_traj(TRUE_K, n_times=48, noise=1e-4, seed=0)\n",
+ "psi0, psi1 = traj[:-2], traj[1:-1]\n",
+ "junk0 = traj[1:-1, :1]\n",
+ "junk1 = traj[2:, :1]\n",
+ "pol0 = torch.cat([psi0, junk0], dim=1)\n",
+ "pol1 = torch.cat([psi1, junk1], dim=1)\n",
+ "clean = ResDMDGateCandidate(\n",
+ " name=\"identity\",\n",
+ " mse=one_step_mse(psi0, psi1),\n",
+ " residual_max=residual_max(psi0, psi1),\n",
+ ")\n",
+ "polluted = ResDMDGateCandidate(\n",
+ " name=\"identity_plus_junk\",\n",
+ " mse=one_step_mse(pol0, pol1),\n",
+ " residual_max=residual_max(pol0, pol1),\n",
+ ")\n",
+ "print(\"identity mse\", clean.mse, \"residual_max\", clean.residual_max)\n",
+ "print(\"polluted mse\", polluted.mse, \"residual_max\", polluted.residual_max)\n",
+ "assert polluted.mse < clean.mse\n",
+ "assert clean.residual_max <= DEFAULT_RESDMD_GATE_TOLERANCE\n",
+ "assert polluted.residual_max > DEFAULT_RESDMD_GATE_TOLERANCE\n",
+ "rmse_only = select_resdmd_gated((clean, polluted), gate_resdmd=False)\n",
+ "gated = select_resdmd_gated((clean, polluted), gate_resdmd=True)\n",
+ "print(\"RMSE-only selected\", rmse_only.selected)\n",
+ "print(\"gated selected\", gated.selected, \"rejected\", gated.rejected_alternatives)\n",
+ "assert rmse_only.selected == \"identity_plus_junk\"\n",
+ "assert gated.selected == \"identity\"\n",
+ "assert gated.rejected_alternatives == (\"identity_plus_junk\",)\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t48-results-md",
+ "metadata": {},
+ "source": [
+ "## Results\n",
+ "\n",
+ "Opt-in `fit(..., identification=IdentificationConfig(...))`\n",
+ "writes the same report on the identity-codec graph oracle.\n",
+ "Default Adam leaves `identification_report` unset.\n",
+ "`evaluate(..., include_invariance=True)` attaches $\\eta$ on\n",
+ "the encoded cloud; here rank equals `latent_dim`, so the\n",
+ "leakage is machine zero (the tautology from Motivation).\n",
+ "\n",
+ "Figure 1 (left) shows one-step MSE: the polluted dictionary is\n",
+ "the RMSE-only winner. Figure 1 (right) shows the max ResDMD\n",
+ "residual with both values labeled: only the leaked lift\n",
+ "exceeds the $10^{-2}$ cutoff (dashed). Figure 2 shows $\\eta$\n",
+ "on the rank-1 line (scale vs rotation) and on the full-rank\n",
+ "`evaluate` cloud.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "t48-results-fit",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T18:39:23.338564Z",
+ "iopub.status.busy": "2026-08-20T18:39:23.338503Z",
+ "iopub.status.idle": "2026-08-20T18:39:23.383378Z",
+ "shell.execute_reply": "2026-08-20T18:39:23.382952Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "fit epochs 2\n",
+ "fit one_step.mse 1.057457916267142e-31\n",
+ "fit residual_max 0.0\n",
+ "evaluate eta 1.1114968235444816e-16 rank 2 held_out True\n",
+ "default Adam identification_report None\n"
+ ]
+ }
+ ],
+ "source": [
+ "sequence = linear_sequence(TRUE_K)\n",
+ "model = GraphKoopmanModel(\n",
+ " encoder=IdentityCodec(2),\n",
+ " decoder=IdentityCodec(2),\n",
+ " latent_dim=2,\n",
+ " time_step=1.0,\n",
+ " koopman_init_mode=\"identity\",\n",
+ " koopman_init_scale=0.0,\n",
+ ").to(dtype=torch.float64)\n",
+ "history = model.fit(\n",
+ " sequence,\n",
+ " epochs=2,\n",
+ " identification=IdentificationConfig(solver=\"ridge\", ridge=0.0, gate_resdmd=True),\n",
+ ")\n",
+ "fit_report = model.identification_report\n",
+ "assert isinstance(fit_report, IdentificationReport)\n",
+ "assert fit_report.one_step.mse is not None\n",
+ "assert fit_report.spectral.residual_max is not None\n",
+ "assert fit_report.spectral.polluted is False\n",
+ "print(\"fit epochs\", history.epochs)\n",
+ "print(\"fit one_step.mse\", fit_report.one_step.mse)\n",
+ "print(\"fit residual_max\", fit_report.spectral.residual_max)\n",
+ "\n",
+ "eval_result = model.evaluate(\n",
+ " sequence, horizons=(3, 6, 12), include_invariance=True\n",
+ ")\n",
+ "assert eval_result.invariance is not None\n",
+ "print(\n",
+ " \"evaluate eta\",\n",
+ " eval_result.invariance.leakage,\n",
+ " \"rank\",\n",
+ " eval_result.invariance.rank,\n",
+ " \"held_out\",\n",
+ " eval_result.invariance.held_out,\n",
+ ")\n",
+ "assert eval_result.invariance.rank == 2\n",
+ "assert eval_result.invariance.leakage < 1e-12\n",
+ "\n",
+ "adam = GraphKoopmanModel(\n",
+ " encoder=IdentityCodec(2),\n",
+ " decoder=IdentityCodec(2),\n",
+ " latent_dim=2,\n",
+ " time_step=1.0,\n",
+ " koopman_init_mode=\"identity\",\n",
+ " koopman_init_scale=0.0,\n",
+ ").to(dtype=torch.float64)\n",
+ "adam.fit(sequence, epochs=1, lr=1e-2)\n",
+ "assert adam.identification_report is None\n",
+ "print(\"default Adam identification_report\", adam.identification_report)\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5440ec8a",
+ "metadata": {},
+ "source": [
+ "Figure 1 uses the gate scores from the deep dive. Figure 2\n",
+ "uses the rank-1 leakages and the `evaluate` cloud."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "t48-results-fig",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T18:39:23.384775Z",
+ "iopub.status.busy": "2026-08-20T18:39:23.384688Z",
+ "iopub.status.idle": "2026-08-20T18:39:23.676647Z",
+ "shell.execute_reply": "2026-08-20T18:39:23.676169Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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ZUH2qcGDB9vwyJGjSF2hgrpZXLbgd/19tFqgdUJroTCFQdz+fT6x+FqxLaeYuehSWJJe9DP18/OTwMBAVcsRVy2HLkuU/aLkae3YDvbeBSK7nxdTyrFAhqcbN26oC2h0caKUhZ8/pVQv41BGcCQKERERERElGAMLIiIiIiJKMAYWRERERESUYAwsiIiIiIgowRhYEBERERFRgjGwICIiioMUlkyR/sPPnSh2DCyIiIisgFmqAXMmUMqjfe7a94CIouMEeURERFZwc3OTdOnSqVmhwdvbW1z+m+2XnLulAkEFPnd8/vgeEJF5DCyIiIislDVrVvVXCy4o5UBQoX3+RGQeAwsiIiIroYUiW7ZskjlzZomIiOBxSyHQ/YktFUSxY2BBREQUR7jI5IUmEZExBhZERERETuhexw723gVyIDmWLkv012BWKCIiIiIiSjAGFkRERERElGAMLIiIiIiIKMEYWBARERERUYIxsCAiIiIiogRjYEFERERERAnGwIKIiIiIiBKM81gQEVGCLF++XK5fv27Vuh06dJB8+fJZte6rV69k5syZcv78eTXbda9evSR//vwxPufKlSuycOFCuXPnjqRPn15atGghdevWter1iIgoYRhYEBFRguzdu1f2799v1bq1a9e2KrAIDg6W6tWrS+rUqaVr166ya9cuKVeunBw4cECKFy9u9jkHDx6UOnXqqOAFf69evSpNmzaVcePGyeeffx7n90VERMkssLh7967s2bNHvLy8pF69euLn5xfrc86cOaNqsDw8PKRChQqSO3fuJNlXIiKKbtq0aTY/LL/99pvcvn1bbt26pcqFPn36qKBk2LBhsmrVKrPPWbx4sRQpUkQWLFhg1OoxZ84cBhZERM4+xuL333/XFwKoUSpYsKAcPXrU4vqRkZGqJqpmzZqycuVKmT17thQuXFjGjh2bpPtNRESJa8OGDdKoUSN9ZZOLi4u8++67smnTJlUWmINuUk+fPpWgoCD9Yzdv3lRlCxEROXGLxcOHD+Xjjz+WCRMmSL9+/dRjnTp1km7dusnZs2fNPmfHjh2qL+/JkyelTJky6jH0v8Xz+/btK2nTpk3S90BEROaFhISoi3rDi3xAZZA15+pr165J27ZtjR7LkyePhIaGyoMHDyRnzpzRntO/f38JCAiQUqVKSenSpdW4j6JFi8rPP/9s8XXCwsLU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yDnFObCIioqSoKAg9f/t27dV/9kqVaqo+56enhIZGWn7vSQiIoeGyVHTpUsX4zpYjm60RETknOIcWNSsWVPWrFkjY8aMUa0UmBApVapU+pm3q1evnhj7SUREDsyaSiWkJGflExGR84pzYIFsUL/99pts2rRJfH19ZfLkyerxkydPqtqo2rVrJ8Z+EhGRg2vbtq2as8LSDYO7iYjIecVr5m3MYWE6j0XZsmVl1apVttovIiJKRvr27atasGNTvHjxJNkfIiJKJoGFoT///FNevHghvXv3ts0eERFRstOyZUt77wIRESW3rlCmbt26JZcvX7bN3hARUbK3dOlS/SBtZIFq1aqVNG/eXE6fPm3vXSMiIkcOLIiIiDRIQY7AIm3atOo+WrNDQ0PF399fWrduzcHbREROLMFdoTw8PFSaWSIiIoy1e/vtt9WBePr0qRw6dEgePXqkkn0ULVpUzp07p59glYiInEuCA4tPP/3UNntCREROMZ+FloJ8586datI8BBWQMWNGtdxaCEymTJki58+fl2zZssknn3wipUqVsuq5mGepT58+kj17dpk1a1Y83w0RESVJYBEREaH6znp7e0vevHnjuxnZsGGDbNu2Tby8vOSdd96RcuXKWf3c0aNHy4MHD9TM37FNzERERImvYsWK8sMPP6hzMs7NHTt21JcZGI+H2betgQCkatWqkitXLvnwww/1QcrevXulfPnyMT4Xc2W899578vjxY7l27ZpN3hcRESXSGAtMkJcjRw4pUaKE/PTTT+qxU6dOSf369eO0ncGDB0uXLl0kderUEhAQoAoNZJmyxrRp0+SXX36RX3/9VV6/fh2ft0FERDaGVoWcOXNKp06dJHPmzOo+zJw5Uz3m5+dn1Xaw/vPnz9WYDQQJmD8JE7AOHz481ueOGDFClVHt2rVL8PshIqJEbLG4d++edOvWTaZPn65qgrRm7TJlyqjmb0yA1KBBg1i3c+nSJTW53t9//63PfY7m8v79+6tJlrSmdHMwGd/EiRPV7f3334/rWyAiokSCifD++uuvaI+jW5Kbm5vV28EkrI0aNVKt4hqM3fjss8/kzZs3FsuI7du3y+LFi+XEiRMyfvz4eL4LIiJKkhYLNEfjZI+WBhQghipVqiR79uyxajuohUJTedOmTfWPYZsY5Hf48GGLzwsKClJN62gpyZo1a1x3n4iI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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "image/png": "iVBORw0KGgoAAAANSUhEUgAAAsYAAAE2CAYAAABiElQxAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlcelbwAAAAlwSFlzAAAPYQAAD2EBqD+naQAANYRJREFUeJzt3QeYE+X69/F7AekdAWlKFRVUmoKggBSVakFpFgRR4NgQDiJY0KOIiGLvHizHAjZALIgIolQRkCagIkovAgLSS97r9+Sd/JNsNpvt2d3v57py7WaSmTwzmcncc889zyT4fD6fAQAAALlcnqxuAAAAABAPCIwBAAAAAmMAAADAj4wxAAAAQGAMAAAA+JExBgAAAAiMAQAAAD8yxgAAAACBMQAAAOBHxhhAQM2aNa179+4skQh27NhhXbt2tdKlS1tCQoI98sgj9u2337r/p06dmqplNn/+fDf+Z599xjLP5GWVGcte64U+Q+sJgOyBwBjIJW677Ta3k470+Pe//205wT///GOvvfaaXXzxxVa2bFkXxDZt2tTGjx9vPp8vTdMeMmSILVmyxJYtW+amdd9991lGSGuwDQBIvXxpGBdANrRlyxY75ZRTIr7222+/WXb2xBNP2JgxY2zEiBEuGM6XL5+98cYb1rNnT/vxxx/d66k1ffp069Chg1WuXDkwrGXLlmkKuJs0aZLmgB0AkH4IjAHkGFWqVLEffvjB6tSpEximbPiiRYvs6aeftmHDhlmZMmVSNe3t27dboUKF0rG1AIB4QykFgKg1xocPH3ZlBBUqVLAiRYpYmzZt7JdffnHZUmU8PYcOHXIlAA8++GCiJXrhhRe6R7CTTz7Z+vbtawsWLHCvKej0yhP27dtnd999t9WoUcPy58/vMtz9+vWzXbt2Rf22brrpppCgOHi+jh8/buvXrw8ZXrduXdeOaBRQa76OHj1qzzzzTMRSlOCyh+C60hdeeMGqVq1qBQsWdCUdixcvjlrn+uabb7oyEGnXrl1g+mqDJ7XLRo4cOWL333+/1apVywoXLmynnXaa3XDDDfb7778H3vPHH3+EzFuBAgXszDPPtFGjRrllGGk+laXXQUnx4sXd+qM2yuOPP26nnnqq+241PzpbESx4GnqvsvF6b/Pmzd0BTizSsjxSO71Yl1Eke/futcsuu8wdoM2aNcsNe/7550Omp+WoZfD5558nGv/jjz+2c845x61TtWvXtnfffddefvllN57aFeznn392dfEqK9K8nHHGGfbUU09xlgKIgsAYQFS9evWyV155xQWFCmwee+wxu+uuu1w9b1r9+eef7iK2V1991ZVxNGrUyPbv3++CAgUAL730ku3cudMFUCqFaNWqlQvUU0KlCl988YWddNJJVq1atRS3ceDAgW4aefPmtTvvvNP97z1mzpyZ5HiqdVaQtXDhQlu5cqUdO3bMrrjiChecJuXGG28MTPPLL78MfI7aIGldNvfee6+9+OKLLpD666+/bN68eS5Ie/bZZwPvUSAfPI9bt251BywjR450gV84TU/LZunSpS7A/f77723AgAFuPVGwprpsfc6KFSvslltuidgufb6Wj967fPlyK1asmDtA0HKLJr3XlVinl9JlFLy+6wBJByJaJi1atAjU/3vTUmCtZXXeeee59UXrj2fixIl2zTXXuINSTeObb76xuXPnRqxH11mS888/3w4ePOi+F83Lo48+av/5z3/cgS6AJPgA5Aq33nqrilkjPo4ePereU6NGDV+3bt0C4yxZssS9Pnr06JBpLV++3JeQkOBr3LhxYNjBgwfde0eMGJHos5s1a+YewcqUKeMrVKiQb8eOHSHDR44c6aazePHikOG//PKLL0+ePL7XXnstRfP99NNPu+nddtttvrTImzev78477wwZNnPmTDftL7/8MjBM/2tYz549Q977zTffuOGTJ08ODJs3b54bNmXKlKjTTK9l06hRI1+nTp18qTFw4EBf5cqVE83nddddF/K+Rx991C2rG2+8MWS41iGtM1u3bk00jS5duoS8d+/evb5SpUr5rrrqqqjLKi3LI72nF20Z6TvV55UrV87XvHlz386dO32xqFmzpq9///6B57Vr1/Y1aNAg0fvOPvts9znr1q0LDLvgggt81atX9x06dCjR9qDvZ8OGDTG1AchtyBgDuYyyvsHZLj10kVokXvayU6dOiUoQUpN9DafsWXgpw5QpU1zpQ/369UOG6/S/Tst7p59joayrsmP16tWz0aNHW2bShXrhy0yCyxZSKtZlE17qoeytnHvuufbVV1/Zww8/bKtWrUrylLpOz6u8pUSJEiHlHBs3bnQlM8FUIhFMp+uV9fRKQjwqNdDnrVu3LtHnde7cOeS5MsatW7e2GTNmpMvyiFVKppeS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0jIrAAAAyOVSlTH++eefrU2bNlasWDG755573DAFqkOGDIl5Guq94osvvrCePXu6oFjUw0XLli1t0qRJSY734osv2rXXXhty1z31pVyuXLnUzAoAAACQusB47969dskll1jdunVdba9HN/mYNWuW/fTTTzFNR30gHz58ONFtpfVcJRZJffYvv/xizZo1s48++siGDRvmMsibN2+O+ln6HI0b/AAAAADSFBjrNtBnnXWWPf3003bmmWeGvNa8eXOXBY6FV/qgrHOw4sWLJ1kW4QW0uoHIG2+84cZVe2rXrm3z589P8rNGjRplJUqUCDyqVKkSUxsBAACQe6S4OHjTpk1Wq1atwO2hQyaWL5+7GC4WXkD8999/hwzfvXt3omA5fByVTXz++eeB4R06dLB7773Xvvnmm4jjKbM8aNCgkACb4BgAAABpyhiffvrpNnfu3ESBsQJi3RL6nHPOiWk6qidWbfDq1atDhuu5MtKRKNtbqVIla9iwYchwPf/999+T/KwCBQq4THTwAwAAAEhTYKyL7pQZvuqqq2zx4sX2559/2ksvvWSNGjVyw4NvDx2NLrjr0qWLvfnmm3bo0CE3bNmyZTZnzhzr2rVr4H0TJkyw0aNHB5736NHDZs6cGejSTd23qT9j1TgDAAAAqZXgU59rKbRr1y7XA8WHH35o+/btcxnZjh072rPPPmsVK1aMeTpbt261Fi1aWP78+a1+/fquPlllEW+99VbgPX379nX1wytWrAiUQbRt29ZlqJs0aWILFy60PXv2uFrj6tWrx/S5moayzxqP7DEQ/zZ175bVTQASqTR+AksFyAZSEvelKjD2aFR9mEoi8ubNm6ppKMBVP8bene8uvPDCkNcV8CqAvu666wLDjh07Zl9//bXLVqsko3Xr1i44jxWBMZC9EBgjHhEYA9lDpgXG2RWBMZC9EBgjHhEYAzkv7ktxjbHKJZQhjvTQh6m3B9UI6yYgAAAAQI7trk21xKoBVo8UvXr1cjXFO3bscLdxVldu6jZNtceqA165cqWVLFkyY1oOAAAAZGVg/Ndff7kaX130povmPLfccour9S1btqy7iE43+5g4caL17t07PdsLAAAAZIgUl1Lols/qDSI4KHYTypPHLrroIluyZInLJrdq1cpdHAcAAADkyMBYpRG6w5x3e2aP+iJWprhUqVLu+fr1693NQAAA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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "fig, axes = plt.subplots(1, 2, figsize=(8.0, 3.2))\n",
+ "names = (clean.name, polluted.name)\n",
+ "axes[0].bar(names, (clean.mse, polluted.mse), color=(\"#4c78a8\", \"#e45756\"))\n",
+ "axes[0].set_ylabel(\"one-step MSE\")\n",
+ "axes[0].set_title(\"Figure 1 left: train MSE\")\n",
+ "axes[1].bar(\n",
+ " names,\n",
+ " (clean.residual_max, polluted.residual_max),\n",
+ " color=(\"#4c78a8\", \"#e45756\"),\n",
+ ")\n",
+ "axes[1].axhline(\n",
+ " DEFAULT_RESDMD_GATE_TOLERANCE,\n",
+ " color=\"black\",\n",
+ " linestyle=\"--\",\n",
+ " linewidth=1,\n",
+ " label=r\"cutoff $10^{-2}$\",\n",
+ ")\n",
+ "axes[1].set_ylabel(\"max ResDMD residual\")\n",
+ "axes[1].set_title(\"Figure 1 right: residual gate\")\n",
+ "axes[1].annotate(\n",
+ " f\"{clean.residual_max:.4f}\",\n",
+ " xy=(0, clean.residual_max),\n",
+ " xytext=(0.35, 0.18),\n",
+ " textcoords=\"data\",\n",
+ " ha=\"left\",\n",
+ " color=\"#4c78a8\",\n",
+ " fontsize=9,\n",
+ " arrowprops={\"arrowstyle\": \"->\", \"color\": \"#4c78a8\", \"lw\": 1},\n",
+ ")\n",
+ "axes[1].annotate(\n",
+ " f\"{polluted.residual_max:.3f}\",\n",
+ " xy=(1, polluted.residual_max),\n",
+ " xytext=(1, polluted.residual_max + 0.04),\n",
+ " ha=\"center\",\n",
+ " va=\"bottom\",\n",
+ " color=\"#e45756\",\n",
+ " fontsize=9,\n",
+ ")\n",
+ "axes[1].set_ylim(0.0, polluted.residual_max * 1.18)\n",
+ "axes[1].legend(loc=\"upper left\")\n",
+ "fig.tight_layout()\n",
+ "display(fig)\n",
+ "plt.close(fig)\n",
+ "\n",
+ "fig2, ax = plt.subplots(figsize=(7.2, 3.2))\n",
+ "labels = (\"scale (rank 1)\", \"rotation (rank 1)\", \"evaluate (rank 2)\")\n",
+ "values = (\n",
+ " eta_scale.leakage,\n",
+ " eta_leave.leakage,\n",
+ " float(eval_result.invariance.leakage),\n",
+ ")\n",
+ "ax.bar(labels, values, color=(\"#4c78a8\", \"#e45756\", \"#72b7b2\"))\n",
+ "ax.set_ylabel(r\"leakage $\\eta$\")\n",
+ "ax.set_title(\"Figure 2: finite-sample leakage\")\n",
+ "fig2.tight_layout()\n",
+ "display(fig2)\n",
+ "plt.close(fig2)\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t48-interpretation",
+ "metadata": {},
+ "source": [
+ "## Interpretation / discussion\n",
+ "\n",
+ "Report fields used here are `one_step`, `rollout`,\n",
+ "`stability.spectral_radius`, and gated `spectral`. On a true\n",
+ "trajectory those MSE slots agree (both $\\ll 10^{-20}$). On\n",
+ "i.i.d. 2-D pairs, rollout MSE can be $O(1)$ even when $K$ is\n",
+ "exact — that is a layout artifact, not a failed solve. The\n",
+ "graph-oracle `fit` residual can print as `0.0` because the\n",
+ "trajectory is noise-free; the helper residual on the 24-step\n",
+ "line is $\\sim 10^{-8}$.\n",
+ "\n",
+ "`fit` does **not** populate `invariance`. Read $\\eta$ from\n",
+ "`subspace_invariance_report` or `evaluate(..., include_invariance=True)`.\n",
+ "The evaluate path on this full-rank identity codec is the\n",
+ "tautology ($P=I$, rank $= d$). The rank-1 line is the case that\n",
+ "can fail.\n",
+ "\n",
+ "The polluted dictionary is the RMSE-only winner (lower\n",
+ "one-step MSE) and the gated reject (max residual\n",
+ "$\\approx 0.86 > 10^{-2}$). The identity residual\n",
+ "($\\approx 4 \\times 10^{-3}$) sits below the cutoff. This is\n",
+ "not a Haseli–Cortés certificate. Networked, continuous, and\n",
+ "controlled operators are out of scope.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t48-takeaways",
+ "metadata": {},
+ "source": [
+ "## Takeaways\n",
+ "\n",
+ "- Reach for opt-in identification when you want a closed-form\n",
+ " dense per-node $K$ and a finite-sample report. Default\n",
+ " `fit(..., identification=None)` stays Adam.\n",
+ "- Use `select_resdmd_gated` when an RMSE-only lift may be\n",
+ " polluted by a leaked future coordinate. The $10^{-2}$ cutoff\n",
+ " is a finite-dictionary gate, not a certified bound.\n",
+ "- Read $\\eta$ on a proper subspace (rank $< d$). When\n",
+ " rank $= d$, $\\eta$ is identically zero and is not evidence\n",
+ " of invariance.\n",
+ "- Do not treat i.i.d. 2-D `LatentPairs` as a trajectory:\n",
+ " rollout MSE then compares $K^{t} z_{0}$ to unrelated rows.\n",
+ "- Do not use this path for networked `koopman=\"graph\"` /\n",
+ " hetero / continuous / controlled operators, or for\n",
+ " `solver=\"varpro\"` (not implemented).\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t48-further",
+ "metadata": {},
+ "source": [
+ "## Further reading\n",
+ "\n",
+ "- Sphinx: [identification](../docs/source/identification.rst),\n",
+ " [faq](../docs/source/faq.rst), [limitations](../docs/source/limitations.rst)\n",
+ "- Williams, Kevrekidis, and Rowley (2015), *J. Nonlinear Sci.*\n",
+ " ([doi:10.1007/s00332-015-9258-5](https://doi.org/10.1007/s00332-015-9258-5))\n",
+ "- Colbrook and Townsend (2023/2024), *Comm. Pure Appl. Math.*\n",
+ " ([doi:10.1002/cpa.22125](https://doi.org/10.1002/cpa.22125))\n",
+ "- Haseli and Cortés (2023), arXiv:2311.13033\n",
+ " ([doi:10.48550/arXiv.2311.13033](https://doi.org/10.48550/arXiv.2311.13033))\n",
+ "- Example [53_latent_rank_selection.ipynb](53_latent_rank_selection.ipynb)\n"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "koopmangraph",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/49_multi_hop_factorization.ipynb b/examples/49_multi_hop_factorization.ipynb
new file mode 100644
index 0000000..1b446ac
--- /dev/null
+++ b/examples/49_multi_hop_factorization.ipynb
@@ -0,0 +1,565 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "t49-title",
+ "metadata": {},
+ "source": [
+ "# Multi-hop factorization: Kronecker spectrum versus dense $K_{\\mathrm{eff}}$\n",
+ "\n",
+ "This notebook checks the monomial $P=2$ networked map\n",
+ "\n",
+ "$$\n",
+ "K_{\\mathrm{eff}} = \\sum_{k=0}^{P} \\widehat{A}^{k} \\otimes K_{k}\n",
+ "$$\n",
+ "\n",
+ "on a path with $N=4$ nodes and latent width $d=2$. When the discrete\n",
+ "graph operator is eligible, `.spectrum` uses the pencil\n",
+ "$B(\\lambda)=\\sum_k \\lambda^k K_k$ (Kronecker reduction), not a sum of\n",
+ "independent factor eigenvalues. Dense assembly of $K_{\\mathrm{eff}}$ is\n",
+ "the reference. A second cell only checks that `fit` warns when a\n",
+ "two-layer encoder exceeds the operator hop degree.\n",
+ "\n",
+ "**Honesty.** This is an algebraic agreement check plus a hop-mismatch\n",
+ "warning exercise. It is **not** a forecasting contest, **not** a claim that\n",
+ "raising $P$ closes the example-38 hold-out MSE gap (approximately $0.71$\n",
+ "versus $0.019$), and **not** a continuous-graph result (that generator stays\n",
+ "one-tap). Factory `koopman_filter_degree` remains $1$; every fit here passes\n",
+ "$P$ explicitly. This notebook does **not** download METR-LA.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t49-setup-md",
+ "metadata": {},
+ "source": [
+ "## Setup\n",
+ "\n",
+ "Imports, a four-node path, and the float64 hop factors used in the operator\n",
+ "unit tests (`rtol=1e-6`, `atol=1e-8` on eigenvalues). Written against\n",
+ "`koopman_graph` 0.15.x.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "t49-setup",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T18:50:12.592086Z",
+ "iopub.status.busy": "2026-08-20T18:50:12.591903Z",
+ "iopub.status.idle": "2026-08-20T18:50:15.207633Z",
+ "shell.execute_reply": "2026-08-20T18:50:15.207160Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "koopman_graph 0.15.0; torch 2.13.0; N=4, d=2, P=2\n"
+ ]
+ }
+ ],
+ "source": [
+ "import os\n",
+ "import warnings\n",
+ "\n",
+ "from tqdm.std import TqdmExperimentalWarning, TqdmWarning\n",
+ "\n",
+ "warnings.filterwarnings(\"ignore\", category=TqdmWarning)\n",
+ "warnings.filterwarnings(\"ignore\", category=TqdmExperimentalWarning)\n",
+ "\n",
+ "import matplotlib\n",
+ "\n",
+ "if os.environ.get(\"PYTEST_CURRENT_TEST\"):\n",
+ " matplotlib.use(\"Agg\")\n",
+ "\n",
+ "import matplotlib.pyplot as plt\n",
+ "import torch\n",
+ "\n",
+ "import koopman_graph\n",
+ "from koopman_graph import (\n",
+ " GNNDecoder,\n",
+ " GNNEncoder,\n",
+ " GraphKoopmanModel,\n",
+ " GraphKoopmanOperator,\n",
+ " compute_spectrum,\n",
+ ")\n",
+ "from koopman_graph.datasets.synthetic import SyntheticDynamicGraphBenchmark\n",
+ "from koopman_graph.graph_utils.topology import dense_symmetric_normalized_adjacency\n",
+ "from koopman_graph.nn import ReceptiveFieldMismatchWarning\n",
+ "from koopman_graph.operators.kronecker_spectrum import (\n",
+ " spectrum_k_eff_kronecker_polynomial,\n",
+ ")\n",
+ "from koopman_graph.operators.polynomial_graph import dense_polynomial_kronecker\n",
+ "\n",
+ "SEED = 0\n",
+ "N = 4\n",
+ "D = 2\n",
+ "P = 2\n",
+ "DT = 0.25\n",
+ "# Match tests/operators/test_kronecker_spectrum.py (float64 polynomial).\n",
+ "RTOL = 1e-6\n",
+ "ATOL = 1e-8\n",
+ "torch.manual_seed(SEED)\n",
+ "\n",
+ "src = [0, 1, 1, 2, 2, 3]\n",
+ "dst = [1, 0, 2, 1, 3, 2]\n",
+ "edge_index = torch.tensor([src, dst], dtype=torch.long)\n",
+ "dtype = torch.float64\n",
+ "k0 = torch.tensor([[0.50, 0.10], [0.00, 0.40]], dtype=dtype)\n",
+ "k1 = torch.tensor([[0.08, 0.02], [0.01, 0.05]], dtype=dtype)\n",
+ "k2 = torch.tensor([[0.03, 0.00], [0.00, 0.02]], dtype=dtype)\n",
+ "hops = (k0, k1, k2)\n",
+ "\n",
+ "print(\n",
+ " f\"koopman_graph {koopman_graph.__version__}; torch {torch.__version__}; \"\n",
+ " f\"N={N}, d={D}, P={P}\"\n",
+ ")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t49-motivation",
+ "metadata": {},
+ "source": [
+ "## Motivation and background\n",
+ "\n",
+ "Assembling the ambient $(N\\cdot d)\\times(N\\cdot d)$ matrix and calling\n",
+ "`torch.linalg.eig` is the direct spectrum. When the discrete graph\n",
+ "operator is eligible, `.spectrum` instead diagonalizes $N$ pencils of\n",
+ "size $d\\times d$:\n",
+ "\n",
+ "$$\n",
+ "B(\\lambda)=\\sum_{k=0}^{P}\\lambda^{k}K_{k},\\qquad\n",
+ "\\mu_{ij}\\in\\operatorname{eig}\\bigl(B(\\lambda_i)\\bigr).\n",
+ "$$\n",
+ "\n",
+ "Those $\\mu$ **are** the eigenvalues of $K_{\\mathrm{eff}}$. They are\n",
+ "**not** the growth rates $\\log|\\mu|/\\Delta t$ stored on\n",
+ "`KoopmanSpectrum.growth_rates` (here $\\Delta t=0.25$ only fills that\n",
+ "metadata). Pairing independent factor eigenvalues\n",
+ "$\\nu_j^{(0)}+\\lambda_i\\nu_j^{(1)}+\\lambda_i^{2}\\nu_j^{(2)}$ is the\n",
+ "formula the reduction must not use unless the $K_k$ share an eigenbasis.\n",
+ "\n",
+ "Eligibility (all required): a single shared self factor;\n",
+ "`adjacency` in `{\"symmetric\", \"random_walk\"}`; `sparsity` in\n",
+ "`{\"dense\", \"block_diagonal\"}`. Dual random-walk, `sparsity=\"distributed\"`\n",
+ "(Arnoldi Ritz values), and orbit / isotypic self banks are ineligible.\n",
+ "The helper returns `None` when the reduction is numerically unreliable;\n",
+ "callers then fall back to dense assembly. Continuous graph stays the\n",
+ "one-tap generator.\n",
+ "\n",
+ "This monomial hop map is **not** the modularized EDMD of Guo, Schaller,\n",
+ "Worthmann, and Streif (2025). That paper is related networked-EDMD\n",
+ "literature only.\n",
+ "\n",
+ "Encoder message-passing hops and operator $P$ are different maps.\n",
+ "Default factory `koopman_filter_degree` is $1$. A two-layer GCN mixes\n",
+ "two hops; that encoder radius exceeds a $P=1$ operator, and `fit` emits\n",
+ "`ReceptiveFieldMismatchWarning` without stopping training.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t49-minimal-md",
+ "metadata": {},
+ "source": [
+ "## Minimal example\n",
+ "\n",
+ "Three routes should agree on eigenvalues (unordered multiset, greedy nearest\n",
+ "match) on this eligible symmetric path:\n",
+ "\n",
+ "1. `spectrum_k_eff_kronecker_polynomial` (power-user helper).\n",
+ "2. `GraphKoopmanOperator.spectrum` (auto-routed Kronecker path).\n",
+ "3. `compute_spectrum` on `effective_matrix` / `dense_polynomial_kronecker`.\n",
+ "\n",
+ "Tolerance: relative $10^{-6}$ with absolute floor $10^{-8}$, copied from the\n",
+ "float64 $N=4$, $d=2$ operator tests — not an invented metric.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "t49-minimal",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T18:50:15.208687Z",
+ "iopub.status.busy": "2026-08-20T18:50:15.208574Z",
+ "iopub.status.idle": "2026-08-20T18:50:15.262566Z",
+ "shell.execute_reply": "2026-08-20T18:50:15.262208Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "helper vs dense_polynomial_kronecker: True\n",
+ "operator.spectrum vs effective_matrix: True\n",
+ "helper vs operator.spectrum: True\n",
+ "K_eff shape: (8, 8) ||K_eff - dense||_F = 0.0\n"
+ ]
+ }
+ ],
+ "source": [
+ "def eigvals_match(left, right, rtol=RTOL, atol=ATOL):\n",
+ " if left.shape != right.shape:\n",
+ " return False\n",
+ " remaining = right.detach().clone()\n",
+ " for value in left.detach():\n",
+ " index = int(torch.argmin((remaining - value).abs()))\n",
+ " if not torch.isclose(value, remaining[index], rtol=rtol, atol=atol):\n",
+ " return False\n",
+ " remaining[index] = complex(float(\"inf\"), float(\"inf\"))\n",
+ " return True\n",
+ "\n",
+ "\n",
+ "adj = dense_symmetric_normalized_adjacency(edge_index, N, dtype=dtype)\n",
+ "K_dense = dense_polynomial_kronecker(adj, hops)\n",
+ "spec_dense = compute_spectrum(K_dense, DT)\n",
+ "spec_helper = spectrum_k_eff_kronecker_polynomial(\n",
+ " hop_matrices=hops,\n",
+ " edge_index=edge_index,\n",
+ " num_nodes=N,\n",
+ " adjacency=\"symmetric\",\n",
+ " time_step=DT,\n",
+ ")\n",
+ "assert spec_helper is not None\n",
+ "\n",
+ "op = GraphKoopmanOperator(D, filter_degree=P).double()\n",
+ "op.set_dense_matrices(k0, k1, hop_matrices=(k2,))\n",
+ "K_eff = op.effective_matrix(edge_index, N)\n",
+ "spec_op = op.spectrum(edge_index, num_nodes=N, time_step=DT)\n",
+ "spec_from_keff = compute_spectrum(K_eff, DT)\n",
+ "\n",
+ "print(\"helper vs dense_polynomial_kronecker:\",\n",
+ " eigvals_match(spec_helper.eigenvalues, spec_dense.eigenvalues))\n",
+ "print(\"operator.spectrum vs effective_matrix:\",\n",
+ " eigvals_match(spec_op.eigenvalues, spec_from_keff.eigenvalues))\n",
+ "print(\"helper vs operator.spectrum:\",\n",
+ " eigvals_match(spec_helper.eigenvalues, spec_op.eigenvalues))\n",
+ "print(\"K_eff shape:\", tuple(K_eff.shape), \" ||K_eff - dense||_F =\",\n",
+ " float(torch.linalg.norm(K_eff - K_dense).item()))\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t49-deep-md",
+ "metadata": {},
+ "source": [
+ "## Progressive deep dive\n",
+ "\n",
+ "### Independent factor eigenvalues\n",
+ "\n",
+ "Pairing $\\nu_j^{(0)} + \\lambda_i \\nu_j^{(1)} + \\lambda_i^2 \\nu_j^{(2)}$ from\n",
+ "**separate** eigendecompositions of $K_k$ is the anti-oracle. On this $K_0$\n",
+ "(upper triangular) and $K_1$ (lower triangular) pair the factors do not\n",
+ "share an eigenbasis, so the pairing fails the same tolerance while the\n",
+ "pencil still matches dense `eig`.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "t49-anti-oracle",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T18:50:15.263660Z",
+ "iopub.status.busy": "2026-08-20T18:50:15.263575Z",
+ "iopub.status.idle": "2026-08-20T18:50:15.268191Z",
+ "shell.execute_reply": "2026-08-20T18:50:15.267841Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "pencil vs dense (should match): True\n",
+ "independent factor eigs vs dense (should fail): False\n"
+ ]
+ }
+ ],
+ "source": [
+ "k0_naive = torch.tensor([[0.50, 0.40], [0.00, 0.20]], dtype=dtype)\n",
+ "k1_naive = torch.tensor([[0.10, 0.00], [0.35, 0.30]], dtype=dtype)\n",
+ "k2_zero = torch.zeros(2, 2, dtype=dtype)\n",
+ "hops_naive = (k0_naive, k1_naive, k2_zero)\n",
+ "dense_naive = torch.linalg.eigvals(dense_polynomial_kronecker(adj, hops_naive))\n",
+ "reduced_naive = spectrum_k_eff_kronecker_polynomial(\n",
+ " hop_matrices=hops_naive,\n",
+ " edge_index=edge_index,\n",
+ " num_nodes=N,\n",
+ " adjacency=\"symmetric\",\n",
+ " time_step=DT,\n",
+ ")\n",
+ "assert reduced_naive is not None\n",
+ "print(\n",
+ " \"pencil vs dense (should match):\",\n",
+ " eigvals_match(reduced_naive.eigenvalues, dense_naive),\n",
+ ")\n",
+ "\n",
+ "adj_eigs = torch.linalg.eigvalsh(adj)\n",
+ "nu0 = torch.linalg.eigvals(k0_naive)\n",
+ "nu1 = torch.linalg.eigvals(k1_naive)\n",
+ "nu2 = torch.linalg.eigvals(k2_zero)\n",
+ "nu0 = nu0[torch.argsort(nu0.real)]\n",
+ "nu1 = nu1[torch.argsort(nu1.real)]\n",
+ "nu2 = nu2[torch.argsort(nu2.real)]\n",
+ "independent = torch.cat(\n",
+ " [nu0 + lam * nu1 + (lam ** 2) * nu2 for lam in adj_eigs]\n",
+ ")\n",
+ "print(\n",
+ " \"independent factor eigs vs dense (should fail):\",\n",
+ " eigvals_match(independent, dense_naive),\n",
+ ")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t49-hop-md",
+ "metadata": {},
+ "source": [
+ "### Encoder hops versus operator $P$\n",
+ "\n",
+ "A two-layer GCN mixes two hops. With explicit `koopman_filter_degree=1`,\n",
+ "that **encoder** radius exceeds the operator hop degree, so `fit` emits\n",
+ "`ReceptiveFieldMismatchWarning`. The same codec with\n",
+ "`koopman_filter_degree=2` is hop-matched and should stay quiet. Five\n",
+ "epochs on a tiny path-diffusion draw only exercise the warning flag.\n",
+ "Hold-out mean squared error (MSE) is not computed here and is not a\n",
+ "forecasting comparison.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "t49-hop",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T18:50:15.269077Z",
+ "iopub.status.busy": "2026-08-20T18:50:15.269010Z",
+ "iopub.status.idle": "2026-08-20T18:50:15.405083Z",
+ "shell.execute_reply": "2026-08-20T18:50:15.404670Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "P=1 (2-layer encoder exceeds operator hops): warning=True\n",
+ "P=2 (hop-matched 2-layer encoder): warning=False\n"
+ ]
+ }
+ ],
+ "source": [
+ "train = SyntheticDynamicGraphBenchmark.generate(\n",
+ " num_nodes=N,\n",
+ " num_timesteps=16,\n",
+ " seed=0,\n",
+ " in_channels=D,\n",
+ " topology=\"path\",\n",
+ " diffusion_rate=0.35,\n",
+ " decay_rate=0.92,\n",
+ " noise_std=0.0,\n",
+ " initial_state=\"ones\",\n",
+ ")\n",
+ "\n",
+ "\n",
+ "def fit_degree(degree, epochs=5):\n",
+ " torch.manual_seed(SEED)\n",
+ " model = GraphKoopmanModel(\n",
+ " GNNEncoder(D, 16, D, num_layers=2),\n",
+ " GNNDecoder(D, 16, D, num_layers=2),\n",
+ " latent_dim=D,\n",
+ " time_step=1.0,\n",
+ " koopman=\"graph\",\n",
+ " koopman_filter_degree=degree,\n",
+ " )\n",
+ " with warnings.catch_warnings(record=True) as caught:\n",
+ " warnings.simplefilter(\"always\", ReceptiveFieldMismatchWarning)\n",
+ " _ = model.fit(train, epochs=epochs, lr=5e-3, device=\"cpu\")\n",
+ " return any(\n",
+ " issubclass(w.category, ReceptiveFieldMismatchWarning) for w in caught\n",
+ " )\n",
+ "\n",
+ "\n",
+ "warn_p1 = fit_degree(1)\n",
+ "warn_p2 = fit_degree(2)\n",
+ "print(f\"P=1 (2-layer encoder exceeds operator hops): warning={warn_p1}\")\n",
+ "print(f\"P=2 (hop-matched 2-layer encoder): warning={warn_p2}\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t49-results-md",
+ "metadata": {},
+ "source": [
+ "## Results\n",
+ "\n",
+ "The scatter plots stored `KoopmanSpectrum.eigenvalues` for the helper\n",
+ "versus dense `eig` of $K_{\\mathrm{eff}}$. Markers should overlap: those\n",
+ "values are $\\mu=\\operatorname{eig}(B(\\lambda_i))$, not growth rates.\n",
+ "On this real-symmetric path they lie on the real axis. Agreement to the\n",
+ "documented float64 tolerance is the result; the figure is a visual\n",
+ "check, not a new metric.\n",
+ "\n",
+ "The hop cell reports warning flags only (`True` at $P=1$, `False` at\n",
+ "$P=2$). It does not rank forecast MSE.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "t49-plot",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T18:50:15.406134Z",
+ "iopub.status.busy": "2026-08-20T18:50:15.406059Z",
+ "iopub.status.idle": "2026-08-20T18:50:15.667255Z",
+ "shell.execute_reply": "2026-08-20T18:50:15.666889Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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EREZGgMhEREZGgMhEREZGgMhEREZGgMhEREZGgMhEREZGgMhEREZGgMhEREZGgMhEREZGgMhEREZGgMhEREZGgMhEREZGgMhEREZGgMhEREZGgMhEREZGgMhEREZGgMhEREZGgMhEREZmssFwuPHj0u/fv2kffv2MmrUKAkODk5wnfPnz8uQIUOkRYsWcuLEiWTZTiIicg0uFQj3798vNWrUkJiYGGnZsqWsW7dO6tSpI8+ePYt3nbFjx6plPTw85J9//pHAwMBk3WYiInJuLhUIhw8fLk2aNJEZM2ZI165dZfXq1XL16lWZPXt2vOtgubNnz8oHH3yQrNtKRESuwWUCYVhYmGzbtk3atm1reszX11caNWoka9eujXe9PHnyiJubWzJtJRERuRpPcRHXr1+X6OhoyZcvn8Xj/v7+smXLliR9r/DwcHXTPXr0KElfn4iInIfLlAgjIiLU3/Tp01s8jvv6c0kF9YqZM2c23RBsiYgodXKZQIg0KAQFBVk8jsYv+nNJWRcZEhJiut24cSNJX5+IiJyHywTCvHnzip+fnxw7dszi8aNHj0qFChWS9L28vLwkU6ZMFjciIkqdXCYQosHLO++8Iz///LOpC8SGDRvk8OHD0qVLF9NyU6ZMUf0MiYiIUlVjGfjqq69UibBEiRJSvHhxOXLkiIwePVrq1atnWgbP792713R/06ZNMmHCBNXqVE97omTZuXNndSMiImNz0zRNExccXSYgIEDKlCmjukfEfg6jzejB8ebNmyp9GhsCKW72QKtRNJpBfeHzpklPnTqltpeIiBwvMedtlyoR6sqXL2/3c+huEbvLBRERkcvVERIRETkCAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERkaAyERERmaZ2JXaNq0qWTNmlUqVqxouuXIkUOio6NlypQpMnDgQMdsKRERkTOUCDt16iQ+Pj6yfPlyadWqleTMmVPy5MkjDRs2lEGDBsmDBw8csZ1E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GRoDIRERGRoDIRERGRoDIRERGRoDIRERGRoDIRERGRoDIRERGRoDIRERGZrLBsKYmJhELa9pmsO2hYiIXJfLBcKxY8dKzpw5JU2aNFKuXDnZvHmzzeXXrVsnr7zyimTOnFndmjdvLmfOnEm27SUiIufmUoFw5syZ8s0338ivv/4qISEh0rZtW2nRooVcuXLF6vLR0dEyYcIE+eSTT+T27dty6dIl8fHxUYHx0aNHyb79RETkfNw0F8oZFi9eXJo1ayYTJ05U97HpBQoUkE6dOsl3331n12vcuHFD8ufPL+vXr1cB0R4ImihNIvhmypTpubb91KlTUqZMmedal4iIEicx522XKREGBgbKhQsXpH79+qbH3Nzc1P09e/bY/Tp37txRf7NkyeKQ7SQiItfimZJvHhkZqdKXtnh5eamAFxAQoO5nz57d4vkcOXLI/v377Xq/iIgIGThwoFSvXl2qVKkS73Lh4eHqpmMalYgo9UrREuH7778vvr6+Nm+x6/9itxbFfQTKhGC5bt26yfXr12Xp0qU210GDHL1xDW7+/v4vsJdEROTMUjQQzpo1S8LCwmzeChcurJbNnTu3+nvv3j2L18D9XLly2RUEt27dKlu2bJGCBQvaXH748OEqr6zfUK9IRESpk8vUEaJOr3Tp0iqQmQc43K9du7bpsaioKJUCNV/m3XfflY0bN6plixUrluB7IR2LylXzGxERpU4uEwhh2LBh8ssvv8iKFStUd4jBgwdLaGio9O3b17RMnz59pHLlyqZWpb169ZI1a9bI2rVrVQtTvaSZUN0kEREZQ4o2lkmsLl26qMCH1CUaz6BD/YYNGyRfvnymZdDRHiU6CAoKUn0OAQ1kYvdJRLqUiIiMzaX6EaYU9iMkInItqbIfIRERkSMwEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaExEBIRkaEx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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "fig, ax = plt.subplots(figsize=(4.6, 4.4))\n",
+ "ax.scatter(\n",
+ " spec_dense.eigenvalues.real.detach(),\n",
+ " spec_dense.eigenvalues.imag.detach(),\n",
+ " s=70,\n",
+ " facecolors=\"none\",\n",
+ " edgecolors=\"#3d5a80\",\n",
+ " label=r\"dense $K_{\\mathrm{eff}}$\",\n",
+ ")\n",
+ "ax.scatter(\n",
+ " spec_helper.eigenvalues.real.detach(),\n",
+ " spec_helper.eigenvalues.imag.detach(),\n",
+ " s=28,\n",
+ " c=\"#ee6c4d\",\n",
+ " label=r\"Kronecker $B(\\lambda)$\",\n",
+ " zorder=3,\n",
+ ")\n",
+ "ax.axhline(0.0, color=\"0.8\", linewidth=0.6)\n",
+ "ax.axvline(0.0, color=\"0.8\", linewidth=0.6)\n",
+ "ax.set_xlabel(r\"Re $\\mu$\")\n",
+ "ax.set_ylabel(r\"Im $\\mu$\")\n",
+ "ax.set_title(rf\"$P=2$ spectrum on a path ($N={N}$, $d={D}$)\")\n",
+ "ax.set_aspect(\"equal\", adjustable=\"datalim\")\n",
+ "ax.legend(frameon=False)\n",
+ "fig.tight_layout()\n",
+ "plt.show()\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t49-interp",
+ "metadata": {},
+ "source": [
+ "## Interpretation / discussion\n",
+ "\n",
+ "On this eligible path the three spectrum routes agree and\n",
+ "$\\|K_{\\mathrm{eff}}-K_{\\mathrm{dense}}\\|_F=0$. That is algebraic\n",
+ "identity, not forecast skill. The independent-factor pairing fails on\n",
+ "the same graph when $K_0$ and $K_1$ do not share an eigenbasis, which\n",
+ "is why the pencil $B(\\lambda)$ is the reduction.\n",
+ "\n",
+ "Reach for the Kronecker path when the eligibility gates hold and $d\\ll N$\n",
+ "makes $N$ pencils cheaper than a dense $(N\\cdot d)$ eigendecomposition\n",
+ "(order $O(N^3+N d^3)$ versus dense assembly). Do not use it for dual\n",
+ "adjacency, distributed Arnoldi, or orbit / isotypic self banks; those\n",
+ "routes stay on dense `effective_matrix` or leading-modulus Ritz values.\n",
+ "If the helper returns `None`, treat that as a fall-back signal, not a\n",
+ "spectrum. Continuous graph has no polynomial generator reduction here.\n",
+ "\n",
+ "The hop warning only says the encoder radius exceeds operator $P$.\n",
+ "Raising $P$ does not close the example-38 one-tap versus joint\n",
+ "least-squares hold-out MSE gap (approximately $0.71$ versus $0.019$).\n",
+ "Factory $P$ stays $1$.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t49-takeaways",
+ "metadata": {},
+ "source": [
+ "## Takeaways\n",
+ "\n",
+ "- Use eligible discrete $P\\ge 0$ spectrum when you want\n",
+ " $\\mu=\\operatorname{eig}(B(\\lambda_i))$ with\n",
+ " $B(\\lambda)=\\sum_k\\lambda^k K_k$, matching dense $K_{\\mathrm{eff}}$\n",
+ " at the documented float64 tolerance on this path.\n",
+ "- Do not pair independent factor eigenvalues unless the $K_k$ share an\n",
+ " eigenbasis.\n",
+ "- Do not use the Kronecker path for dual / distributed / orbit layouts,\n",
+ " or treat `None` as a spectrum. Continuous graph stays one-tap.\n",
+ "- Encoder hops and operator $P$ are different maps; `fit` warns when the\n",
+ " encoder exceeds $P$. Factory $P$ stays $1$.\n",
+ "- Example 38's historical one-tap versus joint-LS gap is a separate\n",
+ " protocol and is not attributed to hop order here.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t49-reading",
+ "metadata": {},
+ "source": [
+ "## Further reading\n",
+ "\n",
+ "- Sphinx: [matrix_free](../docs/source/matrix_free.rst),\n",
+ " [limitations](../docs/source/limitations.rst),\n",
+ " [faq](../docs/source/faq.rst),\n",
+ " [architecture](../docs/source/architecture.rst),\n",
+ " [api](../docs/source/api.rst)\n",
+ " (`GraphKoopmanOperator.spectrum`,\n",
+ " `GraphKoopmanOperator.effective_matrix`,\n",
+ " `koopman_graph.operators.kronecker_spectrum.spectrum_k_eff_kronecker_polynomial`,\n",
+ " `koopman_graph.operators.polynomial_graph.dense_polynomial_kronecker`,\n",
+ " `ReceptiveFieldMismatchWarning`)\n",
+ "- Related notebook:\n",
+ " [38_operator_factorization_ablation.ipynb](38_operator_factorization_ablation.ipynb)\n",
+ " (one-tap versus joint LS; hop-matched $P\\in\\{0,1,2\\}$ forecasting arm)\n",
+ "- Guo, Y., Schaller, M., Worthmann, K., and Streif, S. (2025).\n",
+ " Modularized data-driven approximation of the Koopman operator and\n",
+ " generator. *Physica D*, 476, 134651.\n",
+ " [doi:10.1016/j.physd.2025.134651](https://doi.org/10.1016/j.physd.2025.134651)\n",
+ " (`Guo2025ModularEDMD`; related networked-EDMD literature — **not** this\n",
+ " monomial hop map)\n"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "koopmangraph",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.13"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/50_graph_state_closure.ipynb b/examples/50_graph_state_closure.ipynb
new file mode 100644
index 0000000..ece2a84
--- /dev/null
+++ b/examples/50_graph_state_closure.ipynb
@@ -0,0 +1,531 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "t50-title",
+ "metadata": {},
+ "source": [
+ "# Graph-state closure versus hold-last\n",
+ "\n",
+ "This notebook shows opt-in graph-state dynamics: a topology head can\n",
+ "propose $\\hat A_{t+1}$ and feed it into the networked Koopman step,\n",
+ "instead of copying the last observed edges (hold-last). On a seeded\n",
+ "six-node cycle replaced by a directed $G(n,p)$ draw (independent\n",
+ "Bernoulli arcs, $p=0.35$), a dense stub that scores the **true next\n",
+ "graph** beats hold-last when the comparison target is the model's own\n",
+ "one-step oracle forecast.\n",
+ "\n",
+ "**Honesty.** That comparison is a **wiring check**, not a learned-forecast\n",
+ "claim and not a traffic or METR-LA result. Default\n",
+ "`graph_dynamics=None` keeps the 0.14 hold-last schedule. The configured\n",
+ "default head is `sparse_candidate` (at most `candidate_k` destinations\n",
+ "per node); this demo uses `dense_mlp` only so the stub can score every\n",
+ "pair. Predicted edges enter $K_{\\mathrm{eff}}$ as sigmoid weights on the\n",
+ "candidate COO, not a hard threshold. Homogeneous models only.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t50-setup-md",
+ "metadata": {},
+ "source": [
+ "## Setup\n",
+ "\n",
+ "Imports, a six-node bidirectional cycle / directed $G(n,p)$ helper\n",
+ "pair, and a tiny `koopman=\"graph\"` model. Feature coordinates are\n",
+ "dimensionless synthetic values. Written against the in-repo\n",
+ "`koopman_graph` checkout.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "t50-setup",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T22:17:02.528297Z",
+ "iopub.status.busy": "2026-08-20T22:17:02.528212Z",
+ "iopub.status.idle": "2026-08-20T22:17:05.172625Z",
+ "shell.execute_reply": "2026-08-20T22:17:05.172151Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "koopman_graph 0.15.0\n"
+ ]
+ }
+ ],
+ "source": [
+ "import os\n",
+ "import types\n",
+ "import warnings\n",
+ "\n",
+ "from tqdm.std import TqdmExperimentalWarning, TqdmWarning\n",
+ "\n",
+ "warnings.filterwarnings(\"ignore\", category=TqdmWarning)\n",
+ "warnings.filterwarnings(\"ignore\", category=TqdmExperimentalWarning)\n",
+ "\n",
+ "import matplotlib\n",
+ "\n",
+ "if os.environ.get(\"PYTEST_CURRENT_TEST\"):\n",
+ " matplotlib.use(\"Agg\")\n",
+ "\n",
+ "import matplotlib.pyplot as plt\n",
+ "import torch\n",
+ "from torch import nn\n",
+ "from torch_geometric.data import Data\n",
+ "\n",
+ "import koopman_graph\n",
+ "from koopman_graph import GNNDecoder, GNNEncoder, GraphKoopmanModel\n",
+ "from koopman_graph.data import (\n",
+ " GraphDynamicsConfig,\n",
+ " GraphSnapshotSequence,\n",
+ " graph_state_at,\n",
+ ")\n",
+ "from koopman_graph.nn.predicted_topology import PredictedTopologyHead\n",
+ "\n",
+ "print(\"koopman_graph\", koopman_graph.__version__)\n",
+ "\n",
+ "SEED = 6\n",
+ "N = 6\n",
+ "IN_CHANNELS = 2\n",
+ "LATENT_DIM = 2\n",
+ "\n",
+ "\n",
+ "def cycle_edge_index(num_nodes: int) -> torch.Tensor:\n",
+ " forward_src = list(range(num_nodes))\n",
+ " forward_dst = list(range(1, num_nodes)) + [0]\n",
+ " src = forward_src + forward_dst\n",
+ " dst = forward_dst + forward_src\n",
+ " return torch.tensor([src, dst], dtype=torch.long)\n",
+ "\n",
+ "\n",
+ "def directed_gnp_edge_index(num_nodes: int, *, seed: int) -> torch.Tensor:\n",
+ " # Directed G(n,p): independent Bernoulli arcs; not undirected ER.\n",
+ " generator = torch.Generator().manual_seed(seed)\n",
+ " mask = torch.rand(num_nodes, num_nodes, generator=generator) < 0.35\n",
+ " mask.fill_diagonal_(False)\n",
+ " src, dst = mask.nonzero(as_tuple=True)\n",
+ " if src.numel() == 0:\n",
+ " src = torch.arange(num_nodes)\n",
+ " dst = (src + 1) % num_nodes\n",
+ " return torch.stack([src, dst], dim=0)\n",
+ "\n",
+ "\n",
+ "def tiny_model(config: GraphDynamicsConfig | None) -> GraphKoopmanModel:\n",
+ " return GraphKoopmanModel(\n",
+ " GNNEncoder(IN_CHANNELS, 8, LATENT_DIM, num_layers=1),\n",
+ " GNNDecoder(LATENT_DIM, 8, IN_CHANNELS, num_layers=1),\n",
+ " latent_dim=LATENT_DIM,\n",
+ " time_step=0.1,\n",
+ " koopman=\"graph\",\n",
+ " graph_dynamics=config,\n",
+ " )\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t50-motivation",
+ "metadata": {},
+ "source": [
+ "## Motivation and background\n",
+ "\n",
+ "Mechanical dynamic topology (notebook 18) already lets each snapshot\n",
+ "carry its own `edge_index`. The forecast loop still **held the last\n",
+ "observed graph** unless the caller passed `future_topologies`. That is\n",
+ "the right control when the next adjacency is known, and the wrong\n",
+ "default when the scientific question is whether the model can propose\n",
+ "$\\hat A_{t+1}$ after a structural event.\n",
+ "\n",
+ "`GraphDynamicsConfig` (opt-in; off the root façade) attaches a topology\n",
+ "head. Recursive rollout uses predicted $\\hat A_{t+1}=g_\\phi(z_t)$ when\n",
+ "`future_topologies` is omitted. Hold-last and oracle futures remain\n",
+ "controls. This is distinct from static Graph WaveNet\n",
+ "`learn_topology=\"self_adaptive\"` / `AdaptiveAdjacency` (Wu et al.,\n",
+ "IJCAI 2019; `Wu2019WaveNet`); combining that path with a non-`none`\n",
+ "topology head raises.\n",
+ "\n",
+ "A changing node **universe** is a different problem: sequences still\n",
+ "lock $N$ unless the caller applies `EntityRemap` into a finite $N_{\\max}$.\n",
+ "This notebook keeps $N$ fixed. Unbounded growth is refused.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t50-min-md",
+ "metadata": {},
+ "source": [
+ "## Minimal example\n",
+ "\n",
+ "Attach the default sparse-candidate config, wrap two snapshots, and\n",
+ "compare recursive versus hold-last `predict`. The two rollouts should\n",
+ "differ once a head is attached. `graph_state_at` is a supervision\n",
+ "record, not a replacement for `GraphSnapshotSequence`.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "t50-min",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T22:17:05.173813Z",
+ "iopub.status.busy": "2026-08-20T22:17:05.173661Z",
+ "iopub.status.idle": "2026-08-20T22:17:05.226846Z",
+ "shell.execute_reply": "2026-08-20T22:17:05.226466Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "topology_head sparse_candidate candidate_k 8\n",
+ "graph_state_at x (6, 2) E 12\n",
+ "recursive uses predicted weights: True\n",
+ "hold-last copies origin edges: True\n",
+ "node-feature tensors differ: True\n"
+ ]
+ }
+ ],
+ "source": [
+ "torch.manual_seed(SEED)\n",
+ "config = GraphDynamicsConfig() # sparse_candidate, candidate_k=8\n",
+ "print(\"topology_head\", config.topology_head, \"candidate_k\", config.candidate_k)\n",
+ "\n",
+ "g0 = cycle_edge_index(N)\n",
+ "g1 = directed_gnp_edge_index(N, seed=11)\n",
+ "snapshots = [\n",
+ " Data(x=torch.randn(N, IN_CHANNELS), edge_index=g0),\n",
+ " Data(x=torch.randn(N, IN_CHANNELS), edge_index=g1),\n",
+ "]\n",
+ "sequence = GraphSnapshotSequence(snapshots, allow_dynamic_topology=True)\n",
+ "state = graph_state_at(sequence, 0)\n",
+ "print(\"graph_state_at x\", tuple(state.x.shape), \"E\", int(state.edge_index.shape[1]))\n",
+ "\n",
+ "model = tiny_model(config)\n",
+ "origin = snapshots[0]\n",
+ "model.eval()\n",
+ "with torch.no_grad():\n",
+ " recursive = model.predict(origin, steps=1, topology_policy=\"recursive\")[0]\n",
+ " hold = model.predict(origin, steps=1, topology_policy=\"hold_last\")[0]\n",
+ "print(\"recursive uses predicted weights:\", recursive.edge_weight is not None)\n",
+ "print(\"hold-last copies origin edges:\", bool(torch.equal(hold.edge_index, origin.edge_index)))\n",
+ "print(\"node-feature tensors differ:\", not torch.allclose(recursive.x, hold.x, atol=1e-6))\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t50-dive-md",
+ "metadata": {},
+ "source": [
+ "## Progressive deep dive\n",
+ "\n",
+ "The seed-11 check is not \"a randomly initialized head forecasts better.\"\n",
+ "Truth is the **same model** advanced one step with oracle\n",
+ "`future_topologies` equal to the post-event directed $G(n,p)$ graph.\n",
+ "Hold-last keeps the pre-event cycle. A dense stub that assigns large\n",
+ "logits to the true next arcs should land closer to that oracle than\n",
+ "hold-last, but not at zero error: recursive rollout still feeds\n",
+ "$\\sigma(\\mathrm{logit})$ on all $N(N-1)$ off-diagonal pairs, whereas\n",
+ "the oracle COO is unweighted.\n",
+ "\n",
+ "We bind `pairwise_logits` on `PredictedTopologyHead` (`dense_mlp`) to\n",
+ "that stub. Mean squared error (MSE) is over node features\n",
+ "(dimensionless synthetic coordinates).\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "t50-dive",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T22:17:05.227799Z",
+ "iopub.status.busy": "2026-08-20T22:17:05.227723Z",
+ "iopub.status.idle": "2026-08-20T22:17:05.234515Z",
+ "shell.execute_reply": "2026-08-20T22:17:05.234116Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "stub recursive MSE vs oracle: 0.001610\n",
+ "hold-last MSE vs oracle: 0.070864\n",
+ "stub recursive beats hold-last on the oracle one-step (wiring check).\n"
+ ]
+ }
+ ],
+ "source": [
+ "torch.manual_seed(SEED)\n",
+ "g0 = cycle_edge_index(N)\n",
+ "g1 = directed_gnp_edge_index(N, seed=11)\n",
+ "origin = Data(x=torch.randn(N, IN_CHANNELS), edge_index=g0)\n",
+ "nxt = Data(x=torch.zeros(N, IN_CHANNELS), edge_index=g1)\n",
+ "\n",
+ "model = tiny_model(GraphDynamicsConfig(topology_head=\"dense_mlp\"))\n",
+ "assert isinstance(model.predicted_topology, PredictedTopologyHead)\n",
+ "\n",
+ "\n",
+ "def oracle_logits(self: PredictedTopologyHead, z: torch.Tensor) -> torch.Tensor:\n",
+ " del self\n",
+ " logits = torch.full((z.shape[0], z.shape[0]), -8.0, device=z.device, dtype=z.dtype)\n",
+ " logits[g1[0], g1[1]] = 8.0\n",
+ " logits.fill_diagonal_(-1e9)\n",
+ " return logits\n",
+ "\n",
+ "\n",
+ "model.predicted_topology.pairwise_logits = types.MethodType(\n",
+ " oracle_logits,\n",
+ " model.predicted_topology,\n",
+ ")\n",
+ "model.eval()\n",
+ "with torch.no_grad():\n",
+ " truth = model.predict(origin, steps=1, future_topologies=[nxt])[0]\n",
+ " recursive = model.predict(origin, steps=1, topology_policy=\"recursive\")[0]\n",
+ " hold = model.predict(origin, steps=1, topology_policy=\"hold_last\")[0]\n",
+ "\n",
+ "mse_rec = float(nn.functional.mse_loss(recursive.x, truth.x))\n",
+ "mse_hold = float(nn.functional.mse_loss(hold.x, truth.x))\n",
+ "print(f\"stub recursive MSE vs oracle: {mse_rec:.6f}\")\n",
+ "print(f\"hold-last MSE vs oracle: {mse_hold:.6f}\")\n",
+ "if not (mse_rec < mse_hold):\n",
+ " raise AssertionError(\n",
+ " f\"stub recursive MSE {mse_rec} was not below hold-last {mse_hold}\"\n",
+ " )\n",
+ "print(\"stub recursive beats hold-last on the oracle one-step (wiring check).\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t50-perm-md",
+ "metadata": {},
+ "source": [
+ "Dense pairwise logits must **permute with nodes**. If $P$ permutes\n",
+ "rows of $z$, then $\\mathrm{logits}(Pz) = P\\,\\mathrm{logits}(z)\\,P^{\\top}$.\n",
+ "That is not entity resolution: a changing node count still requires a\n",
+ "user-supplied injective `EntityRemap` into a finite $N_{\\max}$. Silent\n",
+ "universe merge and unbounded growth remain refused.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "t50-perm",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T22:17:05.235561Z",
+ "iopub.status.busy": "2026-08-20T22:17:05.235497Z",
+ "iopub.status.idle": "2026-08-20T22:17:05.240893Z",
+ "shell.execute_reply": "2026-08-20T22:17:05.240563Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "dense logits permute with the node order (atol=1e-5).\n",
+ "EntityRemap is still required if N changes; this demo keeps N fixed.\n"
+ ]
+ }
+ ],
+ "source": [
+ "torch.manual_seed(5)\n",
+ "head = PredictedTopologyHead(LATENT_DIM, hidden_dim=8, max_nodes=8)\n",
+ "z = torch.randn(N, LATENT_DIM)\n",
+ "perm = torch.tensor([2, 0, 4, 1, 3, 5])\n",
+ "logits = head.pairwise_logits(z)\n",
+ "logits_perm = head.pairwise_logits(z[perm])\n",
+ "torch.testing.assert_close(logits_perm, logits[perm][:, perm], atol=1e-5, rtol=0.0)\n",
+ "print(\"dense logits permute with the node order (atol=1e-5).\")\n",
+ "print(\"EntityRemap is still required if N changes; this demo keeps N fixed.\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t50-results-md",
+ "metadata": {},
+ "source": [
+ "## Results\n",
+ "\n",
+ "The bar chart repeats the two MSE values from the stub comparison. The\n",
+ "prints under it report the COO sizes behind the leftover stub error.\n",
+ "\n",
+ "Look at two quantities. First, the gap: hold-last is the pre-event\n",
+ "cycle, so it cannot match an oracle that already uses the post-event\n",
+ "arcs. Second, the stub MSE is small but not zero. Recursive decode\n",
+ "keeps every off-diagonal pair with sigmoid weights\n",
+ "$\\sigma(8)\\approx 0.9997$ and $\\sigma(-8)\\approx 3.4\\times 10^{-4}$\n",
+ "on a dense COO of size $N(N-1)=30$. The oracle one-step uses the\n",
+ "unweighted directed $G(n,p)$ graph (`edge_weight` omitted). That\n",
+ "residual is the sigmoid mechanism, not a failed wiring check, and it\n",
+ "does **not** show that a trained GNN encoder learned the event.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "t50-results",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T22:17:05.241894Z",
+ "iopub.status.busy": "2026-08-20T22:17:05.241827Z",
+ "iopub.status.idle": "2026-08-20T22:17:05.484197Z",
+ "shell.execute_reply": "2026-08-20T22:17:05.483814Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "hold-last MSE 0.070864 stub MSE 0.001610\n",
+ "oracle COO E 8 edge_weight None\n",
+ "recursive COO E 30 N*(N-1) 30\n",
+ "recursive sigmoid min/max 0.000335 0.999665\n"
+ ]
+ }
+ ],
+ "source": [
+ "fig, ax = plt.subplots(figsize=(5.2, 3.2))\n",
+ "labels = [\"hold-last\", \"stub recursive\"]\n",
+ "values = [mse_hold, mse_rec]\n",
+ "ax.bar(labels, values, color=[\"#6c757d\", \"#2a6f97\"])\n",
+ "ax.set_ylabel(\"MSE vs oracle one-step (dimensionless)\")\n",
+ "ax.set_title(r\"Six-node directed $G(n,p)$ event (seed 6 / graph seed 11)\")\n",
+ "for x, y in zip(labels, values):\n",
+ " ax.text(x, y, f\"{y:.4f}\", ha=\"center\", va=\"bottom\")\n",
+ "fig.tight_layout()\n",
+ "plt.show()\n",
+ "print(\"hold-last MSE\", f\"{mse_hold:.6f}\", \"stub MSE\", f\"{mse_rec:.6f}\")\n",
+ "print(\n",
+ " \"oracle COO E\",\n",
+ " int(truth.edge_index.shape[1]),\n",
+ " \"edge_weight\",\n",
+ " truth.edge_weight,\n",
+ ")\n",
+ "print(\n",
+ " \"recursive COO E\",\n",
+ " int(recursive.edge_index.shape[1]),\n",
+ " \"N*(N-1)\",\n",
+ " N * (N - 1),\n",
+ ")\n",
+ "assert recursive.edge_weight is not None\n",
+ "print(\n",
+ " \"recursive sigmoid min/max\",\n",
+ " f\"{float(recursive.edge_weight.min()):.6f}\",\n",
+ " f\"{float(recursive.edge_weight.max()):.6f}\",\n",
+ ")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t50-interpret",
+ "metadata": {},
+ "source": [
+ "## Interpretation / discussion\n",
+ "\n",
+ "The experiment answers a wiring question that notebook 18 left open:\n",
+ "when the next adjacency is **not** supplied, does predicted topology\n",
+ "enter $K_{\\mathrm{eff}}$? Yes, as sigmoid weights on the scored COO.\n",
+ "The leftover stub MSE is that dense sigmoid COO versus the unweighted\n",
+ "oracle graph, not numerical noise. Hold-last remains the documented\n",
+ "control (`topology_policy=\"hold_last\"` or `graph_dynamics=None`).\n",
+ "\n",
+ "Tradeoffs:\n",
+ "\n",
+ "- `sparse_candidate` is the configured default because dense $N\\times N$\n",
+ " scoring has an $N$ ceiling of 64 (`dense_mlp`).\n",
+ "- Recursive training is refused with `batch_graphs=True`.\n",
+ "- RelGraph / hypergraph layouts raise.\n",
+ "- A trained encoder is **not** certified here. Negative or null\n",
+ " learned-forecast gaps remain allowed outcomes, as on the topology\n",
+ " transfer path (notebook 37).\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t50-take",
+ "metadata": {},
+ "source": [
+ "## Takeaways\n",
+ "\n",
+ "- Reach for `GraphDynamicsConfig` when you need predicted\n",
+ " $\\hat A_{t+1}$ in `fit` / `predict` / `evaluate` / UQ.\n",
+ "- Keep `graph_dynamics=None` (or `topology_policy=\"hold_last\"`) when the\n",
+ " 0.14 exogenous-edge schedule is the scientific control.\n",
+ "- The six-node directed $G(n,p)$ stub beating hold-last is a wiring\n",
+ " check against the model's own oracle one-step, not a learned-forecast\n",
+ " or SOTA claim. Stub MSE is not zero: sigmoid weights on all\n",
+ " off-diagonal pairs versus a hard oracle COO.\n",
+ "- Dense logits permute with node order. Changing $N$ needs\n",
+ " `EntityRemap`; unbounded growth is refused.\n",
+ "- Do not combine a topology head with `learn_topology=\"self_adaptive\"`.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t50-read",
+ "metadata": {},
+ "source": [
+ "## Further reading\n",
+ "\n",
+ "- Package API: [`GraphDynamicsConfig`](https://koopmangraph.readthedocs.io/en/latest/api.html),\n",
+ " [`GraphStateSnapshot`](https://koopmangraph.readthedocs.io/en/latest/api.html),\n",
+ " [`graph_state_at`](https://koopmangraph.readthedocs.io/en/latest/api.html),\n",
+ " [`SparseCandidateTopologyHead`](https://koopmangraph.readthedocs.io/en/latest/api.html),\n",
+ " [`PredictedTopologyHead`](https://koopmangraph.readthedocs.io/en/latest/api.html),\n",
+ " [`EntityRemap`](https://koopmangraph.readthedocs.io/en/latest/api.html)\n",
+ "- Docs: [Predicted graph dynamics](https://koopmangraph.readthedocs.io/en/latest/graph_dynamics.html),\n",
+ " [Scope and limitations](https://koopmangraph.readthedocs.io/en/latest/limitations.html)\n",
+ " (predicted topology / wiring check),\n",
+ " [Architecture](https://koopmangraph.readthedocs.io/en/latest/architecture.html),\n",
+ " [Data](https://koopmangraph.readthedocs.io/en/latest/data.html)\n",
+ "- Related notebooks: `18_networked_koopman_dynamic_topology.ipynb`\n",
+ " (exogenous `future_topologies`), `28_adaptive_topology_learning.ipynb`\n",
+ " (static Graph WaveNet adjacency), `41_node_churn_presence_masks.ipynb`\n",
+ " (fixed-union presence), `37_cross_topology_transfer.ipynb` (measured\n",
+ " transfer; negative advantage allowed)\n",
+ "- Citations (`paper.bib`): `Wu2019WaveNet` (Wu et al., Graph WaveNet,\n",
+ " IJCAI 2019)\n"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "koopmangraph",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.13"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/51_spectral_diagnostics.ipynb b/examples/51_spectral_diagnostics.ipynb
new file mode 100644
index 0000000..c364109
--- /dev/null
+++ b/examples/51_spectral_diagnostics.ipynb
@@ -0,0 +1,645 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "t51-title",
+ "metadata": {},
+ "source": [
+ "# Spectral diagnostics: non-normal shear and Nyquist toys\n",
+ "\n",
+ "This notebook reads `SpectralDiagnostics` from discrete\n",
+ "`compute_spectrum` on four maps: the identity, a $2\\times 2$\n",
+ "non-normal shear with a closed-form $\\kappa_i$, a\n",
+ "Nyquist-adjacent $\\lambda=-1$ diagonal, and a $\\rho<1$\n",
+ "shear whose $\\|K^{k}\\|$ exceeds $\\rho^{k}$. It shows when\n",
+ "to inspect $\\kappa(V)$, Wilkinson $\\kappa_i$, and Nyquist\n",
+ "flags, and when those fields are not a bound.\n",
+ "\n",
+ "**Honesty.** These fields are not a finite-horizon bound on\n",
+ "$\\|K^{k}\\|$ and not a sampling-theorem identification\n",
+ "result. `CONDITION_WARN` is $10^{6}$. Frequencies are\n",
+ "principal values in cycles per unit time; phase unwrapping\n",
+ "does not recover aliases. This notebook does **not**\n",
+ "download METR-LA.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t51-setup-md",
+ "metadata": {},
+ "source": [
+ "## Setup\n",
+ "\n",
+ "Package version, Float64 toys, and warning capture.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "t51-setup",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T23:16:03.807567Z",
+ "iopub.status.busy": "2026-08-20T23:16:03.807433Z",
+ "iopub.status.idle": "2026-08-20T23:16:09.521248Z",
+ "shell.execute_reply": "2026-08-20T23:16:09.520865Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "koopman_graph 0.15.0\n",
+ "CONDITION_WARN 1000000.0\n"
+ ]
+ }
+ ],
+ "source": [
+ "from __future__ import annotations\n",
+ "\n",
+ "import math\n",
+ "import os\n",
+ "import warnings\n",
+ "\n",
+ "import matplotlib\n",
+ "\n",
+ "if os.environ.get(\"PYTEST_CURRENT_TEST\"):\n",
+ " matplotlib.use(\"Agg\")\n",
+ "\n",
+ "import matplotlib.pyplot as plt\n",
+ "import torch\n",
+ "\n",
+ "import koopman_graph\n",
+ "from koopman_graph.spectrum_types import (\n",
+ " CONDITION_WARN,\n",
+ " DefectiveSpectrumError,\n",
+ " KoopmanSpectrum,\n",
+ " compute_spectrum,\n",
+ ")\n",
+ "\n",
+ "print(\"koopman_graph\", koopman_graph.__version__)\n",
+ "print(\"CONDITION_WARN\", CONDITION_WARN)\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t51-motivation",
+ "metadata": {},
+ "source": [
+ "## Motivation and background\n",
+ "\n",
+ "A list of $|\\lambda|$ or a unit-disk sketch is the naive\n",
+ "check. It misses two failure modes this helper flags.\n",
+ "\n",
+ "First, a non-normal map can produce finite-horizon\n",
+ "transients with $\\|K^{k}\\|$ above $\\rho(K)^{k}$ even when\n",
+ "every $|\\lambda|\\le 1$ (Trefethen and Embree 2005, *Spectra\n",
+ "and Pseudospectra*,\n",
+ "[doi:10.1515/9780691213101](https://doi.org/10.1515/9780691213101)).\n",
+ "`SpectralDiagnostics` reports the 2-norm condition\n",
+ "$\\kappa(V)$ of **column-normalized** right eigenvectors,\n",
+ "per-mode Wilkinson sensitivities\n",
+ "$\\kappa_i=1/|w_i^{H} v_i|$ (Wilkinson 1965, *The Algebraic\n",
+ "Eigenvalue Problem*), and Frobenius departure\n",
+ "$\\|KK^{H}-K^{H}K\\|_{F}$. None of those quantities is a\n",
+ "bound on $\\|K^{k}\\|$.\n",
+ "\n",
+ "Second, a real eigenvalue near $-1$ sits at discrete\n",
+ "Nyquist phase. The Nyquist frequency is $1/(2\\Delta t)$ in\n",
+ "cycles per unit time. Reported frequencies remain principal\n",
+ "values. Zeng, Yue, Mauroy, Gonçalves, and Yuan (2022) is\n",
+ "related sampling-theorem literature for continuous-time\n",
+ "identification\n",
+ "([doi:10.1109/CDC51059.2022.9992482](https://doi.org/10.1109/CDC51059.2022.9992482));\n",
+ "this helper does not implement that theorem.\n",
+ "\n",
+ "The identity is the normal control\n",
+ "($\\kappa(V)\\approx 1$, departure $0$). The triangular\n",
+ "shear $[[1,a],[0,2]]$ has a closed-form $\\kappa_i$ and\n",
+ "$\\rho=2$, so radius alone already grows. A separate\n",
+ "$\\rho<1$ shear isolates transients later.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t51-minimal-md",
+ "metadata": {},
+ "source": [
+ "## Minimal example\n",
+ "\n",
+ "Identity versus the $a=10$ shear $[[1,10],[0,2]]$. Both\n",
+ "$\\kappa_i$ equal $\\sqrt{1+a^{2}}$. The printed $\\kappa(V)$\n",
+ "is compared with `CONDITION_WARN`: this shear is\n",
+ "non-normal and well below the amplitude warning. Its\n",
+ "$\\rho=2$ is a spectral-radius fact, not the non-normality\n",
+ "demo.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "t51-minimal",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T23:16:09.522507Z",
+ "iopub.status.busy": "2026-08-20T23:16:09.522340Z",
+ "iopub.status.idle": "2026-08-20T23:16:09.526515Z",
+ "shell.execute_reply": "2026-08-20T23:16:09.526179Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "identity kappa(V) 1.0\n",
+ "identity departure 0.0\n",
+ "identity Nyquist 0.5\n",
+ "shear kappa_i [10.049875621120888, 10.04987562112089] oracle 10.04987562112089\n",
+ "shear kappa(V) 20.049875621120886 CONDITION_WARN 1000000.0\n",
+ "shear departure 142.12670403551894\n",
+ "shear rho 2.0\n"
+ ]
+ }
+ ],
+ "source": [
+ "identity = compute_spectrum(torch.eye(2, dtype=torch.float64), time_step=1.0)\n",
+ "assert identity.diagnostics is not None\n",
+ "print(\"identity kappa(V)\", identity.diagnostics.eigenvector_condition)\n",
+ "print(\"identity departure\", identity.diagnostics.departure_from_normality)\n",
+ "print(\"identity Nyquist\", identity.diagnostics.nyquist_frequency)\n",
+ "\n",
+ "shear = 10.0\n",
+ "nonnormal = compute_spectrum(\n",
+ " torch.tensor([[1.0, shear], [0.0, 2.0]], dtype=torch.float64),\n",
+ " time_step=1.0,\n",
+ ")\n",
+ "diag = nonnormal.diagnostics\n",
+ "assert diag is not None\n",
+ "expected_kappa_i = math.sqrt(1.0 + shear**2)\n",
+ "print(\"shear kappa_i\", diag.eigenvalue_sensitivities.tolist(), \"oracle\", expected_kappa_i)\n",
+ "print(\n",
+ " \"shear kappa(V)\",\n",
+ " diag.eigenvector_condition,\n",
+ " \"CONDITION_WARN\",\n",
+ " CONDITION_WARN,\n",
+ ")\n",
+ "print(\"shear departure\", diag.departure_from_normality)\n",
+ "print(\"shear rho\", float(nonnormal.magnitudes.max()))\n",
+ "assert diag.eigenvector_condition > 1.0\n",
+ "assert diag.eigenvector_condition < CONDITION_WARN\n",
+ "assert diag.departure_from_normality > 0.0\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t51-deep-nyquist-md",
+ "metadata": {},
+ "source": [
+ "## Progressive deep dive\n",
+ "\n",
+ "Diagonal $\\mathrm{diag}(-1, 0.5)$ at $\\Delta t=0.25$.\n",
+ "Nyquist frequency is $1/(2\\Delta t)=2$ cycles per unit\n",
+ "time. $\\lambda=-1$ has argument $\\pi$ and sets one\n",
+ "aliasing flag; the second mode does not.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "t51-deep-nyquist",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T23:16:09.527680Z",
+ "iopub.status.busy": "2026-08-20T23:16:09.527614Z",
+ "iopub.status.idle": "2026-08-20T23:16:09.530152Z",
+ "shell.execute_reply": "2026-08-20T23:16:09.529820Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Nyquist warning fired: One or more discrete-time eigenvalues have argument within 0.001 rad of pi (Nyqu ...\n",
+ "nyquist_frequency 2.0 oracle 2.0\n",
+ "aliasing_warning [True, False]\n"
+ ]
+ }
+ ],
+ "source": [
+ "time_step = 0.25\n",
+ "caught: list[warnings.WarningMessage] = []\n",
+ "with warnings.catch_warnings(record=True) as caught:\n",
+ " warnings.simplefilter(\"always\", UserWarning)\n",
+ " nyquist = compute_spectrum(\n",
+ " torch.diag(torch.tensor([-1.0, 0.5], dtype=torch.float64)),\n",
+ " time_step=time_step,\n",
+ " )\n",
+ "nyquist_warnings = [w for w in caught if \"Nyquist\" in str(w.message)]\n",
+ "assert nyquist_warnings, \"expected a Nyquist UserWarning\"\n",
+ "assert nyquist.diagnostics is not None\n",
+ "print(\"Nyquist warning fired:\", str(nyquist_warnings[0].message)[:80], \"...\")\n",
+ "print(\n",
+ " \"nyquist_frequency\",\n",
+ " nyquist.diagnostics.nyquist_frequency,\n",
+ " \"oracle\",\n",
+ " 1.0 / (2.0 * time_step),\n",
+ ")\n",
+ "print(\"aliasing_warning\", nyquist.diagnostics.aliasing_warning.tolist())\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t51-deep-cond-md",
+ "metadata": {},
+ "source": [
+ "Nearly parallel eigenvector columns exceed\n",
+ "`CONDITION_WARN` on `mode_amplitudes`. The solve may still\n",
+ "proceed. This cell builds a manual `KoopmanSpectrum` with\n",
+ "`diagnostics=None`; the method then recomputes $\\kappa(V)$\n",
+ "from $V$.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "t51-deep-cond",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T23:16:09.531257Z",
+ "iopub.status.busy": "2026-08-20T23:16:09.531184Z",
+ "iopub.status.idle": "2026-08-20T23:16:09.533795Z",
+ "shell.execute_reply": "2026-08-20T23:16:09.533423Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "CONDITION_WARN warning fired\n",
+ "manual spectrum diagnostics None\n"
+ ]
+ }
+ ],
+ "source": [
+ "scale = 1e-8\n",
+ "vectors = torch.tensor(\n",
+ " [[1.0 + 0.0j, 1.0 + 0.0j], [0.0 + 0.0j, scale]],\n",
+ " dtype=torch.complex128,\n",
+ ")\n",
+ "ill = KoopmanSpectrum(\n",
+ " eigenvalues=torch.ones(2, dtype=torch.complex128),\n",
+ " eigenvectors=vectors,\n",
+ " magnitudes=torch.ones(2, dtype=torch.float64),\n",
+ " growth_rates=torch.zeros(2, dtype=torch.float64),\n",
+ " frequencies=torch.zeros(2, dtype=torch.float64),\n",
+ " time_step=1.0,\n",
+ ")\n",
+ "assert ill.diagnostics is None\n",
+ "amp_caught: list[warnings.WarningMessage] = []\n",
+ "with warnings.catch_warnings(record=True) as amp_caught:\n",
+ " warnings.simplefilter(\"always\", UserWarning)\n",
+ " _ = ill.mode_amplitudes(torch.tensor([1.0, 0.5], dtype=torch.float64))\n",
+ "cond_warnings = [w for w in amp_caught if \"CONDITION_WARN\" in str(w.message)]\n",
+ "assert cond_warnings, \"expected a CONDITION_WARN UserWarning\"\n",
+ "print(\"CONDITION_WARN warning fired\")\n",
+ "print(\"manual spectrum diagnostics\", ill.diagnostics)\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3192fb31",
+ "metadata": {},
+ "source": [
+ "A zero column in $V$ is singular.\n",
+ "`mode_amplitudes` raises `DefectiveSpectrumError` (a\n",
+ "`LinAlgError`) with a Schur-subspace hint; it does not\n",
+ "compute a Schur form."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "0d60c9b1",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T23:16:09.534771Z",
+ "iopub.status.busy": "2026-08-20T23:16:09.534710Z",
+ "iopub.status.idle": "2026-08-20T23:16:09.537352Z",
+ "shell.execute_reply": "2026-08-20T23:16:09.537071Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "DefectiveSpectrumError fired: Eigenvector matrix is singular (defective or numerically non-diagonalizable). Do ...\n"
+ ]
+ }
+ ],
+ "source": [
+ "singular = KoopmanSpectrum(\n",
+ " eigenvalues=torch.ones(2, dtype=torch.complex128),\n",
+ " eigenvectors=torch.tensor(\n",
+ " [[1.0 + 0.0j, 0.0 + 0.0j], [0.0 + 0.0j, 0.0 + 0.0j]],\n",
+ " ),\n",
+ " magnitudes=torch.ones(2, dtype=torch.float64),\n",
+ " growth_rates=torch.zeros(2, dtype=torch.float64),\n",
+ " frequencies=torch.zeros(2, dtype=torch.float64),\n",
+ " time_step=1.0,\n",
+ ")\n",
+ "raised: DefectiveSpectrumError | None = None\n",
+ "try:\n",
+ " singular.mode_amplitudes(torch.ones(2, dtype=torch.float64))\n",
+ "except DefectiveSpectrumError as exc:\n",
+ " raised = exc\n",
+ "assert raised is not None\n",
+ "print(\"DefectiveSpectrumError fired:\", str(raised)[:80], \"...\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t51-results-disk-md",
+ "metadata": {},
+ "source": [
+ "## Results\n",
+ "\n",
+ "Unit-disk sketch of the identity and the $\\rho=2$ shear.\n",
+ "The identity is an open blue marker drawn on top of the\n",
+ "shear so the coincidence at $\\lambda=1$ stays visible.\n",
+ "Filled orange marks the shear at $\\lambda=1$ (on the\n",
+ "circle) and $\\lambda=2$ (outside). The point at\n",
+ "$\\mathrm{Re}\\,\\lambda=2$ is spectral radius, not\n",
+ "non-normality: the circle does not encode $\\kappa(V)$ or\n",
+ "departure.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "t51-results-disk",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T23:16:09.538465Z",
+ "iopub.status.busy": "2026-08-20T23:16:09.538390Z",
+ "iopub.status.idle": "2026-08-20T23:16:09.691142Z",
+ "shell.execute_reply": "2026-08-20T23:16:09.690625Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": "iVBORw0KGgoAAAANSUhEUgAAAasAAAEuCAYAAAAwbD0GAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlcelbwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAWI1JREFUeJztnQm4jGX7wB9rWcpO9n2nhOxFoUKlqIQQKUnK1r58aaOoFP2RfKmvSEShQgjZlyiyL9kTjiV7h/d//W6905w5c+bMnDPLOzP377re65yZeff3eZ/7udcng2VZllEURVEUB5Mx0iegKIqiKKmhwkpRFEVxPCqsFEVRFMejwkpRFEVxPCqsFEVRFMejwkpRFEVxPCqsFEVRFMejwkpRFEVxPCqsUuC3334zI0aMMKdOnQrvE4kSpkyZYqZOnWpimWi5xkWLFpnJkyebWOLgwYPy/h0+fNiv78PBhAkTzLfffhuWc/f3WE5m9uzZ5tNPP03y3X//+1/z66+/pml/GeKhgsXWrVvNrFmzXJ+zZs1qcuXKZSpUqGCqVatmsmTJkmybUaNGmZ49e5o9e/aYYsWKGafx5Zdfmssuu8y0bt06Isdv1KiRyZw5s5k/f76JVaLhGv/66y9Tvnx589JLL5lHH300ZtrcsmXLTP369c2aNWtMjRo1Uv2ejp139ZFHHkm2L37buXOnadiwobn22muT/c5233zzjSlatKi56667Ujwn+opSpUqZGTNmBPXc03MsJ3PffffJNf/++++u75588knpi7kHmTJlCmh/caFZrV692vTu3Vsa7aZNm+RGMRKlYRYuXNg899xz5syZM8kaS69evUzOnDmNE3nrrbfM8OHDI3b8tm3b+nyxlfAwZMgQeem7d+8e823OFyNHjjR9+vRJ9v0bb7xhbrvtNrN48WJ5pz1JSEgwt9xyi/QPbdq0MR999FGYzjg+efrpp82WLVuSaVz+kNnEEQ8++KC5++67XZ9RKhkt8v2CBQvM3LlzzeWXX+4aVbMo3unbt6/emghz/vx5M3r0aPPAAw+ItUAxSd5thNf7778vgui9994zGTJkSHKLGKDefvvtZseOHWbixIlmzJgxopkVKlRIvleCT/78+U2rVq3kuXTt2jWgbeNKWHlC423Xrp2YUh566CGxJw8YMMDls/rxxx/lhubIkcO1zdGjR81PP/1kDh06ZEqUKGHq1atnrrjiCq8jNnu9smXLiuCzzY0///yzWbJkiXn44YfN2bNnRS0+cuRIEhPG+vXrZb2///5bTAa1atVy/TZ27FjZ78mTJ+WcAQ2QTssdX/tIjdS2xZ/D/fPUrugAfvjhBzm/mjVritkF2/Uff/xhOnfuHPBxsN1feeWV0sA3btxoli5danLnzi2jYffnMmnSJJOYmGjat2+f7BiYIlatWiWDkmzZspmZM2eabdu2yW9oJXnz5hUTkT/mXjo0zG5NmjRJ8j37xBdx//33B3yNsGHDBrHlI4CqVKliateuneq5YCn4888/zT333JPsN3/vm83FixdF+9i8ebMM2Bo0aGDKlCkTcJvzvCdpvc/pgfvMeY0fP9689tpr5vnnn0+2zoULF8RMhZVl2rRp5uabbzZ33HGHaFf0CXPmzJF74M+xPvnkE3kXaN/eXArphXaxatUqky9fPtO8eXOTPXv2gJ9foG030PZDP8Z7T3u03/uUoL3ynnLvfa2XDCsOmDBhAn45a9KkSV5/P3funJUzZ06rRo0aru9Gjhwp2+zZs8f13eTJk60cOXJYDRs2tLp37261aNHCKlu2rPXdd98l2d+bb75pZcuWTfbXpUsXq2nTplb16tWtrVu3yu+DBg2Sfc+ePVvW6dChg1W+fHn5LSEhwWrZsqWVPXt264477rDuu+8+K0+ePNbtt99unTp1StZ5+umnrQIFCljFihWzevXqJcsLL7zgOr4/+0gJf7flHjRu3DjJths3brRKlixpFSlSxOrYsaPVpEkTq2/fvla7du3k+7Qcp2rVqlarVq3knjZq1Mjq1KmTddVVV8n+9u/f71rvueeeszJmzGjt3r072TXVrl3buuaaa1yfP/roI9d9e+ihh6zrr7/eypQpk/XGG28k2c7bNebKlcvq0aNHsmO0bdtW2kKg13jx4kWrc+fO0q7atGljde3a1apXr57VoEED68iRIz6elGU99thj0s7+/vvvZL/5e99g586dcn/y588v59i8eXO5H9yfCxcu+NXmvOHvffbG0qVL5R1Zs2aNX99zrZdddpl18uRJ65ZbbpHjfPjhhynun/eXd/7HH39M1hfwrPLmzWtt2LDB6z214fnQxgsWLGgtWbIkzefuDftY3GPaYceOHa2iRYtaxYsXt9avXx/w8wu07QbSfjZv3myVLl3a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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "fig, ax = plt.subplots(figsize=(4.2, 4.2))\n",
+ "circ = plt.Circle((0.0, 0.0), 1.0, fill=False, color=\"0.7\")\n",
+ "ax.add_patch(circ)\n",
+ "ax.scatter(\n",
+ " nonnormal.eigenvalues.real,\n",
+ " nonnormal.eigenvalues.imag,\n",
+ " s=55,\n",
+ " color=\"C1\",\n",
+ " zorder=3,\n",
+ " label=\"shear a=10\",\n",
+ ")\n",
+ "ax.scatter(\n",
+ " identity.eigenvalues.real,\n",
+ " identity.eigenvalues.imag,\n",
+ " s=120,\n",
+ " facecolors=\"none\",\n",
+ " edgecolors=\"C0\",\n",
+ " linewidths=1.8,\n",
+ " zorder=4,\n",
+ " label=\"identity\",\n",
+ ")\n",
+ "ax.annotate(\n",
+ " r\"identity and shear $\\lambda=1$\",\n",
+ " xy=(1.0, 0.0),\n",
+ " xytext=(0.15, 0.55),\n",
+ " fontsize=8,\n",
+ " arrowprops={\"arrowstyle\": \"->\", \"color\": \"0.4\"},\n",
+ ")\n",
+ "ax.annotate(\n",
+ " r\"shear $\\lambda=2$\",\n",
+ " xy=(2.0, 0.0),\n",
+ " xytext=(1.15, -0.55),\n",
+ " fontsize=8,\n",
+ " arrowprops={\"arrowstyle\": \"->\", \"color\": \"0.4\"},\n",
+ ")\n",
+ "ax.set_aspect(\"equal\", adjustable=\"box\")\n",
+ "ax.set_xlabel(r\"$\\mathrm{Re}\\,\\lambda$\")\n",
+ "ax.set_ylabel(r\"$\\mathrm{Im}\\,\\lambda$\")\n",
+ "ax.set_title(\"Discrete eigenvalues (not a ||K^k|| bound)\")\n",
+ "ax.legend()\n",
+ "fig.tight_layout()\n",
+ "plt.show()\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t51-results-transient-md",
+ "metadata": {},
+ "source": [
+ "The next map is $[[0.9, 10],[0, 0.5]]$ with $\\rho=0.9<1$.\n",
+ "Both eigenvalues lie inside the unit disk. The plot is\n",
+ "$\\|K^{k}\\|_{2}$ versus $\\rho^{k}$. Look for the printed\n",
+ "peak of $\\|K^{k}\\|_{2}$ near $k=3$, while $\\rho^{k}$ is\n",
+ "already below $1$. That gap is the transient the spectrum\n",
+ "does not certify. `SpectralDiagnostics` on this map has\n",
+ "$\\kappa(V)>1$ and positive departure; it does not compute\n",
+ "$\\|K^{k}\\|$.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "t51-results-transient",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-20T23:16:09.692319Z",
+ "iopub.status.busy": "2026-08-20T23:16:09.692234Z",
+ "iopub.status.idle": "2026-08-20T23:16:09.753271Z",
+ "shell.execute_reply": "2026-08-20T23:16:09.752809Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "inside-disk evals [0.9, 0.5]\n",
+ "inside-disk kappa(V) 50.01999200639361\n",
+ "inside-disk departure 141.53444810363305\n",
+ "peak k 3 ||K^k|| 15.118102713923355 rho^k 0.7290000000000001\n"
+ ]
+ },
+ {
+ "data": {
+ "image/png": "iVBORw0KGgoAAAANSUhEUgAAAdYAAAE1CAYAAABaysF2AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlcelbwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAU6pJREFUeJztnQd0VFUXhTfppAMJSaiSUEIvoQkIUpReFKRKVVDEApZfQEWsgAVsNJGiSJMmRZAi0nsCoXcIHUJLSG/zr3MnM06SCSQwyZTsb627Xp2ZN+/NvP3OueecW0ij0WhACCGEEJNgZ5q3IYQQQgiFlRBCCDExtFgJIYQQE0JhJYQQQkwIhZUQQggxIRRWQgghxIRQWAkh+cbVq1eRmJjIM24CYmJiEBkZyXNpgVBYCSH5RtWqVXH58mWecRPw9ddf49NPP+W5tEAorISQfOHixYtISkpCuXLleMZNwOHDh1GtWjWeSwuEwkoIyRcOHTqEKlWqwM5Oe9tJTk7GhQsX6Bp+jPNpKKw3btyga9hCoLASkgeEh4cjNDTUqs/trVu3sHnzZmVlnj59Gvv27XtsIahevbq+r7VZs2aYOnUqHB0d1bpjx47h4MGDan7Hjh2IiIh46HueOXMG27ZtQ0EjNjYW58+f1wvrt99+i2eeeQZRUVHmPjRCYSUFnWvXrinxOH78uEnf95133sErr7wCa2b79u1o3rw57ty5g++//x4DBw7Mss/du3fV+ROhzIzc5GWbCF9aWpredSmi+dRTT+HNN9/EhAkT9Bas9BcOHz5czXfu3Blz58596DH+9NNPaNu2LSyR6Oho9f3lAcXUHDlyBCVLllQPJb169cLWrVvVeS1fvrzJP4vkHlqspEAyefJkZTHVrVtXiccXX3xh7kOySv744w91/sQaNUSWQ0JC0K1bNyQkJCjxlHW7d+/GgAEDsGTJEvTs2fOxP1+ERETaEhELXM6NPKCYGjmXXl5eaNSokToHf/75Jzw8PEz+OeTRoLASm0T678SaetBNTywkcXGSR2f//v1qKg8oOn777Tc0bNgQRYsWRVhYmHJRijv51KlTykX+8ssvo3bt2iY57a+//jrWrl2LgoZY/3JOJeVm5MiRKFSokLkPiRjgYLhAiLlcZhMnTsTevXtVIEvNmjXx4Ycfqhtzbtm1axd+//13ZUlNmjQJL774YrYWqyDW1KMigTcSMFKqVCnllssO+Qxx3bm6uqJy5crZ3gTlvc6dOwd3d3cV5GNvb59hu/Q/3rt3T83LNl9fX1SsWFHvStUhnxUXF4f69eurm688RAi1atXC9evXceLECQQHB8Pf3x+Pi/Qjly1bFj4+PuqzxJUr/aaDBw/Gjz/+CGdnZ7WfHIMc799//62srHr16qFFixaP/fnSxyru/MxWq5wn6YMUV2mFChX0x5EdMnrmli1b1HeRqOXbt2+rhy65ttKye3iT7xUfH6+sRjkHOm7evKkeKnTXw9vbW80HBgaiTJkyD/1ecvzffPONOr9yXuvUqYMPPvhA/z5isX722WdYt24d+vfvj8WLF1NcLQkZj5UQc3Ho0CFNqVKlNBUrVtS88cYbmi5dusj4wJp69epp0tLScvQeJ0+e1Hz00UeawMBA9doSJUpoRowYobl06dJDXxsfH69e06dPnxwfc3h4uKZatWqaokWLaho0aKApW7aspmbNmpqdO3fq92nZsqUmJCREs3nzZvXd6tSpoylcuLCa3rhxI8P7XbhwQdOqVSuNi4uLplatWpqSJUuqtmbNmgz7vfXWW5pmzZqp1rBhQ/X5st+6desy7Ne5c2dNpUqVNFu2bNFUqFBBU716dU3z5s3VttmzZ6vvK9OHsXz5crXvtWvXNMOGDdNUrVo1w/aEhASNo6OjpmvXrpqIiAhN/fr11Xcw9t6//vqrpk2bNmp+06ZNGj8/P82ZM2cy7NOjRw/13YRixYppPvvss4ceo5wTNzc3/XJqaqrm1Vdf1Tg7O2tq1KihqV27tnov+X08iOTkZPVdP/jgA8348eM1QUFB6hrb2dlpXnrppSz7//zzz+r8+/v7q/0cHBw03bt319y7d09t/+eff9Rny3vKedNdt3nz5j30O4WGhqr3DQ4O1rz55puajh07qvdp0qSJfh/57BMnTmgSExM1jRo10owZM+ah70vyDworMRtRUVGagIAATfv27TVxcXH69XIzkRvJ1q1bs33t9evXNd9//70SYNnX29tb3QDlhiY315zyKMJat25ddZOUm5qOI0eOaP74448MwiqCO3DgQE1sbKz+AcDd3V2JlOE5eOKJJ5QI6x4E5IFi1KhRShzkfbNDPl9EpEiRIprIyMgMwirnVW700dHRat3BgwfVdO3aterYZfq4wrpnzx61vXXr1hofHx9NuXLlNGFhYUbfS8Tqiy++0C9PmTJF07Zt2wzn0BTCOmfOHHVM+/fv16+T39a4ceNyJKzyYPPtt9/q18+cOVOtX7VqlX7dkiVL1LrRo0fr18nnidjJudCxa9cutZ+cx5xy+/Ztja+vr+a5555TDy46hgwZot5r7969mps3b6qHNd3vXP4LIuLyEEcsAworMRsjR45UN8XMFpyIo9xE5KaWGbFyxPKxt7dX1lG3bt00y5Yty3CDzg2PIqwi4oMHD37gPiKsYsVcvXo1w3r5HLGodXzzzTfq88VKyXyjF2tUbqiGyM1UBHrbtm2af//9V1mC8vqVK1dmEFZZ9yBRzgkPE1YRR9leqFAhjaurq+bKlSuP9XmmENYPP/xQ4+TkpH+gyCk6YRWPQubzLUI3aNAg/Tp5CBLvSEpKSoZ95cHB8Fo+irAOHz5c4+XlpQTWkL/++ku9V04sXmJ+2MdKzIb0g0qqRPHixTOslz4lwc3NLctrzp49q/rppI/xjTfewJAhQ/I9xeC5557DL7/8otIoOnXqpKKLjVUTKl26NAICAjKsk/3mz5+v+uek/0/SJAoXLqz6RCV6NP1hVzV5ra6fTli0aBHefvttta98Zzk/urq7ly5dyvA5np6eqnxgfgQu/fzzz+o6jBgxAgsXLjRrX1/Hjh0xfvx4FZHcr18/PP3002jQoIE+V/ZhyL6GSP+19LtK1SghNTUVBw4cQO/evbP0gTdt2lRNJVZA+kRzi1xz6SuV75A5vuBB/wlieVBYiVkQgZRAnddeey3LtqNHj6qpBLhk5tlnn9UHKM2ZM0fVS5X9JJevR48eKFGiRJ4f+4wZM1SwzNKlS5XQSfSxBFxNmzZNRcPqMBZ8JUE0cgMVQZSb/f3799V6CdbKjNxE5aYuSERtnz59VG6s5JQ6OGj/uhI8IwIqeaKG+Pn5Ia8RYZVgHInylYeMUaNGISgoCF9++SXMhQRsScCPXAv5jYwZM0YFg0lQVU7q6mZ3zSRASZDrJudaHlwyo1un2ze3yLW8cuVKht+Q4X9CHlgMo6+J5cJ0G2IWdJaYsRuUPLWLxSE3bWPIjUcKA0hRgtWrV6ubuQiTWIgSaSrCJ0UN8gqxVKRYgny2RI9KYr6kPUjOZm554oknlNBu2LBBFRPI3H799Ve1n8yLtSRWuk5UhewKW2SOFDY1EuksQiDXSZCUD8lPHTduHGbNmgVzUqNGDUyZMkVFP0vEcN++fVUEreR6Pi4S2S3iKxHHmdGtk9+hkFvLPbv/hPw+JO9XoqkfFH1OLAcKKzEL4k4T9uzZk2G9uFhl3UcfffTQ9xCBad++PRYsWKBSVWbPnq3WDR06VKWSrFmzxuTHLdaKpAfpkJun3PDEpS03cZ3LLqeIu1JESiwsY+hycYsVK6amhsOEidDq0oZyiqTbiEjL9HGQ1J+UlBS9sArTp09X7tBXX30VmzZtgjnInLsslrtUeBJyUiIxp10BGzduVB4XQ+QaSpGGVq1aqeUiRYqoqTx0Pc5/Qq6x5P8a82oQy4SuYGIW5Olc8vmkT07y/6RPSm74cnOSG6GUtMsN4u4TkZImoiH9mA/qj5KbmJTc0wmhCLN8vvCgHE9xBUpZvi5duii3o+RmSp6iuKVfeuklODk55eq4pQ/w888/Vy5luXlKMQWxiiSHUs6N5OHK+WjdurXe7SruTXmAEMtQLPR///03x58n/dNibctDiFiYpiwMId992bJlyqPQtWtX7Ny5U+Xt5idyLkX05fpIP7Q8BIl3Q/rx5ZhMgbi6JedVrp2Infx+582bp8RWimPock2lP12sV/lNi8jK7/FBeazyn5DXyHWV9xDLW95THjbfe+89tGnTxiTHT/IeCisxCyJs7dq1U+5TcSNKMQFxi4oLTyyex0FEUYTqQcjNS6rXCBJ8JMFEY8eOVctyPNndxCTQSPq7pI6tJOdL36K450TIO3TooN9P+lyNWSrSZyqfZxj4Ion/YvFKn6DcmKXvVQo/iAWoq1AkDw7StyzF1uWz5Eb97rvvqqIPcvM1dBGK8BtzsevOjXz+4xaHkAcSeZ/MQTpiWYuLXPrOpf9bApsMXdemJnNJQzk/Inoi8FKRSSxIuS6DBg1SD0HZIZ6H7ILQMleJEpGWflz5buIVkT5VKUIhgxTI9dAh11HOhQj7d999p35jEuQlgU/GkAcCiRWQOAIRbClwIkIsXRvy0Eash0ISGmzugyAFCwnQkGo2P/zwg+ozJJaJ9EmK21Nc3GIJikUv1nleIbWDda5qsQIl4KiguD8lmE8eEkSspWoVsW7Yx0ryHV1fkqnqxRJi7fA/YVtQWEm+I31JErVq6DYjpKD/J8R1rBuvllg37GMl+c7zzz+v+sWk35BYLuKOlX5HCUqSPkQZXDsvkYEHdH2/jRs31ufwFgTEDS4Bag8bLIBYB+xjJYQQQkwIXcGEEEKICaGwEkIIISaEfazZVNeRcnmSA2fOguKEEEIsA8lMldreUo/8YSVDKaxGEFHV1fskhBBCdMhIUpKH/yAorEYQS1V3ArOrYEMIIaTgEB0drQwunT48CAqrEXTuXxFVCishhBAdOekeZPASIYQQYkIorIQQQogJobASQgghJoR9rIQQYqXIYPcyHB15fKRWs+FwjlYrrCkpKWpoqkWLFqnwZRl/0JCEhAQ1XmdmRowYgZYtWz7wxzZz5kz8888/cHFxQffu3dG+ffs8+Q6EEGKOnEoZYu/evXs8+SZEBpiXetWPW7/AbMIqT1lBQUGoW7euSro9f/68UeH966+/8M0336BSpUr69Ybzxhg4cCD+/fdfNYD03bt30bVrVyXaQ4cOzZPvQggh+YlOVGXQdVdXVxayMcGDSlxcHG7evKmWAwICrFNYxeTes2eP+gIyoPH27duz3VdGumjYsGGOxzWcO3cutm7dqkZQ0TF69GgluGLBFkQOXrqHvw5dRb0niqJJBR+4OrEXgBBrRDxyOlEtVqyYuQ/HZihcuLCairjKuX0ct7DZ7q5SEiqnTwVffvmlGk5JLFwRxwdZrH///Td8fX3RpEkT/TpxJ4uw7t69G08//TQKGhduxaLfzD2ITkjBjG3n4eRgh0ZBxdCysh9aBhdHCW/tD4oQYvno+lTFUiWmRXdO5RxbpbDmFBkHsmnTpkqE165di5o1a2L58uVo27at0f3FpVyyZMkMrhFdeULZZkxYExMTVTOssGErxCSmYPBv+5WoBvq4ISk1DZfvxmPzyUjVPgJQOcATrSoXR4vg4qhZyht2dqyPTIilwzrmlntOHSzdNBfXrpubm1ru06ePitwaNmwYzp07Z/Q1SUlJWZ7kxNqVpw/ZZoxx48bhk08+ga2RlqbB24sO4vTNGBT3cMaCIQ3VVJb/OX4T/xy/gbCLd3H8WrRqP246Ax93JzSvVFxZs09V8IGbs0X/RAghBYy3334br7zyykNjbcyJReexihjqRFVHx44dleUpQUnZRXXduXMnwzrpj5B+CdlmjFGjRiEqKkrfpEawLSBCuf7YDTjZ22F63xD4ebqoJ7KKfh4Y+nQQlgxthP0fPoOJ3WuiffUAeDg74FZMEhaHXsarv4ei8YRNCI0wfp4JIcQcLF++XI0wY8lYnTkigiri4OBg/NDFVTx16lQVaawrlnzw4EH9NmOIRSvNllh/9DombTyl5j9/rhpqlylidL+ibk54vk4p1ZJS0rDvwh1lza47eh1X7sWrvtk5g+qroCdCCHkcEhIS9H2XYuzkNphUDB+J4DVWCP9x37vAWKwbNmzA6dOnM4SYS+pNq1at9Cc2NjYWHTp0UDmrQufOndUJ/e677/Rjq8pr6tWrh+DgYBQETt+4jxGLtA8T/Z8si+51czYEngQ1NS7vgzEdq2DD203xZGAxxCalot/Mvdh19nYeHzUhxNbp0qULJk+ejG+//RY9e/bMsE3u1TExMWpqiIikrJeuvPDwcFSpUkWtF22QYNZTp7QGxIsvvogJEyZg+vTpShPMiVmFVfpK5QSsXLkSZ86cUfPS5MlDENdtjx49UKNGDRXAJIFM5cqVw6+//qp/D4neklxXnfu2aNGimDdvnrpwtWvXRvny5XH8+HH89ttvKAhExSdjyNxQJYgNyhXFhx20P8LcIuk4swbUU/2s8cmpGDhnL7adjjT58RJCiCA6IAaTYVfeyZMnUb16dbRr1055K3XCKuIsXXiSMVKxYkVYGmZ1BYtoGovAlQAlQazM/fv348SJE4iMjERgYGCWAcjd3d2xatUq1KpVS79OxFmEVl4rLl55H9172jKpaRq8tfAAzt+KRUnvwpjSpw4c7R/92amwkz1m9KuL1+aFYdOJm3jp1/2Y/mIImgcXN+lxE0JIaGgoypYtCx8fH3UypCpf//79VZs4caLq/hNhFUOqV69eWLJkicWeNLMKq1ihOcl31Zn+xpCTbczslyef5s2boyDxzfqTKoXGxVEbrFTM/fH7jV0c7THtxRC8Pj9MBUINmbsfk3vXwbNV/U1yzIQQIoSFhSEkJES5gj/88EP88MMPyq0r2SA6RFjF+zhy5EhlcFlq955F97GSnLMq/Cqmbj6r5id0rYFqJb1Mdvqk73Vynzoqcjg5VaMs2LWHr/HyEEJMarGWK1cObdq0UfXjd+zYkUFUpa/12rVreOaZZzB//nz07t0727RLc2N1UcEkK8euRuO9JeFq/pWmgehcq6TJT5O4lL/vWQsO9oWw4uBVvL7gACaladCppmWHvRNSEJBIWYmFyG8KO9qbpKjClStXcOPGDXz//feoU6eO6sYrUiRjJoNYslJZT5Ac1tWrV+vjcSwNCquVcyc2SVVWSkhOQ9OKvvhfm7xzjTjY22Fi91pwsLPD0rDLGL7wAFJS01SqDiHEfIioVhmzLt8/99inrU1Sdzw0NFQf0Cojk0mMTGZhlTiZatWq6ZdzmssqVq6MiCaZI2IRy4AsUn8+L6Er2IoRURs2L0zlm5Yt5oofe9aGfR6XI5T3/7pbDfSsVxppGuCdxeH4Y59tFNQghJiH0NBQNXSopElKyqTEzYggmoJffvkFzz//vMoO6devn0rRyWtosVoxX6w5jl3nbsMtPXrXyzV/Ip+llvCXz1VX7uG5uyPwv6WHkJyWhj4NyubL5xNCsrpkxXo0x+eaMnBJJ4QSeNqpUyds2bLlsQcb+OgjqYiuRWogzJo1C3kNhdVKkcpIs3dcUPMTe9RSZQrzExHXTztXVX2uchwfLD+ClFQN+jd6Il+PgxCiLR5vzUNBhoaG6sfLlgI/kmojaZJ9+/ZVaTWm6MeVanyvv/66ijbOa+gKtlJWH9K6SQY0egKtzZT6Ij/2MR2qqIAp4eOVR7Hh2A2zHAshxDq5efOmqmcgQqrDz89PFYyQcbU///zzx/4MqdondebHjBmTYUjRvMJ6H3EKOGHpxfGfqeJn1uMQcR3ZNhhxSanKLSylFJe/1ggV8tmCJoRYJ8WLF1clCzMjRX+kMNDjoutblZxYiTjOD2ixWiE3oxNUwJLEKdUsbXzEnny3XDtWUSUUdeO/RsVpB2MmhBBzImUPZfjRRo0aKTezVOvLayisVoiMoSpIv6q7hYyXKoFMUkJRSileuB2H1xeEqRKLhBBiOMa2k5OTajKfH+8twUpiEcvwodJu3877AUUs465MckXYxXtqWqes8aHgzIWUUPy5Xwi6Tt2Jbadv4au/T2BUu8rmPixCiAWNpZpXzJ07Vz8/ZMiQDPmv+V0rnharFXIg3WKtbQFu4MxULeGFr7tpx72dvvUc/jxwxdyHRAgh+QqF1cqQwcgPXY6ySItVR8eaJfDa00Fq/v2lh3A4/XgJIaQgQGG1Mo5fi0ZiShq8XR0R6OMGS+WdZyuhRXBxdawyIk7k/URzHxIhhOQLFFYrDVwSN7ApkqbzsvThdz1rIdDXDdeiEjD091BlbRNCiK1DYbXWwKUylukGNsTTxVGVWvRwdsD+iLsYu+qouQ+JEELyHAqrlRaGqG0FwioE+brjh161Icb1/D0X8fvuCHMfEiGE5CkUVissDCEiVbO06QYyz2uaBxfHe60rqfmxK49iz7m8zyMjhBBzQWG1QjdwJT8PeLjkb17W4zK0WRA61AhASpoGr6UPdUcIIZnZvn07Ro4cCWuGwmqN+atW4gY2RAKtJL+1SoAnbscmYchv+xGflGruwyKEWBh79uyBl5f1eOSMQWG1wojgOmUsrzBETijsZK8qMxV1c8LRq9Eqx1WjYdlDQsh/hIeHo2rVqrBm7CzlRB45cuSB4+gdPnwYd+9qheVBpKWlYffu3VmaKUZJMCfWUBgiJ5Qq4qpqCks6zsrwq/h1p3ZMWUIIMRTWhIQEvPvuu5gyZQqsDbMJq1gq33//PSpXroymTZtiwIABWfY5efIkunTpgtKlS6N3794oVaoUXnjhBaNDDOmIi4vDk08+iZdffhnDhw/Xt71798KaOXFdWxjCq7AjyhWz3MIQOaFhYDGMahus5j//6zhCI+6Y+5AIIRZAUlISIiIicOfOHXTq1Alt2rTBa6+9BmvDbMKanJyMc+fOYenSpRg4cGC24+jJNrFUxWI9ffq0GmlenmIexi+//JLBYm3fvj1sI83GG3YyXpyV81KTcmhvEMzEykyEkOPHj8Pe3l4ZUAsXLkSrVq2s8qSYTVhlaB+xWKtUqZLtPmKtdu7cWV9hqESJEnj++eexbdu2h77/tWvXlEtB3Mi2gDUVhsgJck0ndK2BIF833IhOxBsLwpCSyspMhBQEq3Ts2LEIDAxE0aJF0bx5c2WlCnLPfvHFF9GnTx98+umnsFYsoo81N4SFhaFcuXIP3e+ll15Cz549UaxYMXWRoqKibCRwyTaEVZCxZKf3DYGbkz12n7uDr9efNPchEULykNTUVHTo0EGl1MyfPx+rV6/GlStXMGLECL2w1q9fH1988YVaHj16tFVeD6saj3XGjBnYunUrtmzZku0+4kaQgW2lz1asInEfP/PMM3j99dczjNdnSGJiomo6oqOjYUncvJ+Ay3etrzBETihf3ANfdauJYfPDMH3LOdQuXQRtqvmb+7AIsS4kuj45Lv8/19FV3E+5uoefPXtWBavqBiMXUdXlrb7zzjsoUkRrPEyaNAmnTp1SYiz3dWvCaoT1zz//xLBhwzB16lQ0btw42/3kYhn22VaoUEFdNAlgmj17Nhwcsn7lcePG4ZNPPoGlEhahdQNXLG59hSFygvS1hl0sh5nbz+O9xeGo5O+BchY8cg8hFoeI6pcl8v9zR18FnHL+X50zZw4GDx6sF1XBxcVFdQ3quvt0iGFUqZK2Ypu1YRWu4JUrVyq3rvTJykXJLf7+/soivXXrltHto0aNUq5iXbt06RIsiQOX0t3AZa0zfzUnjGwbjHpPFMH9xBS8OjcUcUkp5j4kQogJiYmJwb59+1CvXr0M6yUwtXr16jZ1ri3eYhUffPfu3TFx4kQMHTo0y3ZxE8jFCgoKgq+vr0q3cXV1zbDPxo0b4ePjg+LFixv9DGdnZ9UslQPpFqs1VlzKKY72dpjcuw7a/7gdJ2/cx+hlhzGpRy2LHhqPEItBXLJiPZrjc3PIgQMHVJ0BaTokuHTRokUqmMmWMKuwHjp0SAnh9evXERsbq9JiBOm8trOzw6ZNm9CtWzf06tULderU0W8Xd27dunX1F0byVsXNK/2q4sMXoZUcKG9vb6xZswbTp0/HtGnT1HtaG8mpaTh0xbYigrOjuKeLEtdeM3bjz4NXVSGMfk8+Ye7DIsTykQfQXLhkzRV46uTkhB9//FHd4yWW5ZVXXlHBqIMGDYItYVZhnTBhgurIFqQ2pPSDCv/++6/ywZ84cQK1atVSuU26bYKHhwc2bNigF9kGDRooa1V46623sHz5cixevFhVW5KQbhHkkJAQWCPHr0UjIVlbGCKwAPQ71i9XVBWPkMIRn60+hmolvWz+gYKQgkBoaCh69OihvFCSZiNTSaeUoFJrC056GIU0LNaaBXmSEqGX/lZPT0+Ykzk7zmPsqmN4upIv5gysj4KA/CQlSnjN4evw93TB6jebwMfdcl31hOQnUurv/PnzytKTwB9roVq1aipGRowf8VQKmbvtLPnc5kYXrM83WsA4cCm9f7V0wbHa5ElWUnCkeMT16AS8ueAAi0cQYsXExcUpD6R06ekE1dJE1ZRQWK2lMIQNRwQ/qHiEq5M9dp69jW83nDL3IRFCHpGIiAjUrl1btYIAhdWCkfq5l+5oC0PUKl2whPW/4hE11PzUzWex7uh1cx8SIeQRqFy5sgoqdXd3LxDnj8JqBdaqrRaGyAkdapTAoMbaEpbv/BGOMzdto/YzIcR2obBaMAXVDZyZUe2C0aBcUcQkpmDIb6GITkg29yERQki2UFgtmAMXC17gUrbFI/rUQQkvF5y7FYsRCw8iLU1j7sMihBCjUFgtuTDE5fTCEAXcYhUk3WZ637pwdrDDPydu4ruNDGYiBRtmSlruOaWwWignrt1XhSE8XRwQ6FMwOvwfRvVSXhjfVVtT9IdNZ/D3kWvmPiRC8h1HR228hS4XlJgO3TnVnWObrRVc0PtXpT6wnR3r5ep4rnYpHL4cjVk7zqtgpkBfd1T08zDjlSIkf5EqRVKu9ebNm2pZ8kFZU/vxLVURVTmncm4ftxIUhdVCscWBzU3F6HbBOHE9WuW3DvltP1YMawIv14IZNU0KJjJil6ATV2IaRFR15/ZxoLBavMXK/tXMONjb4afeddDxx+24cDsOby06gJn968Gelj0pIIiFGhAQoEbsSk5mlLwpEPevqWoWU1gtvTAEhdUoRd2cVGWmbtN2YvPJSEzccBLvtQ7O70tFiFkRIbC1Ava2AIOXLJAD6dZqheLu8CyghSFygox8M6GrtjLT5H/PYs1hBjMRQswPhdUCCUvPX2X/6sPpXKskBj+lrcz07uJw1fdKCCHmhMJqgTBwKXe83yYYTcr7IC4pVVVmuheXlEdXhhBCHg6F1cJgYYhHC2b6sVdtlC5aGBfvxOGNBQeQyspMhBAzQWG1ME5eZ2GIR6GIBDO9WBcujnbYdvoWvl530uTXhhBCcgKF1ULdwLVYGCLXVCnhia+71VTz07acxYqDV0x9eQgh5KFQWC2MsAhdYQjmrz4KHWuWwKvNgtT8e4sPYd+FOya9PoQQ8jAorBYGI4Ifn/+1roTWVf2QlJqmKjNduBVrgnclhJCcQWG1IG7FJKrgGxaGeDyktvJ3PWqjZikv3I1LxqA5+xgpTAjJNyisFjj+anlfFoZ4XAo72WNG/7oo6V1YjeE6ZG4oElNSTXCVCCHEgoU1KSkJCxcuxHPPPYc333zT6D4pKSmYMmUKunbtij59+mDlypUPfd9HeY0lwPxV01LcwwWzBtSDh7MD9p6/g1FLD3MMS0KI7QqrFI4OCgrCsmXL1HA9O3fuNLpf//79MWHCBLRt2xa1atVC9+7d8dNPPz3wvR/lNRYVuMSBzU1GJX8PTO5TRxXoX3bgCn7cdMZ0b04IIZZUhF8KR4eGhqrRGYYPH47t27dn2ScsLAzz589X2xo3bqzWpaWl4cMPP8TLL78MFxcXk7zGEkhJTcOhy1FqnqUMTUvTir74rHM1jF5+GBM3nEKZoq7oUrukiT+FEELMbLHa2dkpUX0Qf//9N3x9fdGoUSP9OnHvRkVFYdeuXSZ7jSVw4vp9xCenwtPFAUG+7uY+HJujd4MyeKVpoJr/35JDyjVMCCEFLnjpwoULKFWqlBp7UEfp0qX120z1msTERERHR2do5qi4pBuxRaJaSd7UFG5T1V+l4bwydz/OMw2HEFLQhFWCmwoXLpxhnZOTk3IjyzZTvWbcuHHw8vLSN50Q5yc37ieoqb+XZbqqbQF5YJnUo1aGNJy7sSzYTwgpQMLq7e2NO3cyuuzu3buH1NRUFClSxGSvGTVqlHIV69qlS5eQ39yMTtRHspL8ScMRi/WV35mGQwgpQMIqEb3nzp3L4Jo9cOCAmtasWdNkr3F2doanp2eGlt9ExuiE1TnfP7ugp+GMZBoOIaSgCGvnzp3h6uqKiRMnqmWxOr/++ms0aNAAlSpVUutiY2PRpk0bbNiwIcevsUQidRarJ4U1v9JwpryoTcNZfuAKfviHaTiEECtPtxFeffVVFVB04sQJ3L17Vwmk8Oeff6q0GHHdzps3TxV5WLp0qbJCpb90zZo1GfJh161bh549e6rlnLzGErmZ3sdKV3D+8VQFX3zepRpGLTuMSRtPwc/TGT3rl8nHIyCE2CJmFda+ffvi/n1tNKwhjo6O+vl27drh8uXLKj9VXLYhISEqEEmHu7s71q5di+rVq+f4NZbIzft0BZuDXvXLqPrMUzefxajlh+Hh4oj2NQLMciyEENugkEaj0Zj7ICwNsXIlOlgCmfKjvzUmMQXVPl6n5o992hquTmZ93ilwyF9g9PIjWLD3IhztC+GX/vXQrKKvuQ+LEGKlumDRfawFhZvRWjewu7MDRdUMSM6zuIQ71AhAcqoGr84NRWgEC0gQQh6NRxLWFStWoFmzZihTpgz8/f0ztE8//fQRD6XgQjew+ZEgponda+HpSr6qAtaA2ftw7Gr+FwohhFg/ufY5hoeHq6L2r732GgYMGJChP1SoUqWKKY+vQAmrL1NtzIqTgx2m9glBv1l7sO/CXTVd/GojlPNxM++BEUJsW1h37NiBF154AZMmTcqbIyrArmAKq2UUkJA+1l4/78axa9F48Zc9WDL0SQR4ZazmRQghJnMFS+F8yQ0lpiNSHxHMqkuWgFdhR/z2Un0E+rjhyr14Ja630wt4EEKIyYW1ffv2OHLkCBYvXqxySIkJ+1hZHMJi8HF3xtyXG6CElwvORsaqPtf7Cfy9E0LyQFilwH2rVq1UP6vMSx6pYfvggw9y+5YFnv+KQ7DqkiUh9YRFXIu6OeHwlSi89Ot+JCTTW0MIMXEf6+bNmzF58mQ1cHiNGjWyBC9VqFAht29Z4GEBfstFxsb9bVB91ecqdYWHzQvDtL4hcLRnphohxETCKm7gXr164bPPPsvtS0k20BVs2cgYub/0r4t+s/binxM38e7icEzqXovj5hJCjJLrx+4nnngC8fHxuX0ZyQZxLUbFa/vu6Aq2XBoEFsO0F0PgYFcIKw5exZiVR1TFJkIIeWxhbdGiBY4dO4Zff/0VcXFxuX05ySYiWHIoJRqVWC7Ng4tjYo9aKFQI+H33RXy04gjS0iiuhJDHFNZp06bh+PHjqjiEm5sbHBwcMrT3338/t29ZoNGNw+rr7qxK6xHLplPNEpjQtYZeXGVkHIorIeSx+liff/75B1ZXKleuXG7fskCjD1xiqo3V0L1uaVWs/50/wrFo/yUkp6Xh6241VVlEQgjJtbDeunVLpdlIrWDy+EQy1cYqea52KTjY2WH4ooNYFnZFFe+f1L0mHBgtTEiBJ9eu4F27dmH58uUF/sSZvgA/qy5ZGx1rlsDk3nWU9boq/CreWHAASSlp5j4sQoi1Cavkru7duzdvjqZA57CyOIQ10qaav4oWdrK3w9oj1/HavDAkprCIBCEFmVy7gsuXL69KGXbr1g19+vSBr2/GAaFLlSqlUnJILqsusY/VamlZ2Q8z+tfFkN/2Y+PxG3hlbqgSWxdHe3MfGiHEGizWBQsWYP/+/Vi6dKkKZHrqqacytJ9++ilvjtRGoSvYNmhW0RezBtSDi6MdNp+MxMu/7kd8Ei1XQgoiuRbWESNGqAIR2bXx48fnzZHaKByL1XZoXN4HcwbWh6uTPbafuYWBc/YiNjHF3IdFCLF0YbW3t4eLi0u2TXJZSc5ITdPohyOjK9g2aBhYDHNfqg93ZwfsPncH/Wft5ag4hBQwHrmS+LVr1zB//nx89dVXmDt3LiIiIkx7ZAUAEVUp3CPpj8XcGLxkK4SULYrfX24ATxcH7I+4i74z9+rLVhJCbJ9HMi+nT5+Od955R80HBATg5s2bSEhIwMcff4zRo0eb7OCuX7+OM2fOGN0WEhKi8mkzk5aWhp07d2ZZX7FiRTVIuyW6gYu5O7O4gI1Rq7Q35g9uiBdn7sHBS/fU6DhzBtZDcU+mVRFi6+RaWE+fPo0333wTP/74IwYNGqRcv1KMfMmSJejXrx+aN2+OJ5980iQHt2fPHnz99dcZ1p0/fx43btxQFrMxYZX6xRJEJWlBHh4e+vUyTmzbtm1hSXAcVtsfFWfB4IboO3MPjl2LxnNTduLXQfVQvvh/v0tCiO2Ra2HdtGkTOnfujCFDhujXSY3bF154Adu2bcOGDRtMJqzyOdIyW6rSMqf5GLOqGzZsCEuGOay2T+UATywd2ggDZu/D+Vux6Dp1F2b0q4v65Yqa+9AIIZbSxyrWqQQwGX0zOzvlis0rDh48iLCwMAwePPih+16+fBmhoaG4d+8eLBWm2hQMyhZzU+Jau4y36msV9/Caw9fMfViEEEsRVnH1SknDhQsXZhiPcs2aNfjll1/QsmVL5BUzZ85EiRIl0K5du4fuO3ToUDUCj5+fH3r06PFAgU1MTER0dHSGlh+wOETBoaibE+a/3BDPVvFTZQ+HzQ/DzO3nzX1YhBBLENZKlSphwoQJGDhwIHx8fFC9enUlXuKyffvtt1X/Zl4g4jdv3jzVr5udxSxIn+9vv/2GyMhIHD58WA1xt2/fPgwbNizb14wbNw5eXl76Vrp0aeTnWKwsZ1gwKOxkj6kvhqBvw7KQZ9LPVh9TjcPOEWJbFNIYmp254OLFi6o/VVyuEhkslmyFChWQV0jFJymheO7cuVyXTJQxZN966y3ExsYazbMV0ZamQyxWEdeoqCh4enoir3huyg4cuHhPlb+TmrOkYCB/uelbz2H82hNquX31AHzbvSZLIBJiwYguiOGVE1145GoOZcqUwUsvvYT8QtzAzzzzzCPVIRaLOikpSQ155++fVcCcnZ1Vy284FmvBRIL9Xm0WhAAvF7y7OBx/Hb6mvBc/9wuBt6uTuQ+PEPKYODzqE/f27dtV6osIliGS5lK/fn2YEvkciUb+448/smxLTU1VQ9np8lTFKnVzc8uwz/r161UUsSXlsco5pCu4YNO5Vkn4ejjjld9CsffCHXSbtkvlupYq4mruQyOE5KewipC2aNECu3fvVpago6Njhu2ShmNqYZ01a5bqz82ceiPcv39f9evOnj1bBSuJZSsFIjp16gRvb299UNWMGTNU1LKlINGhSanaCGq5uZKCSaMgHywe+iQGzNqHMzdjVK7r7AH1VA4sIaSACOvq1atVcQbpY5UI3fxAqi+9//77WURckD7Txo0bK5EXpHhFuXLlsHjxYhXAFBgYqEbjqVmzJiwx1cbb1RHODhxerCAT7O+J5cMaKXE9eeM+ekzfhZ/61EHzSpbjYSGE5GHw0s8//6zySadMmQJbJTed1I/K9tO3VD5jRT93rB/RLE8+g1gX0QnJyi2869xtFCoEvPtsJbz2dJDqkyWEWI8u5No3KpafpLEQU5UzZO1YosXTxRFzBtVDr/qlVTrO1+tOqnxXDj1HiI27gkuVKqVSUySPtXv37hnq8eq2P0rkbkHjv6pL7F8l/yHdAuOer4HqJb3x8cojWHP4uup7nd63Lsr5ZAzKI4TYiLBKPqkUXJA2Z86cLNtl1JtvvvnGVMdns+hSbXw9KawkK70blEElfw8M/T0Up27EoNNP2/FDz9poHsx+V0IsnVy7gkeMGIH4+Phs2/jx4/PmSG3UFezrTmElxgkpWwSr32iipvcTUjDo1334adNpVmoixNaEVcoJuri46JvEPknhfd2yscpG5AGuYI7PSR6A/D5k6Lk+Dcqoftdv1p/C0HmhiElM4XkjxEJ57MTOyZMnY8yYMaY5mgIEi0OQnOLkYIcvnquO8c9Xh5O9HdYdvYEuk3fgXGQMTyIhFojlVEwoYNyM1kUF0xVMckbP+mWw6JWG8PN0VgFNnX/agX+O3+DpI8TCoLCaAUmfiE1KVfN0BZPcULtMEax6ownqPVEE9xNT8NKv+/H9Rva7EmJTwiq1gRs0aGCaoylg/auuTvZwd2afNMkdkvs87+WG6PdkWbU8aeMp9J+9Vx8QRwixcmF99tln8cILL5jmaAoI7F8lpuh3/bRzNXzdrQZcHO2w7fQttP1uGzadoGuYEKsTVhlN5urVq0a33bhxQ43PSh4Mqy4RU/FC3dJY9XoTBPt74HZsEgbN2Y+xK48iIVnb1UAIsXBhlWHbihQpgpIlS6oKS8uWLcuwfe7cufjuu+9MfYw2B4tDEFNSwc8Dfw5rjEGNy6nlOTsvqKjhUzfu80QTYsnCev36dQwaNEiNHrNu3Tp06NBBuYB/+OGHvD1CG4TlDImpcXG0x5iOVTB7YD34uDvhxPX76PjjdszdHaFyzQkh+UeOI2c2b96Mpk2b4vPPP9f3rbZs2RJ9+/ZVBSKGDx+el8dpU9AVTPIKGWpu7VtN8e7icGw5FYmP/jyCLScj8VW3Gijq5sQTT4glCasMlVOmTJkM68RidXd3x/PPP8+n4lzA4CWSl/h6OKvB0mfvvIAJa09g4/EbaPPdVkzqUQuNy/vw5BNiKa7gWrVqITw8PMv6tm3b4s8//8To0aPx+++/m/r4bLqPtTgL8JM8ws6uEF5qUg7LXmuEIF831f0g4/+OX3sCSSlpPO+EWIKwSq6qRASHhoZm2da6dWusWLECp06dMvXx2SR0BZP8olpJL1VQold9ba3haVvOotu0nQxsIiQPyVV1ArFMCxUqZHSb9LmGhYWpIv0ke8RauBuXrOZZzpDkB65ODhj3fHU0q+iD95cexqHLUWj/wza83rwChj4dpHJiCSGmI1f/qBIlSiAgICDb7cHBwahQoYIpjstmiYzRuoGlmLq3q6O5D4cUINpUC8C64U3RMrg4klM1qmKTRA6HX7pn7kMjxKbgo6qZiu9LgEl21j8heYW/lwt+6V8XP/SqraKET964j+em7MAXfx1DfHr9akLI40FhNVMOqw9HtSFmQh7oOtUsgY1vN0OXWiWQpgFmbDuP1t9txc6zt3hdCHlMKKz5DItDEEtBLNbvetbGrAF1EeDlgot34tB7xh6MWnYY0QnaOABCSO6x6KFVEhISVIWnzPzvf/9TwVLZkZycjKlTp+Kff/6Bi4sLevTooXJtLYFIjsNKLIwWwX5YP6IoJvx9Ar/vvogFey+qYv6fd6mOZ6r4mfvwCLE6LFpYU1JSlDhK2cTKlSvr11etWvWBr5NqUHv27MHHH3+Mu3fvok+fPhg/fjzeeustWI7F6mLuQyFEj4eLoxLSjjVKYOSywzh/KxaDf9uPDjUCMLZTVfi4O/NsEWILwqqjXr16aNiwYY723b9/PxYtWoQdO3agUaNGeoEeM2YMhgwZgsKFC8MihJXFIYgF0iCwGNa+9RS+23gaM7adw+pD19SQdO8+W1HlwjrYs/eIkIdhFf+SsWPHomPHjqoe8eHDhx+47/r161G8eHG9qArPPfccoqOjsXv3bpgbljMk1lDQf2TbYKwY1hhVAjwRFZ+Mj1YcRYcft2PX2dvmPjxCLB6LF1Zx+0o/64ABAxATE4OQkBCsXLky2/0vXLighrUzpHTp0vptxkhMTFTCa9jyClZdItZUtWnl643xWZdqKudaRszpNWM3hs0Lw+W7ceY+PEIsFot2Bbu6uirXrgQgCV27dlWpAjJ0XadOnYy+JikpKYu718nJCXZ2dmqbMcaNG4dPPvkEeU1qmga3YrTHQFcwsQbE9du3YVl0qB6AiRtOYd6eCPx1+Joq7C9Vm15pGoTCTqy2RojVWKwihjpR1dGuXTtERETgzp07Rl8jA7Fn3nbv3j01tJ1sM8aoUaPU6D26dunSJeQFd2KTlLhKXYhiHMKLWBFF3JyU5frXm0+hQbmiSExJU/2wrSZuwZrD1zi6FSHWIqzGuHXrlhJcsUKzG4Xn3LlzSiB1SA1j3TZjODs7w9PTM0PLSzdwMTdnBoEQq6RygCcWDmmIyb3roISXC67ci8dr88KUi/jE9bzrQiHEmrBoYV27di2OHTumXxZL8quvvlKj6cg4sEJsbCxatWqlgpaEzp07w83NDd98841alhF55DVPPvkkKlasCHPC4hDEFpDumPY1AvDPO0/jrZYV4Oxgh93n7qDd99vUwOp3Y413uRBSULBoYfX391dBSyKI9evXR6VKlVC9enXMmTMnQzEIyXW9evWqWvb29saCBQswZcoUlfsqg7OL63ju3LkwN5Ech5XYENK3OuKZiqo0Yttq/qo04tzdEWj69b+Y/O8ZxCWlmPsQCTELhTQaGaXRshHXbmRkJAIDA+Hr65thm+Sobt68WUUPG468Ex8fj4MHDyo3r7iAxX2cUyQq2MvLS7mTTekW/mnTaXyz/hS61y2Fr7rVNNn7EmIJ7DxzC5+uPqaihwUpKvF68yD0alAGzg4McCLWTW50wSqENb/JK2Eds+IIftsVgdebl8e7rSuZ7H0JsRQkOG9V+FUVQSy1h4WS3oUxvFUFPF+nFOztOKITsX1dsGhXsK1xk65gYuOIcHapXVK5hz/vUg3FPZxVgNN7Sw6p0XPWMoKYFAAorPnIf8UhWHeV2DZODnZ4sWFZbHmvOUa1DVYFJs7cjMHQeWHo9NMObD0VyRQdYrNQWM0QFezLAvykAAU4vdIsCFv/1xxvtigPVyd7HL4ShX6z9qoUndCIu+Y+REJMDoU1n5CubKbbkIKKp4sj3n62khLYQY3Lwclem6LTdepO9J+1F3vPGy/4Qog1QmHNJ6LjU5CUkqbmfekKJgUUiRQe07EK/n3vafSsV1r1yW45FYnu03eh29SdahxYxlMSa4fCms/9q54uDmr0EEIKMhIpPL5rDWx6pxl6NyijLNj9EXcxaM5+tP1+G1YcvIKUVO2DKCHWBoU1v4eL8+QA54ToKFvMDV8+Vx3b3m+OIU0D4eZkr/Jg31p4EC2+3aKK/ickp/KEEauCwppPsH+VkOzx83TB6HaVsXNkS7z9TEUUcXVUebAfLD+Cpl/9i5+3nkVMIis5EeuAwppPMNWGkIfj5eqIN1tWwI6RLTCmQxUEeLmoh9Iv15xA4/GbMHH9Sb33hxBLhcKa78Uh6Aom5GG4OjlgUJNyKg/2q241EOjjhqj4ZPyw6YwS2BGLDuLgpXs8kcQiseiBzm0JuoIJebRCE93rlkbXOqWw7uh1/Lz1nBLU5QeuqFaztDcGNCqLdtUDWI+YWAwU1nx2BTPVhpDcI2k5Ip7Swi/dw687L2D1oWtqfsSie/jirxPoXb80+jQsq/prCTEndAXnu8XKPz0hj4NYqRN71MLOUS3wzjMV4efpjFsxiXo38evzwxAacYf5sMRs0GLNJzgWKyGmLzbxRssKePXpIOUmFit234W7ypKVVq2kJ/o9+QQ61AhQfbaE5BccNi4fho2LT0pF5TF/q/nDY5+Fh4vjY78nISQrR69GKYFdcfAqEtMrnbk7O6BjzRJqHORapb1RqBCHriO5h+OxWpiwRtyORbOvN6Owoz2Ofdqaf2xC8pi7sUlYuO8SFuy9qB8XVqhQ3F0FQz1Xp6SyeAnJKRRWCxPWfRfu4IVpu1C2mKtKHyCE5A9paRrsvXAHf+y7hDVHriEhWWvFOtgVQovg4uhRrzSaVfSFgz3DTYjpdIEdD/mZw8ri+4TkK3Z2hdAwsJhqYztXxerwa/hj/yWVsrP+2A3VJFJf0nleqFsKQb7uvELksaGw5mvVJUYEE2LOoeuk4L+0UzfuY/H+S1gWdkVVcpq25axqIWWLoFPNEiqth6lx5FGhsObrAOfs0yHEEqjo54EP2lfBe62DsenETSWy/568qQZel/bJqqNoFOSDjjUD0KZqgCq1SEhOobDmoyuYwkqI5VV2alPNX7Ub0QkqTWdV+FXlKt5+5pZqH/55RPXDSmRxq8p+cHPmbZM8GKv4hdy+fRvnzp1D6dKl4e/v/8B9U1NTsW3btizrg4ODH/ravIIF+AmxfKRi00tNyql28XYcVh26qkRWhrHbePymai6OdmhZ2Q8da5TA05V8ObYysT5hPXr0KN555x2EhobiiSeewMmTJ9GsWTP8/vvvKjrLGPHx8WjevDlq166dIXJr5MiRaNOmDcwBx2IlxLooU8wVw5qXV+30jftKYFeGX8WF23H469A11TycHdCqih+ereKHphV9ackS6ygQsXr1ajg6OqJ169ZqOTIyEo0aNVLi+ssvvxh9TUxMDDw8PLBr1y40bNjQItJtQj7bgNuxSVj71lOoHPD470cIyX/kVnnkSrTekr0WpQ1K1LmUm5T3wTNV/NCycnEGKtogNpNu06FDhwzLvr6+6NKlC/7666+HvjYiIkIVYggKCoKPjw/MRXJqmhJVgek2hFgvcj+pXspLtZFtghF28a4qpSgpOxG341QQlDQp7FS7tDeeqeKPZ6v6MYWnAGLRFqsxxFr19vbGihUrHmixSn+qtOPHj6N9+/aYMWMGihYtavQ1iYmJqhk+mUh/riks1mtR8Xhy3CaVkH7q87Yqr44QYjvILfTUjRhsOHYdG47dQPjlqAzbA33d8GwVf2XNiuDyHmCd2IzFmpnJkydj586d2L59e7b7ODg4YP78+ejVq5davnDhAlq2bIlhw4ZhwYIFRl8zbtw4fPLJJ3keEcw/FCG2aclW8vdQ7fUWFXA9KgEbjt/A+qPXsfvcbZyLjNXnyRZ1c8JTFXzQtIIvnqroQ5exjWI1Fusff/yBF198UVme/fv3z9Vrf/75Z7zxxhuIjY1VwpufFqs8wQ7+bT9qlvLCitebPNZ7EUKsi+iEZGw+GanuA5tP3MT9xJQM26sEeKJZJV8ltFKcQvpqiWVicxbrkiVL0LdvX0ybNi3Xoqrrm01KSsKtW7eMptw4OzurlrcDnLPqEiEFsdqTVHKSJvEWYRF3sfV0JLacilSBUMeuadvUzWfh5mSPJ4N8lNA2q+CrIpOJdWLxwrps2TL06dMHU6ZMwaBBg7JsT0lJUa5hXZ7q/fv3VR+rIevWrUPx4sVVM1udYE9WXSKkIONob4cGgcVUk4pPkoa3/UwktpyMxLbTt1SQ48bjN1QTyvm4oVGQts6xNBaYsR4sWljXr1+Pnj17onfv3iq6d/PmzWq9uHObNGmiD1aSvNXZs2djwIABmDNnDrZs2YJOnTqpIKc1a9Zg5syZmDVrFuzs7MxWzpARwYQQQ0Qon6tdSjUZhefo1WitNXsyEqEX7+L8rVjV5u25qPYvX9wdDQOL4slAHzQILMph7ywYixZWCTySvFWZjh07Vr/e3d1d5bjqRFYihXUuXulLLV++PBYvXqzyXgMDA3HgwAFUq1bNLN8hkgX4CSEPQQIbdak8UpRC+mZ3n72NXeduY/e5Ozh+LRpnbsao9vturdBW9HPXW7MNyhVFMY4vazFYTfBSfmLKAhGdftqOQ5ej8Eu/uqpKCyGEPMrA7XvO31FRxtKkzGJmKvl5IOSJIggpU0QFQsn4zxKxTEyDzQUvWTPsYyWEPC5F3Jz0gwUId2KTsPf8bew6q7VoT964r2/z013HxdycUKesVmSlVS/pxdrG+QSFNQ+RfpNbMbo+VkYFE0JMg+TDtqkWoJpwOyYR+y7cQdjFe2rYu8OXo1QwlKT5SBMc7Quhagkv1Em3aOuU9Ya/pwut2jyAwpqH3IlLQkqaRpU483F3ysuPIoQUYKR/1VBoE1NSVTqPpPeI0O6PuKse8mU4PGmzdpzXB1DVKKnt261ZyltNfdhX+9hQWPPBDSwuGQd7Jn4TQvIHZwd7vQt4cHrZxct34/UDuYvQnrwerVJ+/jlxUzUdJbxclMDWEKEtKVMveLvSMMgNFNY8RFccgk+AhBBzIkFMpYu6qtaldkm1Lj4pFceuRangSnEdh1++h3O3YnE1KkG1dUe1LmShTFFXVCvpicr+nmqErsolPJUAMzjKOBTWPITjsBJCLJXCTmLVFlVNx/2EZJVPK0J76IoI7j01Bu3FO9q25vB1/b5ehR0R7O+hhFZKM8q0gp87A6QorHkLi0MQQqwJDxdHfW6sjqi4ZBy+EqWs2+PX7utzaqPik1UKkDQd9naFEOjjpkRWBiWoUNwdFfw8lMUr2woKtFjzw2L1YDlDQoh14uXqiCYVfFTTIcFRIq46odW1u3HJOH0zRjWE//ceMrhAkK+7VmiV2LqjfHEPPFHM1SbjTyis+dDHSmElhNhacJSk7kjTIQFS4qU7li6yp2+IwN5XApyQnKYXX0MkBSjQxx3l/dwR5OOGQF93VSP5CR835Wq2Viis+VIcgjmshBDbRgKZ/DxdVGte6b8BT1LTNLhyN16JrFiyp25oxVaENz45VV/YIjOSoigiq21awZVB48Wt7OJoD0uGwpqHsI+VEFLQsbcrpIbAk9aysl+GAjpXo+L1lq0MOCCDwstU7p23YpJU23fhbob3k7oAJb0Lq5KNZdIjnWVatqhWdMV1bW4orHmE1i2icwXTYiWEkMwDD5Qq4qpa8+CMQ3rGJKbgggitjPCjxDZGL7wyWLzk5ErbgdvIjKeLg1bIleBqxVZa1RKeqjRkfkBhzSPux0TjTc18XLUvBv8bhYDk0oBnSaBwEe0jFyGEEKO4OzugWkkv1TIbLFKqUUT2okEakK5JwGh0QoqqOiXNkInda+L5OqWQH1BY84i7V8/jNYeV2oU/Zv+3wdFVK7BeJQHPUulTg2XPAMDZk+JLCCFG+nGl4I60ek/8l3+rIy5Ja81GpIvuJQPRlYCo/ILCmkc4F3bFbp+u8E6+iWDXaCDqChB3C0iOA26f1rbscHQDPPwBj4D0qeG8wdTJNa8OnxBCrA5XJwdU9PNQzZxwPNY8Ho81A8kJQPQVbROhjb6cPjVYTojK+fs5ewHuxQF3P8DdVzt1S5+q9cX/W2dv/g59QgixVjgeq6Xi6AIUC9K27EiKA+5fA+5fNzKVdlU7Fcs3MUrbHmT96ihcVCu0rj6AW7H0qc9/yyK+unWyrz2dGYQQ8ijw7mlpiHv3YeKr0QCJ0VqBjbkJxNwAYiO105j0aaysT2+aVCD+jrblFAmyci2mFVnXounzsq7of+syTx1YYYoQQiis1ohEFbt4aZtvpQfvm5YGxN/9T2xjbwFxt9OntwyWI7Xzsi802qmazwUSmOXirRXgwt7p894PWOetDdRy8QQcXBiwRQixCSisto6dXbqrV4pqV3n4/mmpQNwdreiK4MalW7pq3W2t2BquU5bwXUCTpnVPSxN3dW6xd0oXWXlg8Mw4LyIsy84emVqmdU7u2u9LCCFmhMJKMmJnnx4I5ZvzMyNWsfT1Kiv3HpBwL+NU1mdZd0/7mgTJNdMAqUnpYn7r8a6IiKteaN20y2qd+4OX1byb1uo2nHcsTEuaEJIrKKzk8RErUbl6i+T+tSLKSTHaPuOEdKGVqX7ZYD5R9rtv0KL/m09L1r6fvJc0CfgyCYUMBFea+3/zOuFVzS19argufV5eL65umc9uKg80hBCbwOKFNSkpCT/99BP++ecfuLi4oEePHujevbvJX0PMKMrK3esJeD1GVZSUxKximxT731QJbqZlEWqdEKt14sqWaSyQoi1Hqaxp3T6xyDvsHA2E1gVwkHnn9HXO2vX6qYuRZZl31rrU1bITYK/bnr5ObXM2mDqn75e+L93ohBQMYe3Tpw/CwsLwySef4O7duxgwYAAuX76Mt99+26SvIVaOTlgkXcgUSF+z9BeL2Iqo6uZ1wpscn96nHG+wLdM6NZUm+ycAKTKfoF0n8+L+1n9eMpAoLWMZtnzFziFdZA0EWD/v+N+y4X6G6/XzMnXUPiyoZYeM8+o9HDPtJ/vo9pN1Dv9tU/OZthk2WccyocSCsGhh3bdvH5YsWYLdu3ejQYMGemt07NixGDp0KAoXLmyS1xCSBXHN6vpq8d+IHCZFxFssY0PR1U+lJaZPDVv6ugz7iFAnAakyn6RdL6Kt266f1+2Tvs5Q2NXxpGibPBRYG4Xs/hNmNbU3EGH7TGKcedlgXSHdNrtMyzK1z7Qs2+0M1htbl75v5v30U916u0zbDJfT5/X7Fsq0bLjdLuNyhn3sHrxONdYxt3lhXb9+Pfz8/PQCKXTp0gXvvvsudu3ahRYtWpjkNYSYBbm56QKlzIHkQ+sEVie62c6n/LdvanLWebG4ZV5EWzcvzXBe7ad7n+RM89JSDV4jIq97j1SD+eRsvkua8YcFknuMCq6B8GZeJ3EIRvcp9ID9kM36QpnWyVTtnP12o/sa2a/BUKBUSL78IixaWCMiIlCiRIkM60qVKqXfZqrXJCYmqmZYuooQm0duNjoXurXU9pCHARFRJcrp4mzYDIVYt6zbP0NLzWaa/nopqqLWpa/X6LZnei/13gb766e6/TKvM7avxsg6maZpX6MxfK1uneGywfvr3ivzPhIrkONznP5aW6NyJwAUVuXCzey6dXJygp2dndpmqteMGzdO9ccSQiwcnRtUrH1W+sr9A0lmQTZcn2E+u6bJ4fZU7bwIutHtD9imX69bxkO2a7LOG75Gt79fVeQXFm2xFilSBLdvZxzI9t69e0hLS1PbTPWaUaNGZQhsEou1dOnSJvkOhBBiMQ8kSO97JnmKRZepqV27Ns6dO6eE0TA4SahVq5bJXuPs7KxGsTFshBBCiM0Ja+fOneHh4YGvvvpKLaekpKj5xo0bo2LFimpdTEwMnn76afz99985fg0hhBBSIIVVxkRdtGgRZsyYgQoVKqggpGvXrmHu3Ln6fUQ4t2zZguvXr+f4NYQQQkiBHuhcInYPHTqkXLbVq1dHIYNcKxHW7du3Izg4GP7+/jl6jdkGOieEEGKV5EYXrEJY8xsKKyGEkEfVBYt2BRNCCCHWhkWn25gLnRHPQhGEEEIM9SAnTl4KqxHu37+vpsxlJYQQklkfxCX8INjHagQpJnH16lWVtpOboKfM6ApNXLp0yeaDoPhdbRNeV9uE1zX3iKUqoiolc6WS34OgxWoEOWm6+sKmoCAVneB3tU14XW0TXtfc8TBLVQeDlwghhBATQmElhBBCTAiFNQ+R4hQff/yxmto6/K62Ca+rbcLrmrcweIkQQggxIbRYCSGEEBNCYSWEEEJMCIWVEEIIMSHMY80jZHSdo0ePws3NDZUqVYItIgnTO3bsyLJehuvz8/ODLXDnzh0cO3ZMjeVbvHhxo/vcuHEDFy9eRLly5eDj4wNrLhpw+PBh9T0kCd4QGZbxzJkzWV7TpEkTWCNStOXu3bsICgpS/1FjyPazZ88iICAAJUuWhLUixW4iIyMRGBioit4YIgXl5ZpnJiQkBIULF4a1ERUVpa6ZjHSW+TesIzY2FidOnEDRokXVbz1PkNFtiGlZs2aNplixYprAwEBNkSJFNCEhIZqrV6/a3GmOj4+Xopma6tWraxo3bqxvq1at0lg7p06d0gwYMEATEBCgvuPs2bOz7JOWlqZ57bXXNM7OzpoqVaqo6fvvv6+xNi5cuKAZMmSIxt/fX+Pg4KCZNGlSln2mTp2qcXFxyXCdpaWkpGisiT///FNTtWpVTalSpdTv1s3NTfPJJ59k2W/8+PHq+1auXFlNe/furUlKStJYE+vWrdPUrl1b/YZr1KihcXV11bz33nsZ9tmwYYP6fWe+rhERERpr4tKlS5ru3btrfHx8NHXq1NF4eHhonnrqKc2VK1cy7Pfbb79p3N3dNRUrVlTTVq1aaaKiokx+PBRWExMZGaku6qeffqoXn4YNG2ratm2rsVVh3bZtm8bWWLFihWbmzJmauLg4jb29vVFhnTZtmrrWR44cUct79uzRODk5aRYtWqSxJuTmKsJ5//599UCYnbAGBQVprJ3vvvtOc+zYMf3ypk2b1MPE0qVL9es2btyosbOzU1Ph/Pnz6ob9xRdfaKyJKVOmaA4cOKBf3rt3r3pIkN91ZmG1dnbs2KH566+/9MvR0dGamjVranr06KFfd+LECXWtZ82apZbv3LmjqVChgmbw4MEmPx7rP6MW+GOWJ8PY2Fj9uiVLlmgKFSpkc1arTlgXLlyo2b9/v/qh2iLZCWv9+vWVVWtIhw4dNK1bt9ZYKw8S1nLlymkOHTqkOXr0qCYxMVFjK1SrVk0zYsQI/bJYp02aNMmwz/Dhw23iwUK+18CBA7MIq4hOeHi4epC0FQYNGpThOo4ePVp5KjI/aMn9OiEhwaSfzeAlE3PgwAFUrlwZrq6u+nX169dX/ZEHDx6ELfL6669j4MCBql/jhRdeUP2Sto5cz/DwcNUXZYhca/kN2CIXLlxQ17ddu3aqL3ny5Mmwdm7fvo3z58+jfPny+nVy/YxdV+m70418ZY1I3+Lx48czfFcdbdu2Rbdu3VCkSBGMHj06R0OjWSL79u3Dv//+i4kTJ2LlypX44IMPHnpd4+LicOrUKZMeB4OXTIyISrFixTKs0y3bmuDIYAWzZ89G//791ShAcoN65plnMHToUCxatAi2jNykJEDN2LW2tessyMOiBHEFBwer5blz56Jfv34qIEZuytaIiMfgwYPh6+uLvn375vg/nDkAyFoYNmwYHBwcMGTIEP06CTKUAMRGjRqp5c2bN6NNmzYqYOuNN96AtTFu3DgVSHjy5El07twZDRo00G+Ta1e1atV8uTfTYjUxjo6OSEhIyLAuPj5eTZ2cnGBLyPcZMGCAfmg9ibAbOXIkli1bhqSkJNj6dRaMXWtbu85Cs2bN9KIqiBA1btwYCxcuhLUiQrN161asWrUqg1ja4n941KhRWL58ubLiDCPXq1evrhdV4emnn0bPnj2t9rouW7YM+/fvV1Hf4mHp3r27Wa4rhdXElC1bFleuXMmwTrdcpkwZ2DryBJySkoKbN2/C1mutync1dq0LwnUWjH1/a0GssT/++AP//PMPqlWrlqP/sFzz7FKuLJkPP/xQue3//vtv5fq05euqw9vbW3kj5PrqHvLz895MYTUx4gqVvhhxm+lYsWKFypmqU6cObM0dmpn169er7yqupIJwrcXa0ZGamorVq1er9bZG5msdExODXbt2ZREla+DNN9/E/PnzsXHjRtSsWTPLdrl+69aty+B1kf9wixYtYG9vD2tizJgx+OGHH5SoPvnkkw+9rmlpaUYfNqzxXnTmzBk1fqrOuyTXdefOnbh161aG6yrdHKbOU2Yfq4lp2bIlnn32WRXk8cknn6jk7C+++AKTJk3SX2Bb4ddff1V9MtKXIWK6du1aTJs2DVOnTrW6G1BmMifOnz59Gtu3b1dP81IAQ2cJ1KtXDy+//LI6B3Kzlr6a9957D9Z2U9IFXIm3QfrK5btK/5PcdIQuXbool6FYPBLA891336kugHfffRfWhATmTJkyRR2/PBzI9xSkn1VXyEWs2V9++UUF8+isni1btmDbtm2wJr766it8/vnnGD9+vBJM3XeVACVdX6MEHop1J65+6XOeOXOm6p+cPn06rIn//e9/KuZDXNkSOCrf9ZtvvsGECRP0XVW9evVS92H5r8p/VP7f8n2XLl1q8uPh6DZ5gESZSVSa9N/IRe7Tp48SWlvkr7/+Ui41qT4kgSxyI6pduzasHREaY8EbEhErN2cdUl3r22+/RUREhKriI39wY1GXlow82UtfeWaaNm2KL7/8Us2LCMkDk4iLPCCK90XOj6enJ6wJiV6Xh6TMtGrVCmPHjtUvX758WQmSRNFKBR+xcuUhypqQ62MsQr1hw4ZKdITk5GTMmjULGzZsUBa6CK58V2vzOKWmpqqAOnm4lwpiEu8hQZWGwUu6aloittIPK8aA3K/ywsNEYSWEEEJMCPtYCSGEEBNCYSWEEEJMCIWVEEIIMSEUVkIIIcSEUFgJIYQQE0JhJYQQQkwIhZUQQggxIRRWQohRpISfVB0ihOQOCishJAtSnFzGtTR1DVVCCgIUVkJIFg4ePKjKcVasWJFnh5BcQmElhBgVVhmrUwqbC9euXVPFy6WerhR0J4RkD4WVEGJUWHWDKchgEiEhIWqItY8//lgvtoQQ47AIPyEkCzI0ngwJJ0PKybCHMgKKWKyEkIdDYSWEZECGiJPh4GQIMRmf9c8//9SPVUoIeTgc6JwQkoHw8HA1UL2Xl5dq1ja+LCHmhp0lhJAs/auVK1dWlqoM9P3OO+/wDBGSCyishJAswiqBSj4+Pli1ahVmz56NadOm8SwRkkMorIQQo8IqSD/rggULMHz4cGzcuJFnipAcwOAlQkiWPtYyZcqgSJEi+nUnTpxAoUKFGMRESA6gsBJCCCEmhK5gQgghxIRQWAkhhBATQmElhBBCTAiFlRBCCDEhFFZCCCHEhFBYCSGEEBNCYSWEEEJMCIWVEEIIMSEUVkIIIcSEUFgJIYQQE0JhJYQQQkwIhZUQQgiB6fg/pRA5vVKNOy0AAAAASUVORK5CYII=",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "rho = 0.9\n",
+ "inside = torch.tensor([[rho, 10.0], [0.0, 0.5]], dtype=torch.float64)\n",
+ "inside_spec = compute_spectrum(inside, time_step=1.0)\n",
+ "inside_diag = inside_spec.diagnostics\n",
+ "assert inside_diag is not None\n",
+ "print(\"inside-disk evals\", inside_spec.eigenvalues.real.tolist())\n",
+ "print(\"inside-disk kappa(V)\", inside_diag.eigenvector_condition)\n",
+ "print(\"inside-disk departure\", inside_diag.departure_from_normality)\n",
+ "assert float(inside_spec.magnitudes.max()) < 1.0\n",
+ "assert inside_diag.eigenvector_condition > 1.0\n",
+ "assert inside_diag.departure_from_normality > 0.0\n",
+ "\n",
+ "horizon = 31\n",
+ "steps = list(range(horizon))\n",
+ "norm_hist = []\n",
+ "envelope = []\n",
+ "for k in steps:\n",
+ " powered = torch.linalg.matrix_power(inside, k)\n",
+ " norm_hist.append(float(torch.linalg.matrix_norm(powered, ord=2)))\n",
+ " envelope.append(rho**k)\n",
+ "peak_k = max(steps, key=lambda k: norm_hist[k])\n",
+ "print(\"peak k\", peak_k, \"||K^k||\", norm_hist[peak_k], \"rho^k\", envelope[peak_k])\n",
+ "assert peak_k > 0\n",
+ "assert norm_hist[peak_k] > envelope[peak_k]\n",
+ "\n",
+ "fig, ax = plt.subplots(figsize=(4.8, 3.2))\n",
+ "ax.plot(steps, norm_hist, label=r\"$\\|K^{k}\\|_{2}$\")\n",
+ "ax.plot(steps, envelope, label=r\"$\\rho^{k}$\")\n",
+ "ax.set_xlabel(r\"$k$\")\n",
+ "ax.set_ylabel(\"2-norm\")\n",
+ "ax.set_title(r\"$\\rho<1$ shear: $\\|K^{k}\\|$ is not $\\rho^{k}$\")\n",
+ "ax.legend()\n",
+ "fig.tight_layout()\n",
+ "plt.show()\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t51-interp",
+ "metadata": {},
+ "source": [
+ "## Interpretation / discussion\n",
+ "\n",
+ "The identity matches the normal control: $\\kappa(V)=1$\n",
+ "and departure $0$. The $a=10$ shear is non-normal\n",
+ "(printed $\\kappa(V)\\approx 20$, $\\kappa_i=\\sqrt{101}$) but\n",
+ "well below `CONDITION_WARN`. Its $\\rho=2$ already implies\n",
+ "growth, which is why the unit-disk figure is not a\n",
+ "transient-growth demo. The $\\rho=0.9$ shear keeps every\n",
+ "$|\\lambda|<1$ and still has $\\|K^{k}\\|_{2}>\\rho^{k}$ for\n",
+ "$k\\ge 1$. That is the Trefethen–Embree point these fields\n",
+ "flag, not a finite-horizon certificate.\n",
+ "\n",
+ "Nyquist warnings are sampling flags, not the Zeng\n",
+ "identification theorem. `CONDITION_WARN` on\n",
+ "`mode_amplitudes` still solves $Va=z^{\\top}$. A zero\n",
+ "column in $V$ raises `DefectiveSpectrumError` instead.\n",
+ "\n",
+ "`compute_spectrum` and `compute_generator_spectrum`\n",
+ "populate `diagnostics`. Manual `KoopmanSpectrum` objects\n",
+ "may omit them (as in the ill-conditioned amplitude cell).\n",
+ "Eligible Kronecker graph spectra may omit them. Generator\n",
+ "spectra set `nyquist_frequency` to `None`. Resolvent-norm\n",
+ "grids remain a separate opt-in\n",
+ "([40_resdmd_pseudospectra.ipynb](40_resdmd_pseudospectra.ipynb)).\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t51-takeaways",
+ "metadata": {},
+ "source": [
+ "## Takeaways\n",
+ "\n",
+ "- **When to use.** After discrete `compute_spectrum`,\n",
+ " inspect `diagnostics` when the eigenbasis may be\n",
+ " ill-conditioned, when a real eigenvalue is near $-1$,\n",
+ " or when a spectrum inside the unit disk may still be\n",
+ " non-normal.\n",
+ "- **When not to use.** Not a $\\|K^{k}\\|$ bound, not a\n",
+ " sampling-theorem identification result, and not a\n",
+ " resolvent / pseudospectrum certificate. Generator\n",
+ " spectra have no Nyquist frequency. Manual\n",
+ " `KoopmanSpectrum` objects and some Kronecker paths may\n",
+ " omit `diagnostics`.\n",
+ "- Nyquist is $1/(2\\Delta t)$ in cycles per unit time;\n",
+ " frequencies are principal values.\n",
+ "- `CONDITION_WARN=1e6` warns on `mode_amplitudes`; the\n",
+ " solve may proceed. Singular $V$ raises\n",
+ " `DefectiveSpectrumError`.\n",
+ "- $\\rho>1$ and non-normal transients are different\n",
+ " facts. A point outside the unit disk is spectral\n",
+ " radius, not $\\kappa(V)$.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t51-reading",
+ "metadata": {},
+ "source": [
+ "## Further reading\n",
+ "\n",
+ "- Sphinx: [spectral_diagnostics](../docs/source/spectral_diagnostics.rst),\n",
+ " [limitations](../docs/source/limitations.rst)\n",
+ "- Wilkinson, J. H. (1965). *The Algebraic Eigenvalue\n",
+ " Problem*. Clarendon Press (`Wilkinson1965`)\n",
+ "- Trefethen, L. N. and Embree, M. (2005). *Spectra and\n",
+ " Pseudospectra*. Princeton University Press\n",
+ " ([doi:10.1515/9780691213101](https://doi.org/10.1515/9780691213101);\n",
+ " `TrefethenEmbree2005`)\n",
+ "- Zeng, Z., Yue, Z., Mauroy, A., Gonçalves, J., and\n",
+ " Yuan, Y. (2022). A sampling theorem for exact\n",
+ " identification of continuous-time nonlinear dynamical\n",
+ " systems. *CDC*, 6686–6692\n",
+ " ([doi:10.1109/CDC51059.2022.9992482](https://doi.org/10.1109/CDC51059.2022.9992482);\n",
+ " `Zeng2022Sampling`). Related literature; this notebook\n",
+ " does not implement that theorem.\n",
+ "- Example [07_koopman_spectrum.ipynb](07_koopman_spectrum.ipynb)\n",
+ "- Example [40_resdmd_pseudospectra.ipynb](40_resdmd_pseudospectra.ipynb)\n",
+ " (resolvent-norm grid; not these Euclidean fields)\n",
+ "- Example [54_criticality_monitor.ipynb](54_criticality_monitor.ipynb)\n"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "koopmangraph",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/52_cochain_hodge_modes.ipynb b/examples/52_cochain_hodge_modes.ipynb
new file mode 100644
index 0000000..55eb6fc
--- /dev/null
+++ b/examples/52_cochain_hodge_modes.ipynb
@@ -0,0 +1,639 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "t52-title",
+ "metadata": {},
+ "source": [
+ "# Hodge split of cycle modes\n",
+ "\n",
+ "This notebook shows how `hodge_decompose_modes` splits stored\n",
+ "eigenvector columns into combinatorial gradient, curl, and\n",
+ "harmonic blocks on an oriented 6-cycle. Reach for it when a\n",
+ "mode shape is already stored and the question is whether that\n",
+ "column is a potential-driven 1-cochain, a face-free\n",
+ "circulation, or a constant 0-cochain. When $N=E$, pass\n",
+ "`degree` explicitly: inference prefers $k=0$.\n",
+ "\n",
+ "**Honesty.** This is a combinatorial Hodge split\n",
+ "(`hodge_decompose_modes`), not physical circulation, not\n",
+ "`koopman=\"hodge\"`, and not TopologicX / sheaf parity. Face curl is\n",
+ "not shipped ($k\\in\\{0,1\\}$). This notebook does **not** download\n",
+ "METR-LA.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t52-setup-md",
+ "metadata": {},
+ "source": [
+ "## Setup\n",
+ "\n",
+ "Package version, oriented-cycle helpers, and the relative L2\n",
+ "mass $\\|\\mathrm{part}\\|_2/\\|\\mathrm{mode}\\|_2$ (not a squared\n",
+ "energy share).\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "t52-setup",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-21T00:33:31.307384Z",
+ "iopub.status.busy": "2026-08-21T00:33:31.307312Z",
+ "iopub.status.idle": "2026-08-21T00:33:33.521550Z",
+ "shell.execute_reply": "2026-08-21T00:33:33.521252Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "koopman_graph 0.15.0\n"
+ ]
+ }
+ ],
+ "source": [
+ "from __future__ import annotations\n",
+ "\n",
+ "import os\n",
+ "import warnings\n",
+ "\n",
+ "warnings.filterwarnings(\"ignore\", message=\"IProgress not found\")\n",
+ "\n",
+ "import matplotlib\n",
+ "\n",
+ "if os.environ.get(\"PYTEST_CURRENT_TEST\"):\n",
+ " matplotlib.use(\"Agg\")\n",
+ "\n",
+ "import matplotlib.pyplot as plt\n",
+ "import torch\n",
+ "\n",
+ "import koopman_graph\n",
+ "from koopman_graph.analysis import HodgeModeComponents, hodge_decompose_modes\n",
+ "from koopman_graph.observables import boundary_incidence_b1\n",
+ "from koopman_graph.spectrum_types import KoopmanSpectrum, compute_spectrum\n",
+ "\n",
+ "CYCLE_N = 6\n",
+ "ATOL = 1e-6\n",
+ "\n",
+ "print(\"koopman_graph\", koopman_graph.__version__)\n",
+ "\n",
+ "\n",
+ "def cycle_edge_index(num_nodes: int) -> torch.Tensor:\n",
+ " tails = torch.arange(num_nodes, dtype=torch.long)\n",
+ " heads = torch.roll(tails, -1)\n",
+ " return torch.stack((tails, heads), dim=0)\n",
+ "\n",
+ "\n",
+ "def spectrum_from_columns(columns: torch.Tensor) -> KoopmanSpectrum:\n",
+ " modes = columns.to(dtype=torch.complex128)\n",
+ " n_modes = int(modes.shape[1])\n",
+ " eigenvalues = torch.ones(n_modes, dtype=torch.complex128)\n",
+ " return KoopmanSpectrum(\n",
+ " eigenvalues=eigenvalues,\n",
+ " eigenvectors=modes,\n",
+ " magnitudes=eigenvalues.abs(),\n",
+ " growth_rates=torch.zeros(n_modes, dtype=torch.float64),\n",
+ " frequencies=torch.zeros(n_modes, dtype=torch.float64),\n",
+ " time_step=1.0,\n",
+ " )\n",
+ "\n",
+ "\n",
+ "def relative_l2_mass(part: torch.Tensor, mode: torch.Tensor) -> float:\n",
+ " \"\"\"||part||_2 / ||mode||_2; not a squared energy fraction.\"\"\"\n",
+ " return float(\n",
+ " torch.linalg.vector_norm(part).item()\n",
+ " / torch.linalg.vector_norm(mode).item()\n",
+ " )\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t52-motivation",
+ "metadata": {},
+ "source": [
+ "## Motivation and background\n",
+ "\n",
+ "Stored Koopman columns are just vectors. The naive check is\n",
+ "to form $\\ker L_k$ by hand and test membership. That is\n",
+ "enough on a 6-cycle if the only question is yes or no. It\n",
+ "does not report how much of the column sits in each Hodge\n",
+ "block, and it does not resolve $0$-cochains from\n",
+ "$1$-cochains when both have length $N=E$.\n",
+ "\n",
+ "Lim (2020) splits a cochain into gradient, curl, and\n",
+ "harmonic parts (Lim, *SIAM Review*, 2020;\n",
+ "[doi:10.1137/18M1223101](https://doi.org/10.1137/18M1223101)).\n",
+ "On a consistently oriented cycle the circulation spans\n",
+ "$\\ker L_1=\\ker B_1$, with $L_1=B_1^{\\top}B_1$ when faces\n",
+ "are absent. Constants span $\\ker L_0=\\ker B_1^{\\top}$,\n",
+ "with $L_0=B_1 B_1^{\\top}$. That algebraic kernel is not a\n",
+ "validated current.\n",
+ "\n",
+ "`hodge_decompose_modes` projects stored columns onto those\n",
+ "blocks. Face-based $k=1$ curl ($\\operatorname{im} B_2$) is\n",
+ "not shipped. When $N=E$ and `degree` is omitted, inference\n",
+ "returns $k=0$.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t52-minimal-md",
+ "metadata": {},
+ "source": [
+ "## Minimal example\n",
+ "\n",
+ "All-ones edge weights on the cycle, passed as a $1$-cochain\n",
+ "with `degree=1`. Look for harmonic relative $L^2$ mass $1$\n",
+ "and a curl block that is exactly zero (structural, not a\n",
+ "$10^{-16}$ residual).\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "t52-minimal",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-21T00:33:33.522823Z",
+ "iopub.status.busy": "2026-08-21T00:33:33.522689Z",
+ "iopub.status.idle": "2026-08-21T00:33:33.547906Z",
+ "shell.execute_reply": "2026-08-21T00:33:33.547434Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "relative L2 mass harmonic 1.0000000000000002\n",
+ "relative L2 mass gradient 3.4450388676262767e-16\n",
+ "relative L2 mass curl 0.0\n",
+ "curl exactly zero True\n"
+ ]
+ }
+ ],
+ "source": [
+ "edge_index = cycle_edge_index(CYCLE_N)\n",
+ "circulation = torch.ones(CYCLE_N, dtype=torch.float64)\n",
+ "spectrum = spectrum_from_columns(circulation.unsqueeze(1))\n",
+ "parts = hodge_decompose_modes(\n",
+ " spectrum, edge_index, num_nodes=CYCLE_N, degree=1\n",
+ ")\n",
+ "assert isinstance(parts, HodgeModeComponents)\n",
+ "mode = spectrum.eigenvectors[:, 0]\n",
+ "print(\"relative L2 mass harmonic\", relative_l2_mass(parts.harmonic[:, 0], mode))\n",
+ "print(\"relative L2 mass gradient\", relative_l2_mass(parts.gradient[:, 0], mode))\n",
+ "print(\"relative L2 mass curl\", relative_l2_mass(parts.curl[:, 0], mode))\n",
+ "print(\"curl exactly zero\", bool(torch.equal(parts.curl, torch.zeros_like(parts.curl))))\n",
+ "assert abs(relative_l2_mass(parts.harmonic[:, 0], mode) - 1.0) < ATOL\n",
+ "assert relative_l2_mass(parts.gradient[:, 0], mode) < ATOL\n",
+ "assert relative_l2_mass(parts.curl[:, 0], mode) < ATOL\n",
+ "assert torch.equal(parts.curl, torch.zeros_like(parts.curl))\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t52-deep-md",
+ "metadata": {},
+ "source": [
+ "## Progressive deep dive\n",
+ "\n",
+ "A gradient flow $B_1^{\\top}\\varphi$ is **not** harmonic.\n",
+ "Reconstruct the stored column as gradient + curl + harmonic.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "t52-deep-gradient",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-21T00:33:33.548967Z",
+ "iopub.status.busy": "2026-08-21T00:33:33.548890Z",
+ "iopub.status.idle": "2026-08-21T00:33:33.551873Z",
+ "shell.execute_reply": "2026-08-21T00:33:33.551558Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "gradient flow relative L2 mass harmonic 3.117457104162384e-16\n",
+ "gradient flow relative L2 mass gradient 1.0\n",
+ "reconstruction matches stored columns\n"
+ ]
+ }
+ ],
+ "source": [
+ "incidence = boundary_incidence_b1(edge_index, num_nodes=CYCLE_N).to(\n",
+ " dtype=torch.float64\n",
+ ")\n",
+ "potential = torch.tensor(\n",
+ " [1.0, -0.5, 0.25, 0.0, -0.75, 1.0], dtype=torch.float64\n",
+ ")\n",
+ "flow = incidence.T @ potential\n",
+ "grad_spectrum = spectrum_from_columns(flow.unsqueeze(1))\n",
+ "grad_parts = hodge_decompose_modes(\n",
+ " grad_spectrum, edge_index, num_nodes=CYCLE_N, degree=1\n",
+ ")\n",
+ "gmode = grad_spectrum.eigenvectors[:, 0]\n",
+ "print(\n",
+ " \"gradient flow relative L2 mass harmonic\",\n",
+ " relative_l2_mass(grad_parts.harmonic[:, 0], gmode),\n",
+ ")\n",
+ "print(\n",
+ " \"gradient flow relative L2 mass gradient\",\n",
+ " relative_l2_mass(grad_parts.gradient[:, 0], gmode),\n",
+ ")\n",
+ "assert relative_l2_mass(grad_parts.gradient[:, 0], gmode) > 1.0 - ATOL\n",
+ "assert relative_l2_mass(grad_parts.harmonic[:, 0], gmode) < ATOL\n",
+ "reconstructed = grad_parts.gradient + grad_parts.curl + grad_parts.harmonic\n",
+ "assert torch.allclose(\n",
+ " reconstructed, grad_spectrum.eigenvectors, atol=ATOL, rtol=0.0\n",
+ ")\n",
+ "print(\"reconstruction matches stored columns\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "4960c8f4",
+ "metadata": {},
+ "source": [
+ "Omit `degree` on the same all-ones column. Because $N=E=6$,\n",
+ "inference returns $k=0$: curl is a residual near $10^{-16}$,\n",
+ "not a structural zero."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "t52-deep-infer",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-21T00:33:33.552969Z",
+ "iopub.status.busy": "2026-08-21T00:33:33.552858Z",
+ "iopub.status.idle": "2026-08-21T00:33:33.555497Z",
+ "shell.execute_reply": "2026-08-21T00:33:33.555138Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "N 6 E 6\n",
+ "inferred curl exactly zero False\n",
+ "inferred gradient exactly zero True\n",
+ "inferred relative L2 mass curl 2.841075513518106e-16\n"
+ ]
+ }
+ ],
+ "source": [
+ "inferred = hodge_decompose_modes(\n",
+ " spectrum, edge_index, num_nodes=CYCLE_N\n",
+ ")\n",
+ "print(\"N\", CYCLE_N, \"E\", int(edge_index.shape[1]))\n",
+ "print(\n",
+ " \"inferred curl exactly zero\",\n",
+ " bool(torch.equal(inferred.curl, torch.zeros_like(inferred.curl))),\n",
+ ")\n",
+ "print(\n",
+ " \"inferred gradient exactly zero\",\n",
+ " bool(torch.equal(inferred.gradient, torch.zeros_like(inferred.gradient))),\n",
+ ")\n",
+ "print(\n",
+ " \"inferred relative L2 mass curl\",\n",
+ " relative_l2_mass(inferred.curl[:, 0], mode),\n",
+ ")\n",
+ "assert torch.equal(\n",
+ " inferred.gradient, torch.zeros_like(inferred.gradient)\n",
+ ")\n",
+ "assert not torch.equal(inferred.curl, torch.zeros_like(inferred.curl))\n",
+ "assert relative_l2_mass(inferred.curl[:, 0], mode) < ATOL\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t52-results-md",
+ "metadata": {},
+ "source": [
+ "## Results\n",
+ "\n",
+ "This cell rebuilds the cycle so it does not depend on the\n",
+ "minimal-example or deep-dive names. $L_1=B_1^{\\top}B_1$\n",
+ "annihilates the `degree=1` circulation. $L_0=B_1 B_1^{\\top}$\n",
+ "annihilates the `degree=0` constant. On this cycle the same\n",
+ "all-ones vector is both $1_E$ and $1_N$, so both Laplacians\n",
+ "annihilate it; the structural zero (curl at $k=1$, gradient\n",
+ "at $k=0$) is what identifies the split, not the residual\n",
+ "alone. Stacked columns come first; a `compute_spectrum`\n",
+ "leading mode of a constructed cycle map follows Figure 1.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "t52-results-residuals",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-21T00:33:33.556423Z",
+ "iopub.status.busy": "2026-08-21T00:33:33.556337Z",
+ "iopub.status.idle": "2026-08-21T00:33:33.560088Z",
+ "shell.execute_reply": "2026-08-21T00:33:33.559862Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "||L_1 circulation harmonic|| 1.1957467920563633e-15\n",
+ "||L_0 constant harmonic|| 1.961044852356119e-15\n",
+ "||L_1 1|| 0.0\n",
+ "||L_0 1|| 0.0\n",
+ "circulation curl exactly zero True\n",
+ "constant gradient exactly zero True\n"
+ ]
+ }
+ ],
+ "source": [
+ "edge_index = cycle_edge_index(CYCLE_N)\n",
+ "incidence = boundary_incidence_b1(edge_index, num_nodes=CYCLE_N).to(\n",
+ " dtype=torch.float64\n",
+ ")\n",
+ "ones = torch.ones(CYCLE_N, dtype=torch.float64)\n",
+ "potential = torch.tensor(\n",
+ " [1.0, -0.5, 0.25, 0.0, -0.75, 1.0], dtype=torch.float64\n",
+ ")\n",
+ "flow = incidence.T @ potential\n",
+ "\n",
+ "circ_spectrum = spectrum_from_columns(ones.unsqueeze(1))\n",
+ "circ_parts = hodge_decompose_modes(\n",
+ " circ_spectrum, edge_index, num_nodes=CYCLE_N, degree=1\n",
+ ")\n",
+ "const_spectrum = spectrum_from_columns(ones.unsqueeze(1))\n",
+ "const_parts = hodge_decompose_modes(\n",
+ " const_spectrum, edge_index, num_nodes=CYCLE_N, degree=0\n",
+ ")\n",
+ "grad_spectrum = spectrum_from_columns(flow.unsqueeze(1))\n",
+ "grad_parts = hodge_decompose_modes(\n",
+ " grad_spectrum, edge_index, num_nodes=CYCLE_N, degree=1\n",
+ ")\n",
+ "\n",
+ "laplacian_1 = incidence.T @ incidence\n",
+ "laplacian_0 = incidence @ incidence.T\n",
+ "circ_leftover = laplacian_1 @ circ_parts.harmonic[:, 0].real\n",
+ "const_leftover = laplacian_0 @ const_parts.harmonic[:, 0].real\n",
+ "print(\"||L_1 circulation harmonic||\", float(torch.linalg.vector_norm(circ_leftover)))\n",
+ "print(\"||L_0 constant harmonic||\", float(torch.linalg.vector_norm(const_leftover)))\n",
+ "print(\"||L_1 1||\", float(torch.linalg.vector_norm(laplacian_1 @ ones)))\n",
+ "print(\"||L_0 1||\", float(torch.linalg.vector_norm(laplacian_0 @ ones)))\n",
+ "print(\n",
+ " \"circulation curl exactly zero\",\n",
+ " bool(torch.equal(circ_parts.curl, torch.zeros_like(circ_parts.curl))),\n",
+ ")\n",
+ "print(\n",
+ " \"constant gradient exactly zero\",\n",
+ " bool(torch.equal(const_parts.gradient, torch.zeros_like(const_parts.gradient))),\n",
+ ")\n",
+ "assert float(torch.linalg.vector_norm(circ_leftover)) < ATOL\n",
+ "assert float(torch.linalg.vector_norm(const_leftover)) < ATOL\n",
+ "assert torch.equal(circ_parts.curl, torch.zeros_like(circ_parts.curl))\n",
+ "assert torch.equal(\n",
+ " const_parts.gradient, torch.zeros_like(const_parts.gradient)\n",
+ ")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "62057623",
+ "metadata": {},
+ "source": [
+ "Figure 1 reports relative $L^2$ mass\n",
+ "$\\|\\mathrm{part}\\|_2/\\|\\mathrm{mode}\\|_2$ for three stacked\n",
+ "columns. Look for a circulation harmonic bar of $1$ with an\n",
+ "exactly zero curl bar, a gradient-flow gradient bar of $1$,\n",
+ "and a constant-$0$-cochain harmonic bar of $1$ with an\n",
+ "exactly zero gradient bar."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "t52-results-plot",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-21T00:33:33.561031Z",
+ "iopub.status.busy": "2026-08-21T00:33:33.560972Z",
+ "iopub.status.idle": "2026-08-21T00:33:33.816204Z",
+ "shell.execute_reply": "2026-08-21T00:33:33.815778Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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VVq3bi0rVqzQ8eO2bdsm69evd6qNHQQFBcmhQ4e0WhUBY+7cuTXAsw7YEPgBxrBDJw1bzHvXEhEREcUUbgV206ZN06xZnjx55KuvvpKPP/5Yl6MaFVWkzkIgh4DOnsKFC5v+xjy1uBERERGRm4EdOjvcvHlTOzegCvbJkyfaNg7Vqp06dZKSJUu6WjQRERERRWXnCXRewMwQyLLFjRtXq0jRBg4uX77s9FyxREREROQlM0+0a9dO53bFHK7r1q0zzflKRERERF4e2GHKMPN2dKNHj5bMmTPLzp07teNEz549PbWORERERBSZgR2qYM3HjkPVa48ePVwtjoiIiIiiMrBD54jdu3c79FwEes7MF0tEREREURjYPXr0SEqUKOFwVe2DBw9cXS8iIiIiiszADrM53L5926HnWg8uTERERERe1sYuRYoUkbMmRERERBQ949jBy5cvZfz48VKoUCGLjhS9e/eW69evu7dmRERERBR1gR2GOMFUYh07dpR79+6ZlufMmVNCQkLcKZqIiIiIojKwQ1C3fPlynULMXLVq1WTp0qXuFE1EREREUTmlGKpbg4KCwnSUSJQokUUGj4iIiIi8OLAz5ofdt29fmMBu8eLFkjdvXk+sHxERERFFxVyxffv2lRYtWsjw4cMlNDRUNm3aJKtWrZIpU6bITz/95E7RRERERBSVgR3mhMVsFOgFi8GLK1euLOnTp5epU6dK48aN3SmaiIiIiKIqsEOG7vLlyzJgwAAN8C5cuKDLs2TJwsGJiYiIiHwpsHv8+LHkzp1bpw0LCAiQrFmzenbNiIiIiChqOk+g5ys6UDg6xRgRERERefE4dug80b17d7l165bn1oiIiIiIorYqFp0lpk+fru3sfv75Z0mTJo1m8AyJEyeWY8eOuVo8EREREUVVYBcvXjz59NNP7T5uHuQRERERkRcHdnHixJEOHTp4dm2IiIiIKHra2BERERGR92BgR0REROQnGNgRERER+QkGdkRERER+goEdERERkZ9gYEdERETkJxjYEREREfkJBnZEREREfoKBHREREZGfYGBHRERE5CcY2BERERH5Ca8J7F69eiVTp06VGjVqSPXq1WXSpEny4sULj7+GiIiIyF/FES/Rp08f+fnnn2X8+PESO3Zs6du3rxw5ckS++uorj76GiIiIyF95RWB38eJF+fLLL2X+/PnS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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "columns = [\n",
+ " (\"circulation k=1\", circ_parts, circ_spectrum.eigenvectors[:, 0]),\n",
+ " (\"gradient flow k=1\", grad_parts, grad_spectrum.eigenvectors[:, 0]),\n",
+ " (\"constant k=0\", const_parts, const_spectrum.eigenvectors[:, 0]),\n",
+ "]\n",
+ "grads = [relative_l2_mass(parts.gradient[:, 0], mode) for _, parts, mode in columns]\n",
+ "curls = [relative_l2_mass(parts.curl[:, 0], mode) for _, parts, mode in columns]\n",
+ "harms = [relative_l2_mass(parts.harmonic[:, 0], mode) for _, parts, mode in columns]\n",
+ "labels = [name for name, _, _ in columns]\n",
+ "x = list(range(len(labels)))\n",
+ "width = 0.25\n",
+ "\n",
+ "fig, ax = plt.subplots(figsize=(6.4, 3.4))\n",
+ "ax.bar([i - width for i in x], grads, width, label=\"gradient\")\n",
+ "ax.bar(x, curls, width, label=\"curl\")\n",
+ "ax.bar([i + width for i in x], harms, width, label=\"harmonic\")\n",
+ "ax.set_xticks(x, labels)\n",
+ "ax.set_ylabel(r\"relative $L^2$ mass $\\|part\\|_2/\\|mode\\|_2$\")\n",
+ "ax.set_title(\"Figure 1. Hodge blocks (not squared energy)\")\n",
+ "ax.set_ylim(0.0, 1.15)\n",
+ "ax.legend()\n",
+ "fig.tight_layout()\n",
+ "plt.show()\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8688b66c",
+ "metadata": {},
+ "source": [
+ "The next cell is a `compute_spectrum` leading mode, not a\n",
+ "hand-stacked column. A seeded linear map is built so that\n",
+ "its largest-magnitude eigenvector is the cycle circulation\n",
+ "(QR with first column all-ones). This is an\n",
+ "eigendecomposition of a constructed operator, not a trained\n",
+ "`GraphKoopman` fit and not METR-LA. Pass `degree=1`. Look\n",
+ "for harmonic relative $L^2$ mass $1$ and a structural zero\n",
+ "curl."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "667c9ae9",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-21T00:33:33.817131Z",
+ "iopub.status.busy": "2026-08-21T00:33:33.817060Z",
+ "iopub.status.idle": "2026-08-21T00:33:33.821966Z",
+ "shell.execute_reply": "2026-08-21T00:33:33.821663Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "|lambda_0| 0.899999999999999\n",
+ "leading relative L2 mass harmonic 1.0\n",
+ "leading relative L2 mass gradient 6.079983772422532e-16\n",
+ "leading curl exactly zero True\n"
+ ]
+ }
+ ],
+ "source": [
+ "torch.manual_seed(0)\n",
+ "edge_index = cycle_edge_index(CYCLE_N)\n",
+ "raw = torch.randn(CYCLE_N, CYCLE_N, dtype=torch.float64)\n",
+ "raw[:, 0] = 1.0\n",
+ "basis, _ = torch.linalg.qr(raw)\n",
+ "eigenvalues = torch.tensor(\n",
+ " [0.9, 0.5, 0.3, 0.2, 0.1, 0.05], dtype=torch.float64\n",
+ ")\n",
+ "operator = basis @ torch.diag(eigenvalues) @ basis.T\n",
+ "fitted = compute_spectrum(operator, time_step=1.0)\n",
+ "fitted_parts = hodge_decompose_modes(\n",
+ " fitted, edge_index, num_nodes=CYCLE_N, degree=1\n",
+ ")\n",
+ "leading = fitted.eigenvectors[:, 0]\n",
+ "print(\"|lambda_0|\", float(fitted.magnitudes[0].real))\n",
+ "print(\n",
+ " \"leading relative L2 mass harmonic\",\n",
+ " relative_l2_mass(fitted_parts.harmonic[:, 0], leading),\n",
+ ")\n",
+ "print(\n",
+ " \"leading relative L2 mass gradient\",\n",
+ " relative_l2_mass(fitted_parts.gradient[:, 0], leading),\n",
+ ")\n",
+ "print(\n",
+ " \"leading curl exactly zero\",\n",
+ " bool(torch.equal(fitted_parts.curl, torch.zeros_like(fitted_parts.curl))),\n",
+ ")\n",
+ "assert abs(float(fitted.magnitudes[0].real) - 0.9) < ATOL\n",
+ "assert relative_l2_mass(fitted_parts.harmonic[:, 0], leading) > 1.0 - ATOL\n",
+ "assert relative_l2_mass(fitted_parts.gradient[:, 0], leading) < ATOL\n",
+ "assert torch.equal(\n",
+ " fitted_parts.curl, torch.zeros_like(fitted_parts.curl)\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t52-interpretation",
+ "metadata": {},
+ "source": [
+ "## Interpretation / discussion\n",
+ "\n",
+ "The helper replaces a hand $\\ker L_k$ membership test with\n",
+ "a three-block split of a stored column. On this cycle a\n",
+ "`degree=1` circulation is harmonic and a potential-driven\n",
+ "flow is gradient. A `degree=0` constant is the $0$-cochain\n",
+ "analogue. The leading `compute_spectrum` mode of the\n",
+ "constructed cycle map is the same harmonic 1-cochain. That\n",
+ "is analysis of stored mode shapes. It does not change the\n",
+ "factory default, is not a trained `GraphKoopman` fit, and\n",
+ "is not `koopman=\"hodge\"`.\n",
+ "\n",
+ "The method breaks down when $N=E$ and `degree` is omitted:\n",
+ "inference returns $k=0$, and a Laplacian residual on the\n",
+ "all-ones vector cannot tell $L_0$ from $L_1$. Face-based\n",
+ "$k=1$ curl is unshipped, so the curl block at $k=1$ is\n",
+ "identically zero. Forming $\\ker L_k$ remains cheaper if\n",
+ "the only question is membership.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t52-takeaways",
+ "metadata": {},
+ "source": [
+ "## Takeaways\n",
+ "\n",
+ "- Use `hodge_decompose_modes` on stored mode shapes,\n",
+ " including `compute_spectrum` columns, when the question\n",
+ " is which combinatorial Hodge block holds the column\n",
+ " ($k\\in\\{0,1\\}$ on a static signed $B_1$).\n",
+ "- When $N=E$, pass `degree=1` for a 1-cochain. Inference\n",
+ " prefers $k=0`.\n",
+ "- Relative $L^2$ mass is $\\|\\mathrm{part}\\|_2/\\|\\mathrm{mode}\\|_2$,\n",
+ " not a squared energy share. $L_0=B_1 B_1^{\\top}$ and\n",
+ " $L_1=B_1^{\\top}B_1$ are different operators.\n",
+ "- $k\\in\\{0,1\\}$; face curl is not shipped.\n",
+ "- Not physical circulation, not TopologicX, not a factory\n",
+ " kind, and not a trained `GraphKoopman` fit.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t52-further",
+ "metadata": {},
+ "source": [
+ "## Further reading\n",
+ "\n",
+ "- Sphinx: [api](../docs/source/api.rst)\n",
+ " (`hodge_decompose_modes`),\n",
+ " [faq](../docs/source/faq.rst)\n",
+ " (Hodge mode components),\n",
+ " [limitations](../docs/source/limitations.rst)\n",
+ " (node vs cochain)\n",
+ "- Lim (2020), *SIAM Review*\n",
+ " ([doi:10.1137/18M1223101](https://doi.org/10.1137/18M1223101);\n",
+ " `Lim2020Hodge`)\n",
+ "- Example [07_koopman_spectrum.ipynb](07_koopman_spectrum.ipynb)\n",
+ " (`compute_spectrum`)\n",
+ "- Example [43_tdl_sheaf_cell_ablation.ipynb](43_tdl_sheaf_cell_ablation.ipynb)\n"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "koopmangraph",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/53_latent_rank_selection.ipynb b/examples/53_latent_rank_selection.ipynb
new file mode 100644
index 0000000..a835814
--- /dev/null
+++ b/examples/53_latent_rank_selection.ipynb
@@ -0,0 +1,527 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "t53-title",
+ "metadata": {},
+ "source": [
+ "# Latent-rank selection on a linear Gaussian oracle\n",
+ "\n",
+ "`select_latent_rank` scores truncated-SVD ranks of **frozen**\n",
+ "encodings. On a rank-3 AR(1) embedded in $\\mathbb{R}^{8}$,\n",
+ "the helper recovers `selected_rank == 3` when each criterion\n",
+ "is paired with a matching excitation: the variational\n",
+ "approach for Markov processes (VAMP-2) score and\n",
+ "`stability_penalized` on a process-noise-driven series,\n",
+ "`resdmd_elbow` on the noiseless embedding. Swapping those\n",
+ "series selects rank 1 or 2 instead.\n",
+ "\n",
+ "**Honesty.** This is not Ray Tune / AutoML for encoder\n",
+ "`latent_dim`. It does not train a model per candidate.\n",
+ "`koopman_graph.tuning` remains caller-owned HPO.\n",
+ "Finite-dictionary residual dynamic mode decomposition\n",
+ "(ResDMD) residuals are not a certified\n",
+ "infinite-dimensional bound. This notebook does **not**\n",
+ "download METR-LA.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t53-setup-md",
+ "metadata": {},
+ "source": [
+ "## Setup\n",
+ "\n",
+ "Package version, the VAMP-2 plateau relative gap, and a\n",
+ "rank-3 diagonal AR(1) with an orthonormal embedding into\n",
+ "ambient 8. Process noise, when present, is isotropic on\n",
+ "the three latent coordinates (not an exogenous input).\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "t53-setup",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-21T00:47:04.657198Z",
+ "iopub.status.busy": "2026-08-21T00:47:04.657083Z",
+ "iopub.status.idle": "2026-08-21T00:47:07.623652Z",
+ "shell.execute_reply": "2026-08-21T00:47:07.621369Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "koopman_graph 0.15.0\n",
+ "VAMP2_PLATEAU_RELATIVE 0.001\n",
+ "SINGULAR_VALUE_REL_CUTOFF 1e-08\n"
+ ]
+ }
+ ],
+ "source": [
+ "from __future__ import annotations\n",
+ "\n",
+ "import os\n",
+ "import warnings\n",
+ "\n",
+ "from tqdm.std import TqdmWarning\n",
+ "\n",
+ "warnings.filterwarnings(\"ignore\", category=TqdmWarning)\n",
+ "\n",
+ "import matplotlib\n",
+ "\n",
+ "if os.environ.get(\"PYTEST_CURRENT_TEST\"):\n",
+ " matplotlib.use(\"Agg\")\n",
+ "\n",
+ "import matplotlib.pyplot as plt\n",
+ "import torch\n",
+ "\n",
+ "import koopman_graph\n",
+ "from koopman_graph.identification.invariance import SINGULAR_VALUE_REL_CUTOFF\n",
+ "from koopman_graph.identification.rank import (\n",
+ " VAMP2_PLATEAU_RELATIVE,\n",
+ " select_latent_rank,\n",
+ ")\n",
+ "\n",
+ "TRUE_RANK = 3\n",
+ "AMBIENT = 8\n",
+ "CANDIDATES = (1, 2, 3, 4, 5, 6)\n",
+ "\n",
+ "print(\"koopman_graph\", koopman_graph.__version__)\n",
+ "print(\"VAMP2_PLATEAU_RELATIVE\", VAMP2_PLATEAU_RELATIVE)\n",
+ "print(\"SINGULAR_VALUE_REL_CUTOFF\", SINGULAR_VALUE_REL_CUTOFF)\n",
+ "\n",
+ "\n",
+ "def rank3_ambient_encodings(\n",
+ " *, num_times: int, seed: int, process_noise: float\n",
+ ") -> torch.Tensor:\n",
+ " generator = torch.Generator(device=\"cpu\").manual_seed(seed)\n",
+ " true_k = torch.diag(torch.tensor([0.95, 0.90, 0.85], dtype=torch.float64))\n",
+ " embedding, _ = torch.linalg.qr(\n",
+ " torch.randn(AMBIENT, TRUE_RANK, dtype=torch.float64, generator=generator)\n",
+ " )\n",
+ " state = torch.randn(TRUE_RANK, dtype=torch.float64, generator=generator)\n",
+ " frames = [state]\n",
+ " for _ in range(num_times - 1):\n",
+ " state = state @ true_k.T\n",
+ " if process_noise > 0.0:\n",
+ " state = state + process_noise * torch.randn(\n",
+ " TRUE_RANK, dtype=torch.float64, generator=generator\n",
+ " )\n",
+ " frames.append(state)\n",
+ " return torch.stack(frames, dim=0) @ embedding.T\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t53-motivation",
+ "metadata": {},
+ "source": [
+ "## Motivation and background\n",
+ "\n",
+ "Choosing how many coordinates to keep after a frozen\n",
+ "encoder is a dictionary-rank problem, not a search over\n",
+ "`GraphKoopmanModel.latent_dim`. The usual alternatives are\n",
+ "to inspect singular values of the encoding cloud by hand,\n",
+ "or to train one model per width with\n",
+ "`example_lr_latent_dim_space` and Ray Tune.\n",
+ "\n",
+ "VAMP-2 (Wu and Noé, 2020) scores a time-lagged linear map\n",
+ "on a feature dictionary: larger\n",
+ "$\\|C_{00}^{-1/2} C_{01} C_{11}^{-1/2}\\|_F^2$ is better.\n",
+ "The in-tree score is mean-free: lag features are\n",
+ "column-mean-centered before those covariances are formed.\n",
+ "It is not the optional deeptime (`[msm]`) cross-check,\n",
+ "which includes a constant singular value. Here the\n",
+ "dictionary is a truncated-SVD projection of\n",
+ "frozen encodings. `criterion=\"vamp2\"` maximizes that\n",
+ "score, then keeps the smallest rank within relative gap\n",
+ "`VAMP2_PLATEAU_RELATIVE` ($10^{-3}$) of the peak.\n",
+ "\n",
+ "`criterion=\"resdmd_elbow\"` minimizes the maximum\n",
+ "finite-dictionary ResDMD residual of Colbrook and\n",
+ "Townsend (2023/2024); ties go to the smallest rank.\n",
+ "`criterion=\"stability_penalized\"` minimizes held-out\n",
+ "ambient one-step mean squared error (MSE) plus\n",
+ "`stability_penalty * max(0, ρ(K) - 1)`. Without\n",
+ "`validation_z`, the last half of the series is the\n",
+ "hold-out (requires $T \\ge 4$).\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t53-minimal-md",
+ "metadata": {},
+ "source": [
+ "## Minimal example\n",
+ "\n",
+ "`criterion=\"vamp2\"` on the driven series\n",
+ "(`process_noise=0.25`). Higher VAMP-2 is better; the\n",
+ "plateau rule can still prefer a smaller rank when scores\n",
+ "flatten.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "t53-minimal",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-21T00:47:07.625221Z",
+ "iopub.status.busy": "2026-08-21T00:47:07.625067Z",
+ "iopub.status.idle": "2026-08-21T00:47:07.659805Z",
+ "shell.execute_reply": "2026-08-21T00:47:07.659388Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "selected_rank 3\n",
+ "numerical_rank 3\n",
+ "candidates scored (1, 2, 3)\n",
+ "rejected ('4', '5', '6')\n",
+ "vamp2 scores (higher is better) (0.5999314398242473, 1.054344974375685, 1.411381108538763)\n",
+ "n_samples 47\n"
+ ]
+ }
+ ],
+ "source": [
+ "driven = rank3_ambient_encodings(num_times=48, seed=0, process_noise=0.25)\n",
+ "vamp = select_latent_rank(driven, CANDIDATES, criterion=\"vamp2\")\n",
+ "print(\"selected_rank\", vamp.selected_rank)\n",
+ "print(\"numerical_rank\", vamp.numerical_rank)\n",
+ "print(\"candidates scored\", vamp.candidates)\n",
+ "print(\"rejected\", vamp.rejected_alternatives)\n",
+ "print(\"vamp2 scores (higher is better)\", vamp.scores)\n",
+ "print(\"n_samples\", vamp.n_samples)\n",
+ "assert vamp.selected_rank == TRUE_RANK\n",
+ "assert vamp.numerical_rank == TRUE_RANK\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t53-dive-md",
+ "metadata": {},
+ "source": [
+ "## Progressive deep dive\n",
+ "\n",
+ "Process noise keeps all three AR(1) modes excited, which\n",
+ "VAMP-2 and the stability score need. The same noise\n",
+ "inflates extra-mode ResDMD residuals, so `resdmd_elbow`\n",
+ "is scored on the noiseless embedding, where the rank-3\n",
+ "map is exact and the max residual falls to 0.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "t53-dive-matched",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-21T00:47:07.661026Z",
+ "iopub.status.busy": "2026-08-21T00:47:07.660938Z",
+ "iopub.status.idle": "2026-08-21T00:47:07.679780Z",
+ "shell.execute_reply": "2026-08-21T00:47:07.679173Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "resdmd_elbow selected 3\n",
+ "resdmd_elbow residuals (lower is better) (0.0005696588618133979, 6.885426126343342e-05, 0.0)\n",
+ "stability_penalized selected 3\n",
+ "stability_penalized scores (lower is better) (0.035462012198183845, 0.031518218821961805, 0.029604166342325874)\n",
+ "stability n_samples 23\n"
+ ]
+ }
+ ],
+ "source": [
+ "quiet = rank3_ambient_encodings(num_times=48, seed=0, process_noise=0.0)\n",
+ "elbow = select_latent_rank(quiet, CANDIDATES, criterion=\"resdmd_elbow\")\n",
+ "stable = select_latent_rank(driven, CANDIDATES, criterion=\"stability_penalized\")\n",
+ "print(\"resdmd_elbow selected\", elbow.selected_rank)\n",
+ "print(\"resdmd_elbow residuals (lower is better)\", elbow.scores)\n",
+ "print(\"stability_penalized selected\", stable.selected_rank)\n",
+ "print(\"stability_penalized scores (lower is better)\", stable.scores)\n",
+ "print(\"stability n_samples\", stable.n_samples)\n",
+ "assert elbow.selected_rank == TRUE_RANK\n",
+ "assert stable.selected_rank == TRUE_RANK\n",
+ "assert stable.n_samples == 23\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t53-dive-manual-md",
+ "metadata": {},
+ "source": [
+ "Without `select_latent_rank`, the same truncated-SVD\n",
+ "cutoff (`SINGULAR_VALUE_REL_CUTOFF` times the leading\n",
+ "singular value) already shows that ranks 4–6 sit above\n",
+ "the numerical rank of the cloud. The helper still has to\n",
+ "score 1, 2, and 3.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "t53-dive-manual",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-21T00:47:07.680897Z",
+ "iopub.status.busy": "2026-08-21T00:47:07.680793Z",
+ "iopub.status.idle": "2026-08-21T00:47:07.683619Z",
+ "shell.execute_reply": "2026-08-21T00:47:07.683045Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "leading singular values [4.21588, 2.535549, 1.72406, 0.0, 0.0, 0.0]\n",
+ "manual numerical rank 3\n",
+ "helper numerical_rank 3\n"
+ ]
+ }
+ ],
+ "source": [
+ "rows = driven.reshape(-1, driven.shape[-1])\n",
+ "_, singular_values, _ = torch.linalg.svd(rows, full_matrices=False)\n",
+ "peak = float(singular_values[0])\n",
+ "cutoff = SINGULAR_VALUE_REL_CUTOFF * peak\n",
+ "manual_rank = int((singular_values > cutoff).sum().item())\n",
+ "print(\"leading singular values\", [round(float(s), 6) for s in singular_values[:6]])\n",
+ "print(\"manual numerical rank\", manual_rank)\n",
+ "print(\"helper numerical_rank\", vamp.numerical_rank)\n",
+ "assert manual_rank == vamp.numerical_rank == TRUE_RANK\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t53-dive-swap-md",
+ "metadata": {},
+ "source": [
+ "Swapping the series is a negative control, not a second\n",
+ "oracle success. `resdmd_elbow` on the driven cloud and\n",
+ "`vamp2` on the noiseless cloud do not recover rank 3.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "t53-dive-swap",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-21T00:47:07.684686Z",
+ "iopub.status.busy": "2026-08-21T00:47:07.684599Z",
+ "iopub.status.idle": "2026-08-21T00:47:07.687581Z",
+ "shell.execute_reply": "2026-08-21T00:47:07.687235Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "resdmd_elbow on driven 1 (0.46120521034851536, 0.7564452840223927, 0.8067968093510759)\n",
+ "vamp2 on noiseless 2 (0.9996073261347352, 1.742493502507564, 1.743306158740795)\n"
+ ]
+ }
+ ],
+ "source": [
+ "elbow_driven = select_latent_rank(driven, CANDIDATES, criterion=\"resdmd_elbow\")\n",
+ "vamp_quiet = select_latent_rank(quiet, CANDIDATES, criterion=\"vamp2\")\n",
+ "print(\"resdmd_elbow on driven\", elbow_driven.selected_rank, elbow_driven.scores)\n",
+ "print(\"vamp2 on noiseless\", vamp_quiet.selected_rank, vamp_quiet.scores)\n",
+ "assert elbow_driven.selected_rank == 1\n",
+ "assert vamp_quiet.selected_rank == 2\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t53-results-md",
+ "metadata": {},
+ "source": [
+ "## Results\n",
+ "\n",
+ "Figure 1 plots scores against scored ranks for the three\n",
+ "**matched** pairings. VAMP-2 rises through rank 3 (about\n",
+ "0.60, 1.05, 1.41); the peak is isolated, so the plateau\n",
+ "rule still selects 3. On the noiseless cloud the ResDMD\n",
+ "residual falls to 0 at rank 3. Stability-penalized\n",
+ "held-out MSE decreases slightly (about 0.035, 0.032,\n",
+ "0.030) with 23 lag pairs from the last half of $T=48$.\n",
+ "Candidates 4, 5, and 6 are rejected as above the\n",
+ "numerical SVD rank; they are never scored.\n",
+ "\n",
+ "The swapped-series cell shows the complementary failure:\n",
+ "driven `resdmd_elbow` selects 1 (residuals rise with\n",
+ "rank) and noiseless `vamp2` selects 2 (ranks 2 and 3 lie\n",
+ "on the plateau). Rank-3 recovery is therefore a property\n",
+ "of the pairing, not of the oracle alone.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "t53-results",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-21T00:47:07.688701Z",
+ "iopub.status.busy": "2026-08-21T00:47:07.688585Z",
+ "iopub.status.idle": "2026-08-21T00:47:07.884267Z",
+ "shell.execute_reply": "2026-08-21T00:47:07.883720Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": "iVBORw0KGgoAAAANSUhEUgAAA7YAAAE+CAYAAAC5howEAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlcelbwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAmsZJREFUeJzt3QV4FNfXBvATDwlJcHd3dy0uheIuxSnQFii0lCpVaCmF0iLFi2uBAoUWiru7uwYnCXHZ73kv/8m3WSK7ySZr7+95BnYnM7uzMzt358y991wnnU6nEyIiIiIiIiIb5WzpDSAiIiIiIiJKCQa2REREREREZNMY2BIREREREZFNY2BLRERERERENo2BLREREREREdk0BrZERERERERk0xjYEhERERERkU1jYEtEREREREQ2jYEtEdmV+fPny6effmrpzSAThYaGyjvvvCP//PNPmuy7sWPHqu+KJeBz/vXXX6m2PDmWnTt3qu/IixcvLL0pREQW5WrZtyciMt7hw4dl3rx58f6tSJEiMnr0aPnvv/9k79698t1339n0rg0ODpYNGzbInj17JH369PLDDz+k6PU++OADCQkJkUqVKsmgQYNe+/utW7dk/Pjx6nGvXr2kdu3aJr1+VFSUvPvuu9KqVSs1mSo8PFx+//13KVCggDRr1kxS26JFi+SNN96Qvn37SlrD58ySJYu89dZbqbI8OZazZ8+q78hnn30mGTJkMHn9yMhI2bRpk5w8eVKVEfnz55cOHTpIjhw5UmV7iYhSC2tsichmXL58WV3AOTk5SYUKFeJMRYsWVcv069fP5oPaTz75RAoWLCjr1q2TzZs3yx9//JHi18QNAey7MWPGSFhY2Gt/nzt3rvo7pgsXLpj8+ghsse7Ro0dTvK32bsaMGdKmTZtUW57IWCdOnJBSpUrJsmXLxN3dXQXGS5cuVTeYcD4TEdkS1tgSkc1BjV7btm3j/VvDhg3F1rVr104+//xzSZcundSpU0euXr1qltetUqWKHD9+XAXMXbt2jZ2v0+lU8FyjRg05ePCgWd6LEoZmo6m5PJGxUCuLljAZM2aM00wf5ej7778vXbp0SVYtMBGRJTCwJSK7gn6TCAQNa20fP36smp/evn1bSpQoIb1795Z///1XtmzZIjNnzoxdDuv5+vrKe++9F2f9NWvWyPbt22XatGmx86ZPny5Pnz5VQSheC82gc+XKJcOHD1d/f/LkiaxatUrVNHt4eEj9+vWlRYsWSX6GqlWrSmrInTu3ZMqUSRYsWBAnsMV2Y7+gpji+wBa1vAEBAeqxm5ub+owtW7aU8uXLq3kPHz5UzSBh48aN4u/vH/s5+vfvH/s6z549k9WrV8ulS5fEx8dHXTzXq1fvtfdDc8iFCxfKxYsXVc1Rnz594r24Nnb/ohZ57dq1qsllkyZN1GRsv9+RI0eqGw01a9ZUwf+1a9ekcOHC8vbbb6vviSn7ST9QxXytabH++6AJ+JIlS+TcuXPSrVs39b6JLV+3bl2j9hWCl/Xr18fZBwhgihUrFqc5NrZ/+fLl6hzy8vJSTddbt24tzs4JN/BKavsBN1T+/vtvefTokeTMmVM6duwY28pCH5rDopUCvlNotYDl8L3Vh37Yu3btUt8TdEFA8JU1a1b1tyNHjqjWByNGjFDnuT689xdffCHt27eXpk2bxs5PatvQhxX7ZMKECWq7VqxYIQ8ePFBlQXz7JbHl8V2dMmWKWg4tT3DzqmTJkuo99YNL/ddA2YV9GhgYqL7jxtbez5o1S9XIoixDrWx88HkNYbtq1aqltgHnMgNbIrIVbIpMRHYFQRouAg37oOHCDhe86Kt4/fp11Q90x44drzW3Q6CE/maGDhw48NqyuBjGRevHH3+sAmo/Pz85c+aM+hsuzhEAIZjGRTcugHv27Kku/qOjo8VSEMRs3bpV7t27FzsP244gtHTp0vGug/lak28ET7hYrly5cuz+QFCpBW+oAdKWRV89DfYpAhXcRECfYRcXF/nyyy/lo48+ivNeL1++VLXxN2/elMyZM8uPP/4o1atXV31w9Rm7fydPnizVqlVTzatx7PH+xjZV1/r94nvy5ptvqn2Gi3y8ZtmyZVW/ZFP2kwbPEWgavg8CCbwPgmdXV9fYmvqElkf/a2P21U8//aTm44YC9tXs2bPVPsC+w/miwfsiUEQAj33l6empgmb0RTZmP8W3/WgNMHToUPX+mJ8vXz51jmBf6Z+nWA4BPPbX6dOnVTCLz9WoUSN17kFERIQK8PGZ0Qc9e/bs6iYNglC8N+A7gXn6N6A0+CzYTmyD9p7GbJvWhxU3ZQYPHqw+G24kxMTExLs/ElseN3S070i5cuXU9+nXX3+V4sWLx/k+aa+BfvZDhgxRxwKfGd/vpJLjoVsA9iUCWgSoCQW1iR1P3PDDdwETEZHN0BER2YhFixbpUGw1a9ZMN3jw4DjT1atX1TI9evTQ5c+fP856VapU0RUuXFgXFBQUO+/EiRM6Pz8/9Xr6ypcvr17f0KhRo3QuLi5x5r355ps6b29v3bfffhs7D+9x7949Nb9Tp066mJiY2L+dO3dO5+rqqpsyZYrRn7l27dq67Nmz61IKn7VNmza60NBQXYYMGXTjx49X8wMCAnTp0qXTTZ8+Xbdnzx61P2bPnp3k633xxRdqvZcvX6rneF2s++WXX7627O3bt3VeXl66tm3b6iIjI+P87cqVK+r/58+fq/WzZcumu3btWuzfjxw5oubPnDkzdp6x+/f8+fPqmI0YMSLOe44ePVrn6empviuJ0bYpc+bMukuXLsXOf/z4sS5r1qy6Fi1amLyfAK/56aefvvY+mTJlivM+2vc1oeWN2VfYJ87OzroPPvggznZ98sknr+2DsWPHqu9GREREnGUvX75s1H6Kb/unTZum/rZ27drX9gve/+7du+o5jhmWW758eZzlgoOD1fGGcePGqWW2bdsW+/fw8HBdjRo11L7AstClSxddxowZdWFhYXFeq2TJkup80hi7bb/++qtarlWrVrHfXxxP/e+ePlOXx3Zi27p16/baa3To0EEXFRUVO3/48OFq2/AdNFz2zp07uhcvXugaN26svrO7d+/WGQv7AOUotiF37tzq3MJ5S0RkS1hjS0Q2BzV/hsmjvL294132ypUrqikqak5QU6jBOqjJSykkYkJfNA3eAzVDqF1BjSSa9WlQc4IaKCRnsRTU/KDpppaQCs0dUZOk3zTZEGpAUXOEJtfDhg1TtUHYp2iCipqopGB/oNnot99+q2qv9BnWCDVu3FgKFSoUp18waub0k1IZu3/x2bDtyJatb9SoUfEm0EpIgwYNVJNdDWozkaQMtcZoJmqu/YSaUf330f++xseYfYUWBTi+hvsAzeUN9wFiaBwnNAfWF1+TYWO3H831ca4Z9olHbSLe/88//1TPUZOO5fDd1Ifm0GjSDahhxjmLY6xBwiN8NjQjxvGAAQMGyPPnz1Xzcw1qfVFrj79pjN02DY659v1FeaP/3YtPQsvjO4Nac2w3amNxLFDLihp+Q2jKj9YNGrQ0wbahqbchtERB0290K0CXAjRTNxaaJGNfoFk0WlqgBhwtAoiIbAn72BKRXSWPMqQ159S/4NZgHprlpgQuCNG8UJ920an1wUPA8Kri7dWwOuiXa0noh4lmjrj4RTNk7Ev9/n36EECiLywumtFnEkEOgmM0PwYEEElBUIcLfMM+j/GJL4jKli2b3L9/3+T9i2OPgMKwjyaaS6PZuLHQTNSQ9lnQhBXNe82xn+L7jqZ0X2n7wLAvJZYz3AcIxpFYDMEjmpYjUEVfXPRbTqyPbULbj2OCYBLNgzEUlHaMtOOFgO3GjRsq8Ebf0x49eiT42lgG+xbJ1AxpTW21cx2BL25+oeuBdsMGj3GedurUyaRtS+zzmbo/YPfu3So4xfcH+xXbiffCzQQ0vU7qGOO4gf4x1qCZNo71+fPnVdN0U6A5NibAjRnkIMCE70FCXRSIiKwNA1sismtabQf65xmKbx5qgFB7YgiJW+ITX4CEIADvi36YhrU66FenBTuWguzHuLBGXz0Et+PGjUtwWQQE6N957NgxlUhIv9bUWNgXCEywX/Vrn+KDYDC+/anfb9bY/Ytl8J4IVAyXQxIlY8W3rPbd0WrkzLGfTAm2jd1XpuyDPHnyqD6mSMyEGjtMU6dOVf1e0c84qRrkhM4FBFllypR57W+//fabmo/twnLxnY8abZnEjoX23cKy6EuOGn3c6EAN+8qVK1WQq9+yw5htS+rzJSa+5VFLi2B2//79cVovGNYOJ3SMtRsM8fXTR3/1r7/+WrVIwPcxqXMtqZtfWh9sBrZEZCsY2BKRXdMunJGQRqut0WCeIdTuxdds9NSpU0a/JwIBBDTIEItAyxohqy+y4uLzJpYlGLVWyPCrH6zBvn374jzXLqK1mi/D/YGmz8hYG1+Nm6mM3b8IfHFxjuOJJpYaJFFCk1tjGTbNBTQbxX7Ram6N3U9pTdsHqBHVr3lG7WZ8+wDBFmo8tea+WBc1d8h2nVhz9fjgvKtYsaJ6n6SGLMJ+QxNqBGzxBWR4LZzL8TXXRVZj7bNqENjihg1aJKBpbVBQUJwM3aZsmznhe4Is0/pBLZqq43OlJBDVmj4jCO3evbu6EYexaZN7Ew37CxLq4kFEZI3Yx5aI7Br656HpH5re6jf1Q5PLu3fvvrY8LugRCB06dCh2HvpqakPYGFvbgYARTRwNm6DiPVPa/Fnf+PHj1dAmpsJF/owZM1SAmFgzUzTzRC2Zfn87BGsY+kgfgjo0ydXPtqxBYISmsB988EGcPqnoK5icfWHs/kWWZNQyYigirRYe/2OfGQ7Vkxg07dy2bVucjLXYb9rrm7Kf0hqa9yI40d8HCB6RQdlwH6B/MPqq6tM+X3IDHLQKwA2kH3744bWbHqgF1s5JZBZHs+6vvvoqznIIwNFkGJDBGI/RP1WD4XRwPHGDAX2O9Wuf0WUBge2cOXNUwKc1tTV128wJ3xN8L7TM1XhfbAeG/TGHDh06qAzkGH4MGaqRZTwx6JeMYbj04ZxCf3jUOCMIJyKyFayxJSK7hwth9D9DDQ0uflGbgeaJ6As5adKkOMsiuQxqqTDOZfPmzdVYqegrh9pebfzJpCBgQBM+BBXow4daSlwk4sIdgR2CisSgdgwTYB1sr1arhItz/XFHMb4uAnFjt02DINSYmirU7KLmB0ECLnKxLbjwRZNH1AzpQ4IufDaMhYomnto4tvjsuNBGYiAEIEhqo/UrRNNMY8eVNXX/IphGIiksh9o81AqiqS2CPP1ANSmDBg1STXLxXUHTUKyLRE0Y9ic5+ymtb+xgHyAIR+02zgEE6hizGAGQ/k0N7Dt8v/LmzauSUqGvMgI8nBMIkpIDY67OmzdPNY/FMDx4f9zQwM0BDK+jJTHDMDboM42xgPGdxnLoR4ptQgIs7TggGRwCXAzHg0RZaC6NcxmJogxrPPHdw/iwSKb0888/J3vbzAnfIfStRaCNRE/4PqKsQf9sDLFjDijjcH6gzMNjDEuG8avjgxsZ2A58T1CzjWOOpuh4ju3R+vQSEdkCJ6RGtvRGEBEZAxe1uGBDwIlxQuODGrIHDx68logGtVS4CL5z544KrtDPFAHi+vXrX6uxwLJ4H9Q+IhhA01f0RUXtDi6u9Ws70GSvc+fOCW4zgjdcKKPpIxLB4LWSanKIJpn6mW31IcjGRbB+3zwEUPrNLOODmitcrCLwSghqpVGTjaRB+ome8DOxd+9elbwHNWH4O5ZFDR+COP3kTGhujOROCBCQ8Vi/Fg39bFETjqbAuNDGMdAunNFPEkEG5iE7qz6MB4rsuLhQT87+xcU6jidqVOvVq6cCt8WLF6v9ob8vDb148UIl1UKNIAIuJP7BPkAwjeDcsM+qsfsJGYCxnQj8k/rspi6f0L7S3wfYLnx2BOm4GYGgXYPXRl9h3CTAGKt4X3yWxCS1/YAmv6ipRI06jj1uNOhndNbgpgj2M7YXfVExDitaA+jDa+C18Jo4FmiSHt85hc+K7cJxwU2VhBKkJbVt+D6jJr5Xr15G1VwntTy+V/iM+Kw4pjjXcAMBZRNaNyT2GjjXEeijZYmWWCqhZXHDC2UemuHXr18/we3FfsJ5i/IVTaSxPIL8pLI+ExFZGwa2ROSwcBGOmj7UUBAlFtiiqaw9QU0hbtrMmjVLBg4caOnNISIiSjH2sSUiu4caXMNxH9HvDgmhkHCFyJ5hDFcMR6RfQ4kaaNzUad++vUW3jYiIyFzYx5aI7B6aKaJpMhLhoHkjmtyh6d37778f2/SPyJ5v7CCjMWpo0UwZgS6CWzRpNXW8UyIiImvFpshE5BDQvxPBLIY9wVi16A+IZClEKek7akvNqtF3Fv3Gc+TIofoacygXIiKyJwxsiYiIiIiIyKaxjy0RERERERHZNAa2REREREREZNMY2BIREREREZFNY2BLRERERERENo2BLREREREREdk0BrZERERERERk0xjYEhERERERkU1jYEtEREREREQ2jYEtERERERER2TQGtkRERERERGTTGNgSERERERGRTWNgS0RERERERDaNgS0RERERERHZNAa2REREREREZNMY2BIREREREZFNY2BLRERERERENo2BLREREREREdk0BrZERERERERk0xjYEhERERERkU1jYEtEREREREQ2jYEtERERERER2TQGtkRERERERGTTGNhSsrRp00a6dOkSZ16VKlWkcePGRq2P5WrUqJHsvW/Ke6UWa9gGc2rSpIn06dPH0ptBZNXeffddyZAhg1HLTpgwQZycnOTFixdWs00wZswYqVChguh0umS/hjXr2bOn5MmTR+zFe++9JzVr1rT0ZpCR/P391Xk/ZcqUJJcNCwtTy3777bcmz6NXUL5myZJFFi1aZJf7y5Tvky148uSJ+Pr6yurVq1Pl9RnYxmPo0KHqS3ThwoUEd9zChQvVMvonEqxZs0bNxwVCaGhovOuuW7dOLYPpjz/+iHcZBI3aMlFRUfGui8nFxUWyZcsm7dq1kxMnTiR5wI8ePSqDBw+WEiVKiJeXlxQtWlTeeecduXfvnhhr+/btsnHjRhk3bpzR65Bx6tSpoyZT/2YO+AHA9/rYsWOp9h5kvJkzZ8Y5111dXSVHjhzSuXPnRMumlLp48eJr75spUyapVKmSDBs2TJUhia0zduzY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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "rejected (above numerical SVD rank) ('4', '5', '6')\n"
+ ]
+ }
+ ],
+ "source": [
+ "fig, axes = plt.subplots(1, 3, figsize=(9.6, 3.2))\n",
+ "panels = (\n",
+ " (axes[0], vamp.candidates, vamp.scores, vamp.selected_rank, \"VAMP-2 (higher better)\"),\n",
+ " (axes[1], elbow.candidates, elbow.scores, elbow.selected_rank, \"ResDMD residual (lower better)\"),\n",
+ " (axes[2], stable.candidates, stable.scores, stable.selected_rank, \"Stability MSE (lower better)\"),\n",
+ ")\n",
+ "for ax, ranks, scores, chosen, title in panels:\n",
+ " ax.plot(ranks, scores, marker=\"o\")\n",
+ " ax.axvline(chosen, color=\"0.5\", linestyle=\"--\")\n",
+ " ax.set_xticks(list(ranks))\n",
+ " ax.set_xlabel(\"scored rank\")\n",
+ " ax.set_title(title)\n",
+ "axes[1].set_ylim(bottom=0.0)\n",
+ "fig.suptitle(\"Figure 1. Matched pairings recover rank 3\")\n",
+ "fig.tight_layout()\n",
+ "plt.show()\n",
+ "\n",
+ "print(\"rejected (above numerical SVD rank)\", vamp.rejected_alternatives)\n",
+ "assert vamp.rejected_alternatives == (\"4\", \"5\", \"6\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t53-interp",
+ "metadata": {},
+ "source": [
+ "## Interpretation / discussion\n",
+ "\n",
+ "The selected integer is a dictionary rank of the frozen\n",
+ "cloud. It is not a recommendation that encoder\n",
+ "`latent_dim` equal 3, and it is not Ray Tune.\n",
+ "`example_lr_latent_dim_space` remains a caller-owned\n",
+ "smoke scaffold; see the [tuning API](../docs/source/api.rst).\n",
+ "\n",
+ "The three criteria answer different questions (kinetic\n",
+ "variance, finite-dictionary residual, held-out\n",
+ "reconstruction plus a spectral-radius penalty). They\n",
+ "agree on this linear Gaussian toy only when excitation\n",
+ "matches the score. A nonlinear encoder, a short series,\n",
+ "or a weakly excited mode can move the numerical SVD rank\n",
+ "or the VAMP-2 plateau.\n",
+ "\n",
+ "`resdmd_elbow` uses a finite dictionary. It is not a\n",
+ "certified Colbrook–Townsend residual bound on the\n",
+ "infinite-dimensional Koopman operator.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t53-takeaways",
+ "metadata": {},
+ "source": [
+ "## Takeaways\n",
+ "\n",
+ "- **When to use.** Score a candidate grid on **frozen**\n",
+ " encodings when the cloud is approximately low-rank and\n",
+ " you want a documented integer dictionary rank.\n",
+ "- **When not to use.** Not Ray Tune for `latent_dim`.\n",
+ " Do not train one encoder per candidate; do not treat\n",
+ " `selected_rank` as a recommended encoder width.\n",
+ "- **Pairing gotcha.** On this rank-3-in-ambient-8\n",
+ " oracle, VAMP-2 / `stability_penalized` need process\n",
+ " noise; `resdmd_elbow` needs the noiseless embedding.\n",
+ " Swapping those series selects 1 or 2, not 3.\n",
+ "- **Selection rules.** VAMP-2 is maximized, then the\n",
+ " smallest rank within $10^{-3}$ of the peak is kept.\n",
+ " ResDMD residuals and stability scores are minimized\n",
+ " (ties → smallest rank).\n",
+ "- **Other gotchas.** Candidates above the numerical SVD\n",
+ " rank are rejected without scoring.\n",
+ " `stability_penalized` without `validation_z` uses the\n",
+ " last half of $T$ as hold-out. Finite-dictionary\n",
+ " ResDMD is not a certified residual bound.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t53-reading",
+ "metadata": {},
+ "source": [
+ "## Further reading\n",
+ "\n",
+ "- Sphinx: [identification](../docs/source/identification.rst),\n",
+ " [faq](../docs/source/faq.rst),\n",
+ " [api](../docs/source/api.rst) (`select_latent_rank`,\n",
+ " `koopman_graph.tuning`),\n",
+ " [limitations](../docs/source/limitations.rst)\n",
+ "- Wu and Noé (2020), *J. Nonlinear Sci.*\n",
+ " ([doi:10.1007/s00332-019-09567-y](https://doi.org/10.1007/s00332-019-09567-y);\n",
+ " `Wu2020VAMP`)\n",
+ "- Colbrook and Townsend (2023/2024), *Comm. Pure Appl. Math.*\n",
+ " ([doi:10.1002/cpa.22125](https://doi.org/10.1002/cpa.22125);\n",
+ " `ColbrookTownsend2023ResDMD`)\n",
+ "- Hoffmann et al. (2021/2022), *Mach. Learn.: Sci. Technol.*\n",
+ " ([doi:10.1088/2632-2153/ac3de0](https://doi.org/10.1088/2632-2153/ac3de0);\n",
+ " `deeptime2021`; optional VAMP-2 cross-check, not a runtime\n",
+ " requirement)\n",
+ "- Example [48_identification_invariance.ipynb](48_identification_invariance.ipynb)\n",
+ "- Example [40_resdmd_pseudospectra.ipynb](40_resdmd_pseudospectra.ipynb)\n",
+ " (resolvent-norm grid; not this rank helper)\n"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "koopmangraph",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/54_criticality_monitor.ipynb b/examples/54_criticality_monitor.ipynb
new file mode 100644
index 0000000..399256d
--- /dev/null
+++ b/examples/54_criticality_monitor.ipynb
@@ -0,0 +1,339 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "t54-title",
+ "metadata": {},
+ "source": [
+ "# Criticality monitor on a closing spectral gap\n",
+ "\n",
+ "`monitor_critical_transition` scores a sequence of spectra. On\n",
+ "$K_t=\\mathrm{diag}(1, 1+\\delta_t)$ with $\\delta_t$ closing from 1\n",
+ "to 0, the windowed closure rate is positive and larger than a\n",
+ "constant-gap control.\n",
+ "\n",
+ "**Honesty.** The score is a heuristic. It is **not** a certificate\n",
+ "and not Ghosh (2025) neural Koopman forecasting for infrastructure\n",
+ "transitions. This notebook does **not** download METR-LA.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t54-setup-md",
+ "metadata": {},
+ "source": [
+ "## Setup\n",
+ "\n",
+ "Package version, eleven diagonal spectra, and window 5.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "t54-setup",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "koopman_graph 0.15.0\n"
+ ]
+ }
+ ],
+ "source": [
+ "from __future__ import annotations\n",
+ "\n",
+ "import os\n",
+ "\n",
+ "import matplotlib\n",
+ "\n",
+ "if os.environ.get(\"PYTEST_CURRENT_TEST\"):\n",
+ " matplotlib.use(\"Agg\")\n",
+ "\n",
+ "import matplotlib.pyplot as plt\n",
+ "import torch\n",
+ "\n",
+ "import koopman_graph\n",
+ "from koopman_graph.analysis import monitor_critical_transition\n",
+ "from koopman_graph.spectrum_types import compute_spectrum\n",
+ "\n",
+ "print(\"koopman_graph\", koopman_graph.__version__)\n",
+ "\n",
+ "N_TIMES = 11\n",
+ "WINDOW = 5\n",
+ "GAP0 = 1.0\n",
+ "\n",
+ "\n",
+ "def diagonal_spectrum(primary: float, secondary: float):\n",
+ " matrix = torch.diag(torch.tensor([primary, secondary], dtype=torch.float64))\n",
+ " return compute_spectrum(matrix, time_step=1.0)\n",
+ "\n",
+ "\n",
+ "def closing_spectra():\n",
+ " spectra = []\n",
+ " for step in range(N_TIMES):\n",
+ " gap = GAP0 * (1.0 - step / (N_TIMES - 1))\n",
+ " spectra.append(diagonal_spectrum(1.0, 1.0 + gap))\n",
+ " return spectra\n",
+ "\n",
+ "\n",
+ "def stable_spectra():\n",
+ " return [diagonal_spectrum(1.0, 1.0 + GAP0) for _ in range(N_TIMES)]\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t54-motivation",
+ "metadata": {},
+ "source": [
+ "## Motivation and background\n",
+ "\n",
+ "The closest-eigenvalue gap on a stored spectrum is\n",
+ "$\\gamma_t=\\min_{i\\neq j}\\lvert\\lambda_i(t)-\\lambda_j(t)\\rvert$.\n",
+ "On this $2\\times 2$ diagonal toy, $\\gamma_t=\\delta_t$. Without\n",
+ "the helper, a caller would difference those gaps by hand and\n",
+ "could treat a shrinking gap as a Ghosh-style early-warning\n",
+ "score. The helper only records windowed shrinkage from spectra\n",
+ "the caller already computed.\n",
+ "\n",
+ "Ghosh (2025) is related literature for infrastructure\n",
+ "forecasting and the honesty ceiling for this helper, not an\n",
+ "implemented method\n",
+ "([doi:10.1016/j.iswa.2025.200575](https://doi.org/10.1016/j.iswa.2025.200575)).\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t54-minimal-md",
+ "metadata": {},
+ "source": [
+ "## Minimal example\n",
+ "\n",
+ "Linearly closing $\\delta_t=1-t/10$ versus a constant gap.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "t54-minimal",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "closing max score 0.10000000000000003\n",
+ "control max score 0.0\n",
+ "score rose versus the constant-gap control\n"
+ ]
+ }
+ ],
+ "source": [
+ "closing = monitor_critical_transition(closing_spectra(), window=WINDOW)\n",
+ "control = monitor_critical_transition(stable_spectra(), window=WINDOW)\n",
+ "print(\"closing max score\", closing.max_gap_closure_rate)\n",
+ "print(\"control max score\", control.max_gap_closure_rate)\n",
+ "assert closing.max_gap_closure_rate > control.max_gap_closure_rate\n",
+ "assert abs(closing.max_gap_closure_rate - 0.1) < 1e-12\n",
+ "assert abs(control.max_gap_closure_rate) < 1e-12\n",
+ "print(\"score rose versus the constant-gap control\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t54-deep-md",
+ "metadata": {},
+ "source": [
+ "## Progressive deep dive\n",
+ "\n",
+ "Window-5 closure on unit sample times is\n",
+ "$(\\gamma_{t-4}-\\gamma_t)/4=0.1$ at every valid $t$\n",
+ "(window end $t=4,\\ldots,10$). All `near_defective` flags are\n",
+ "false: this diagonal eigenbasis remains usable even when\n",
+ "$\\gamma_t=0$ at the last spectrum. Coalescence is not\n",
+ "near-defectivity.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "t54-deep",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "gaps [1.0, 0.8999999999999999, 0.8, 0.7, 0.6000000000000001, 0.5, 0.3999999999999999, 0.30000000000000004, 0.19999999999999996, 0.10000000000000009, 0.0]\n",
+ "rates [0.09999999999999998, 0.09999999999999998, 0.10000000000000003, 0.09999999999999998, 0.10000000000000003, 0.09999999999999998, 0.09999999999999998]\n",
+ "near_defective [False, False, False, False, False, False, False, False, False, False, False]\n"
+ ]
+ }
+ ],
+ "source": [
+ "print(\"gaps\", closing.spectral_gap.tolist())\n",
+ "print(\"rates\", closing.gap_closure_rate.tolist())\n",
+ "print(\"near_defective\", closing.near_defective.tolist())\n",
+ "assert not bool(closing.near_defective.any().item())\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t54-times-md",
+ "metadata": {},
+ "source": [
+ "Doubling the sample times ($\\tau_t=2t$) halves the rate: the\n",
+ "same $\\Delta\\gamma$ is divided by a longer $\\Delta\\tau$.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "t54-times",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "unit-time max score 0.10000000000000003\n",
+ "doubled-time max score 0.05000000000000002\n",
+ "doubling tau halved the closure rate\n"
+ ]
+ }
+ ],
+ "source": [
+ "times = [2.0 * step for step in range(N_TIMES)]\n",
+ "scaled = monitor_critical_transition(\n",
+ " closing_spectra(), window=WINDOW, sample_times=times\n",
+ ")\n",
+ "print(\"unit-time max score\", closing.max_gap_closure_rate)\n",
+ "print(\"doubled-time max score\", scaled.max_gap_closure_rate)\n",
+ "assert abs(scaled.max_gap_closure_rate - 0.05) < 1e-12\n",
+ "print(\"doubling tau halved the closure rate\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t54-results-md",
+ "metadata": {},
+ "source": [
+ "## Results\n",
+ "\n",
+ "The figure plots the gap $\\gamma_t$, not the scalar score\n",
+ "`max_gap_closure_rate`. The blue series falls linearly from\n",
+ "$1$ to $0$ over spectrum indices $0$ through $10$. The orange\n",
+ "control stays at $1$. That constructed shrinkage is why the\n",
+ "window-5 score on unit sample times is $0.1$ eigenvalue units\n",
+ "per step versus $0$.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "t54-results",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "fig, ax = plt.subplots(figsize=(5.5, 3.2))\n",
+ "ax.plot(closing.spectral_gap.numpy(), label=\"closing gap\")\n",
+ "ax.plot(control.spectral_gap.numpy(), label=\"constant gap\")\n",
+ "ax.set_xlabel(\"spectrum index\")\n",
+ "ax.set_ylabel(r\"$\\gamma_t$\")\n",
+ "ax.set_title(\"Heuristic gap (not a certificate)\")\n",
+ "ax.legend()\n",
+ "fig.tight_layout()\n",
+ "plt.show()\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t54-interp",
+ "metadata": {},
+ "source": [
+ "## Interpretation / discussion\n",
+ "\n",
+ "The score rises because $\\gamma_t$ shrank, by construction.\n",
+ "That is the teaching check against the constant-gap control.\n",
+ "Doubling $\\tau$ halves the rate without changing the gap\n",
+ "trajectory. The last spectrum has $\\gamma_t=0$ and still does\n",
+ "not flag near-defectivity.\n",
+ "\n",
+ "The helper is not an early-warning product and not a Ghosh\n",
+ "certificate. Callers still have to supply spectra; the monitor\n",
+ "does not fit a model or infer edges.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t54-takeaways",
+ "metadata": {},
+ "source": [
+ "## Takeaways\n",
+ "\n",
+ "- When to use: after you already have a `KoopmanSpectrum`\n",
+ " sequence and want a sliding-window record of whether the\n",
+ " closest-eigenvalue gap shrank.\n",
+ "- When not to use: not a topology-criticality certificate,\n",
+ " not a Ghosh (2025) early-warning product, not a stability\n",
+ " certificate, and not a forecast-horizon collapse test.\n",
+ " This notebook is a diagonal toy and does **not** download\n",
+ " METR-LA.\n",
+ "- Gotchas: $2\\le w\\le T$; default $\\tau$ is unit-spaced\n",
+ " (doubling $\\tau$ halves the rate); pairwise min, not\n",
+ " magnitude-adjacent neighbors; coalescence is not\n",
+ " near-defectivity on a well-conditioned eigenbasis.\n",
+ " Positive rate means the gap shrank.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "t54-reading",
+ "metadata": {},
+ "source": [
+ "## Further reading\n",
+ "\n",
+ "- Sphinx: [Spectral-gap criticality monitor](https://koopmangraph.readthedocs.io/en/latest/criticality.html),\n",
+ " [Spectral diagnostics](https://koopmangraph.readthedocs.io/en/latest/spectral_diagnostics.html),\n",
+ " [API reference](https://koopmangraph.readthedocs.io/en/latest/api.html)\n",
+ " (`monitor_critical_transition`)\n",
+ "- Ghosh (2025), *Intelligent Systems with Applications*\n",
+ " ([doi:10.1016/j.iswa.2025.200575](https://doi.org/10.1016/j.iswa.2025.200575))\n",
+ "- Example [51_spectral_diagnostics.ipynb](51_spectral_diagnostics.ipynb)\n"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "koopmangraph",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.13"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/cli/README.md b/examples/cli/README.md
index d438e74..0c4f64c 100644
--- a/examples/cli/README.md
+++ b/examples/cli/README.md
@@ -37,5 +37,9 @@ koopman-graph predict \
`GraphSnapshotSequence` cache. The forecast file is a `torch.save` payload
with `steps`, `forecasts`, and `summary`.
-See `koopman-graph train --help` and `koopman-graph predict --help` for
-flags and exit-code conventions.
+See `koopman-graph train --help`, `koopman-graph predict --help`, and
+`koopman-graph benchmark --help` for flags and exit-code conventions.
+Identity-bound `benchmark run` / `verify` hash a frozen manifest; they
+do not train a model. The Jupyter walkthrough is
+[`examples/47_benchmark_manifest.ipynb`](../47_benchmark_manifest.ipynb)
+(collected by `nbmake`; YAML still needs `[cli]`).
diff --git a/paper.bib b/paper.bib
index 2530448..c1cd4b8 100644
--- a/paper.bib
+++ b/paper.bib
@@ -35,6 +35,16 @@ @article{Brunton2016SINDy
doi = {10.1073/pnas.1517384113}
}
+@article{Pan2021SparseSubspace,
+ author = {Pan, Shaowu and Arnold-Medabalimi, Nicholas and Duraisamy, Karthik},
+ title = {Sparsity-promoting algorithms for the discovery of informative {Koopman}-invariant subspaces},
+ journal = {Journal of Fluid Mechanics},
+ volume = {917},
+ pages = {A18},
+ year = {2021},
+ doi = {10.1017/jfm.2021.271}
+}
+
@inproceedings{Fey/Lenssen/2019,
title = {Fast Graph Representation Learning with {PyTorch Geometric}},
author = {Fey, Matthias and Lenssen, Jan E.},
@@ -98,6 +108,17 @@ @misc{Liu2022PIKN
doi = {10.48550/arXiv.2211.09419}
}
+@misc{HaseliCortes2023,
+ author = {Haseli, Masih and Cort{\'e}s, Jorge},
+ title = {Invariance Proximity: Closed-Form Error Bounds for Finite-Dimensional {Koopman}-Based Models},
+ year = {2023},
+ eprint = {2311.13033},
+ archivePrefix = {arXiv},
+ primaryClass = {math.OC},
+ url = {https://arxiv.org/abs/2311.13033},
+ doi = {10.48550/arXiv.2311.13033}
+}
+
@article{Raissi2019PINN,
author = {Raissi, Maziar and Perdikaris, Paris and Karniadakis, George Em},
title = {Physics-informed neural networks: A deep learning framework for solving forward and inverse problems involving nonlinear partial differential equations},
@@ -190,9 +211,9 @@ @misc{koopmangraph2026
title = {{KoopmanGraph}: Topology-Aware {Koopman} Autoencoders for Networked Dynamics},
year = {2026},
publisher = {Zenodo},
- doi = {10.5281/zenodo.21763908},
- url = {https://doi.org/10.5281/zenodo.21763908},
- version = {0.11.0}
+ doi = {10.5281/zenodo.21926723},
+ url = {https://doi.org/10.5281/zenodo.21926723},
+ version = {0.15.0}
}
@inproceedings{Azencot2020,
@@ -240,6 +261,16 @@ @article{Korda2018
doi = {10.1016/j.automatica.2018.03.046}
}
+@article{Zhang2022TubeMPC,
+ author = {Zhang, Xinglong and Pan, Wei and Scattolini, Riccardo and Yu, Shuyou and Xu, Xin},
+ title = {Robust Tube-Based Model Predictive Control with {Koopman} Operators},
+ journal = {Automatica},
+ volume = {137},
+ pages = {110114},
+ year = {2022},
+ doi = {10.1016/j.automatica.2021.110114}
+}
+
@article{Proctor2016DMDc,
author = {Proctor, Joshua L. and Brunton, Steven L. and Kutz, J. Nathan},
title = {Dynamic Mode Decomposition with Control},
@@ -305,6 +336,18 @@ @article{Klus2018TransferOperator
doi = {10.1007/s00332-017-9437-7}
}
+@article{Klus2020gEDMD,
+ author = {Klus, Stefan and N{\"u}ske, Feliks and Peitz, Sebastian and
+ Niemann, Jan-Hendrik and Sch{\"u}tte, Christof},
+ title = {Data-driven approximation of the {Koopman} generator: Model
+ reduction, system identification, and control},
+ journal = {Physica D: Nonlinear Phenomena},
+ volume = {406},
+ pages = {132416},
+ year = {2020},
+ doi = {10.1016/j.physd.2020.132416}
+}
+
@inproceedings{Yu2018STGCN,
author = {Yu, Bing and Yin, Haoteng and Zhu, Zhanxing},
title = {Spatio-Temporal Graph Convolutional Networks: A Deep Learning Framework for Traffic Forecasting},
@@ -341,6 +384,16 @@ @article{Zhang2026PKGCN
note = {Coarse-to-fine super-resolution precedent; related but not identical to coarse-level hierarchical forecasting}
}
+@article{Peng2026KoopmanGKFA,
+ author = {Peng, Chuansen and Shen, Xiaojing and Zhu, Yunmin},
+ title = {{Koopman} Lifting with Certified Error Bounds for Joint Inference in Nonlinear Networks},
+ journal = {arXiv preprint arXiv:2606.17797},
+ year = {2026},
+ eprint = {2606.17797},
+ archivePrefix = {arXiv},
+ note = {Provisional 2026 preprint. Structural Homomorphism Lemma requires a separable node-wise dictionary. Cited for that precondition only; this library does not implement Koopman-GKFA ADMM or certified MSE bounds}
+}
+
@inproceedings{Vaswani2017Attention,
author = {Vaswani, Ashish and Shazeer, Noam and Parmar, Niki and Uszkoreit, Jakob and Jones, Llion and Gomez, Aidan N. and Kaiser, {\L}ukasz and Polosukhin, Illia},
title = {Attention Is All You Need},
@@ -663,6 +716,18 @@ @article{Colbrook2023ResidualDMD
doi = {10.1017/jfm.2022.1052}
}
+@article{Colbrook2023mpEDMD,
+ author = {Colbrook, Matthew J.},
+ title = {The {mpEDMD} Algorithm for Data-Driven Computations of
+ Measure-Preserving Dynamical Systems},
+ journal = {SIAM Journal on Numerical Analysis},
+ volume = {61},
+ number = {3},
+ pages = {1585--1608},
+ year = {2023},
+ doi = {10.1137/22M1521407}
+}
+
@article{Wu2020VAMP,
author = {Wu, Hao and No{\'e}, Frank},
title = {Variational Approach for Learning {Markov} Processes from Time Series Data},
@@ -675,6 +740,22 @@ @article{Wu2020VAMP
note = {First published online 2019-08-05; print volume dated 2020}
}
+@article{deeptime2021,
+ author = {Hoffmann, Moritz and Scherer, Martin and Hempel, Tim and
+ Mardt, Andreas and de Silva, Brian and Husic, Brooke E. and
+ Klus, Stefan and Wu, Hao and Kutz, J. Nathan and
+ Brunton, Steven L. and No{\'e}, Frank},
+ title = {Deeptime: a {Python} library for machine learning dynamical
+ models from time series data},
+ journal = {Machine Learning: Science and Technology},
+ volume = {3},
+ number = {1},
+ pages = {015009},
+ year = {2022},
+ doi = {10.1088/2632-2153/ac3de0},
+ note = {Published 10 December 2021; print volume dated 2022}
+}
+
@article{Mardt2018VAMPnets,
author = {Mardt, Andreas and Pasquali, Luca and Wu, Hao and No{\'e}, Frank},
title = {{VAMPnets} for deep learning of molecular kinetics},
@@ -921,6 +1002,26 @@ @article{LovaszSzegedy2006
doi = {10.1016/j.jctb.2006.05.001}
}
+@article{Macesic2018Nonautonomous,
+ author = {Maćešić, Senka and Črnjarić-Žic, Nelida and Mezić, Igor},
+ title = {Koopman Operator Family Spectrum for Nonautonomous Systems},
+ journal = {{SIAM} Journal on Applied Dynamical Systems},
+ volume = {17},
+ pages = {2478--2515},
+ year = {2018},
+ doi = {10.1137/17M1133610}
+}
+
+@article{Ruiz2023Transferability,
+ author = {Ruiz, Luana and Chamon, Luiz F. O. and Ribeiro, Alejandro},
+ title = {Transferability Properties of Graph Neural Networks},
+ journal = {{IEEE} Transactions on Signal Processing},
+ volume = {71},
+ pages = {3474--3489},
+ year = {2023},
+ doi = {10.1109/TSP.2023.3297848}
+}
+
@inproceedings{Cuturi2013Sinkhorn,
author = {Cuturi, Marco},
title = {Sinkhorn Distances: Lightspeed Computation of Optimal Transport},
@@ -941,3 +1042,249 @@ @inproceedings{Greydanus2019HNN
url = {https://papers.nips.cc/paper/2019/hash/26cd8ecadce0d4efd6cc8a8725cbd1f8-Abstract.html}
}
+@book{Wilkinson1965,
+ author = {Wilkinson, J. H.},
+ title = {The Algebraic Eigenvalue Problem},
+ publisher = {Clarendon Press},
+ address = {Oxford},
+ year = {1965},
+ isbn = {978-0-19-853418-1}
+}
+
+@book{TrefethenEmbree2005,
+ author = {Trefethen, Lloyd N. and Embree, Mark},
+ title = {Spectra and Pseudospectra: The Behavior of Nonnormal Matrices
+ and Operators},
+ publisher = {Princeton University Press},
+ address = {Princeton, NJ},
+ year = {2005},
+ doi = {10.1515/9780691213101},
+ isbn = {978-0-691-11946-5}
+}
+
+@inproceedings{Zeng2022Sampling,
+ author = {Zeng, Zhexuan and Yue, Zuogong and Mauroy, Alexandre
+ and Gon{\c{c}}alves, Jorge and Yuan, Ye},
+ title = {A Sampling Theorem for Exact Identification of
+ Continuous-time Nonlinear Dynamical Systems},
+ booktitle = {2022 {IEEE} 61st Conference on Decision and Control ({CDC})},
+ pages = {6686--6692},
+ year = {2022},
+ publisher = {{IEEE}},
+ doi = {10.1109/CDC51059.2022.9992482},
+ url = {https://doi.org/10.1109/CDC51059.2022.9992482}
+}
+
+@article{AlMohyHigham2012,
+ author = {Al-Mohy, Awad H. and Higham, Nicholas J.},
+ title = {Improved Inverse Scaling and Squaring Algorithms for the
+ Matrix Logarithm},
+ journal = {SIAM Journal on Scientific Computing},
+ volume = {34},
+ number = {4},
+ pages = {C153--C169},
+ year = {2012},
+ doi = {10.1137/110852553},
+ url = {https://doi.org/10.1137/110852553}
+}
+
+@article{Dawson2016tlsDMD,
+ author = {Dawson, Scott T. M. and Hemati, Maziar S. and Williams, Matthew O. and Rowley, Clarence W.},
+ title = {Characterizing and Correcting for the Effect of Sensor Noise in the Dynamic Mode Decomposition},
+ journal = {Experiments in Fluids},
+ volume = {57},
+ number = {3},
+ pages = {42},
+ year = {2016},
+ doi = {10.1007/s00348-016-2127-7}
+}
+
+@article{Hemati2017tlsDMD,
+ author = {Hemati, Maziar S. and Rowley, Clarence W. and Deem, Eric A. and Cattafesta, Louis N.},
+ title = {De-biasing the Dynamic Mode Decomposition for Applied {Koopman} Spectral Analysis of Noisy Datasets},
+ journal = {Theoretical and Computational Fluid Dynamics},
+ volume = {31},
+ number = {4},
+ pages = {349--368},
+ year = {2017},
+ doi = {10.1007/s00162-017-0432-2}
+}
+
+@InProceedings{Xu2025ResKoopNet,
+ author = {Xu, Yuanchao and Shao, Kaidi and Logothetis, Nikos K. and Shen, Zhongwei},
+ title = {{ResKoopNet}: Learning {Koopman} Representations for Complex Dynamics with Spectral Residuals},
+ booktitle = {Proceedings of the 42nd International Conference on Machine Learning},
+ pages = {69647--69674},
+ year = {2025},
+ volume = {267},
+ series = {Proceedings of Machine Learning Research},
+ publisher = {PMLR},
+ url = {https://proceedings.mlr.press/v267/xu25y.html}
+}
+
+@article{Xu2025StochasticSemigroup,
+ author = {Xu, Yuanchao and Shao, Kaidi and Ishikawa, Isao and
+ Hashimoto, Yuka and Logothetis, Nikos and Shen, Zhongwei},
+ title = {A data-driven framework for {Koopman} semigroup estimation
+ in stochastic dynamical systems},
+ journal = {Chaos: An Interdisciplinary Journal of Nonlinear Science},
+ volume = {35},
+ number = {10},
+ pages = {103123},
+ year = {2025},
+ doi = {10.1063/5.0283640}
+}
+
+@article{Zhou2025Yosida,
+ author = {Zhou, Jun and Meng, Yiming and Liu, Jun},
+ title = {{Koopman} Spectral Analysis and System Identification
+ for Stochastic Dynamical Systems via {Yosida}
+ Approximation of Generators},
+ journal = {arXiv preprint arXiv:2504.13912},
+ year = {2025},
+ eprint = {2504.13912},
+ archivePrefix = {arXiv},
+ primaryClass = {eess.SY}
+}
+
+@article{Lim2020Hodge,
+ author = {Lim, Lek-Heng},
+ title = {Hodge {L}aplacians on Graphs},
+ journal = {SIAM Review},
+ volume = {62},
+ number = {3},
+ pages = {685--715},
+ year = {2020},
+ doi = {10.1137/18M1223101}
+}
+
+@article{TopoX2024,
+ author = {Hajij, Mustafa and Papillon, Mathilde and
+ Frantzen, Florian and others},
+ title = {{TopoX}: A Suite of {Python} Packages for Machine
+ Learning on Topological Domains},
+ journal = {arXiv preprint arXiv:2402.02441},
+ year = {2024},
+ eprint = {2402.02441},
+ archivePrefix = {arXiv},
+ primaryClass = {cs.LG}
+}
+
+@article{Ljung1978Box,
+ author = {Ljung, G. M. and Box, G. E. P.},
+ title = {On a measure of lack of fit in time series models},
+ journal = {Biometrika},
+ volume = {65},
+ number = {2},
+ pages = {297--303},
+ year = {1978},
+ doi = {10.1093/biomet/65.2.297}
+}
+
+@article{Lin2021MoriZwanzig,
+ author = {Lin, Yen Ting and Tian, Yifeng and Anghel, Marian and
+ Livescu, Daniel},
+ title = {Data-Driven Learning for the {Mori}--{Zwanzig} Formalism:
+ A Generalization of the {Koopman} Learning Framework},
+ journal = {SIAM Journal on Applied Dynamical Systems},
+ volume = {20},
+ number = {4},
+ pages = {2558--2601},
+ year = {2021},
+ doi = {10.1137/21M1401759}
+}
+
+@article{Schlembach2025Conformal,
+ author = {Schlembach, Filip and Smirnov, Evgueni N. and
+ Koprinska, Irena and Winands, Mark H. M.},
+ title = {Conformal multistep-ahead multivariate time-series
+ forecasting},
+ journal = {Machine Learning},
+ volume = {114},
+ number = {7},
+ pages = {165},
+ year = {2025},
+ doi = {10.1007/s10994-024-06722-9}
+}
+
+@article{Guo2025ModularEDMD,
+ author = {Guo, Yang and Schaller, Manuel and Worthmann, Karl and
+ Streif, Stefan},
+ title = {Modularized data-driven approximation of the {Koopman}
+ operator and generator},
+ journal = {Physica D: Nonlinear Phenomena},
+ volume = {476},
+ pages = {134651},
+ year = {2025},
+ doi = {10.1016/j.physd.2025.134651}
+}
+
+@inproceedings{LibCity2021,
+ author = {Wang, Jingyuan and Jiang, Jiawei and Jiang, Wenjun and
+ Li, Chao and Zhao, Wayne Xin},
+ title = {{LibCity}: An Open Library for Traffic Prediction},
+ booktitle = {Proceedings of the 29th International Conference on
+ Advances in Geographic Information Systems},
+ series = {SIGSPATIAL '21},
+ pages = {145--148},
+ year = {2021},
+ publisher = {Association for Computing Machinery},
+ doi = {10.1145/3474717.3483923}
+}
+
+@article{BasicTS2024,
+ author = {Shao, Zezhi and Wang, Fei and Xu, Yongjun and Wei, Wei and
+ Yu, Chengqing and Zhang, Zhao and Yao, Di and Sun, Tao and
+ Jin, Guangyin and Cao, Xin and Cong, Gao and
+ Jensen, Christian S. and Cheng, Xueqi},
+ title = {Exploring Progress in Multivariate Time Series Forecasting:
+ Comprehensive Benchmarking and Heterogeneity Analysis},
+ journal = {IEEE Transactions on Knowledge and Data Engineering},
+ volume = {37},
+ number = {1},
+ pages = {291--305},
+ year = {2025},
+ doi = {10.1109/TKDE.2024.3484454}
+}
+
+@article{kooplearn2026,
+ author = {Turri, Giacomo and Pacreau, Gr{\'e}goire and Meanti, Giacomo
+ and Devergne, Timoth{\'e}e and Ordo{\~n}ez-Apraez, Daniel
+ and Mirzaei, Erfan and Belucci, Bruno and Lounici, Karim
+ and Kostic, Vladimir R. and Pontil, Massimiliano and
+ Novelli, Pietro},
+ title = {kooplearn: A scikit-learn compatible library of algorithms
+ for evolution operator learning},
+ journal = {Journal of Open Source Software},
+ volume = {11},
+ number = {122},
+ pages = {10342},
+ year = {2026},
+ doi = {10.21105/joss.10342}
+}
+
+@article{PyKoopman2024,
+ author = {Pan, Shaowu and Kaiser, Eurika and de Silva, Brian M. and
+ Kutz, J. Nathan and Brunton, Steven L.},
+ title = {{PyKoopman}: A {Python} Package for Data-Driven
+ Approximation of the {Koopman} Operator},
+ journal = {Journal of Open Source Software},
+ volume = {9},
+ number = {94},
+ pages = {5881},
+ year = {2024},
+ doi = {10.21105/joss.05881},
+ note = {Same work as Pan2024}
+}
+
+@article{PyDMD2018,
+ author = {Demo, Nicola and Tezzele, Marco and Rozza, Gianluigi},
+ title = {{PyDMD}: {Python} Dynamic Mode Decomposition},
+ journal = {Journal of Open Source Software},
+ volume = {3},
+ number = {22},
+ pages = {530},
+ year = {2018},
+ doi = {10.21105/joss.00530}
+}
+
diff --git a/pyproject.toml b/pyproject.toml
index 042d65c..3b365b3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -103,7 +103,7 @@ baselines-ode = [
# Reserved for future mesh/weather helper deps. GraphCastBaseline teaching
# path is pure PyTorch (no JAX / DeepMind graphcast / eccodes); extra is a no-op.
baselines-graphcast = []
-# Optional e3nn for Tier-B steerable SE(3) encode (not equivariant K).
+# Optional e3nn for Tier-B steerable encode and equivariant-K rotation tests.
equivariance = [
"e3nn>=0.5.1",
]
@@ -123,6 +123,9 @@ control-lmi = [
"cvxpy>=1.4.0",
]
probabilistic = []
+# Protocol-locked experiment manifests (schema/load). YAML still needs PyYAML
+# via ``[cli]``; JSON loads without extras. Runner CLI is a later increment.
+benchmark = []
[project.scripts]
koopman-graph = "koopman_graph.cli:main"
@@ -154,6 +157,7 @@ markers = [
"ray: starts a local Ray / Ray Train runtime (exclude on macOS smoke)",
"slow: long-running CPU tests reserved for full Ubuntu matrix (exclude on macOS smoke)",
"multinode: opt-in multi-node Ray/torchrun smoke (KOOPMAN_GRAPH_MULTINODE=1)",
+ "benchmark_smoke: identity-bound manifests under benchmarks/v0.15/",
]
[tool.coverage.run]
diff --git a/src/koopman_graph/__init__.py b/src/koopman_graph/__init__.py
index e6acea0..f301826 100644
--- a/src/koopman_graph/__init__.py
+++ b/src/koopman_graph/__init__.py
@@ -78,13 +78,17 @@
:mod:`koopman_graph.protocols`, :mod:`koopman_graph.spectrum_types`,
:mod:`koopman_graph.uq` (deep ensembles / optional ``predict_interval``),
:mod:`koopman_graph.hierarchical` (TopK / SAG pool → coarse Koopman → unpool),
-:mod:`koopman_graph.mpc` (receding-horizon Koopman-MPC; ``[mpc]`` extra),
+:mod:`koopman_graph.mpc` (receding-horizon Koopman-MPC and residual-tube
+tightening; ``[mpc]`` extra),
:mod:`koopman_graph.tracking` (CSV / TensorBoard fit adapters),
:mod:`koopman_graph.cli` (``koopman-graph`` console script), and
:mod:`koopman_graph.tuning` (HPO metric helpers; not AutoML)
are importable but intentionally omitted from ``__all__`` (encoder/decoder/delay
and operator *classes*, including ``DelayEmbeddingEncoder`` and
-``GraphKoopmanOperator``, remain root-stable). See the architecture docs for
+``GraphKoopmanOperator``, remain root-stable). Frozen identification
+config / report types and closed-form solvers live in
+:mod:`koopman_graph.identification` (opt-in ``fit(..., identification=)``;
+not on root ``__all__``). See the architecture docs for
the public vs power-user contract.
"""
@@ -165,4 +169,4 @@
"__version__",
"compute_spectrum",
]
-__version__ = "0.14.0"
+__version__ = "0.15.0"
diff --git a/src/koopman_graph/adaptation/__init__.py b/src/koopman_graph/adaptation/__init__.py
index 963e092..082d2e7 100644
--- a/src/koopman_graph/adaptation/__init__.py
+++ b/src/koopman_graph/adaptation/__init__.py
@@ -14,13 +14,26 @@
``observer``
:class:`~koopman_graph.adaptation.KoopmanObserver` façade for Kalman
filtering / smoothing / imputation under ``observation_masks``.
+``joint_observer``
+ :class:`~koopman_graph.adaptation.JointStateTopologyObserver`
+ composing the Kalman observer with group-sparse graph factors or
+ dense RLS. Homomorphism claims require a separable dictionary
+ encoder; default GNN encoders are not separable. Importing this
+ peer loads :mod:`koopman_graph.identification`; RLS and
+ :class:`~koopman_graph.adaptation.KoopmanObserver` stay
+ identification-free at package import.
RLS and observer types (``RecursiveKoopmanAdapter``, ``AdaptationStepResult``,
-``KoopmanObserver``, ``FilterResult``, and related helpers) are available from
+``KoopmanObserver``, ``JointStateTopologyObserver``, ``FilterResult``, and
+related helpers) are available from
this package and are intentionally omitted from root ``koopman_graph.__all__``
(thin façade; capability-module imports only).
"""
+from __future__ import annotations
+
+from typing import Any
+
from koopman_graph.adaptation.kalman import FilterResult
from koopman_graph.adaptation.observer import (
KoopmanObserver,
@@ -36,7 +49,51 @@
"AdaptationMode",
"AdaptationStepResult",
"FilterResult",
+ "JointObserverResult",
+ "JointStateTopologyObserver",
"KoopmanObserver",
"ObservationModel",
"RecursiveKoopmanAdapter",
]
+
+
+def __getattr__(name: str) -> Any:
+ """Lazy-load the joint observer without importing identification.
+
+ Parameters
+ ----------
+ name : str
+ Attribute requested on :mod:`koopman_graph.adaptation`.
+
+ Returns
+ -------
+ object
+ Public joint-observer symbol.
+
+ Raises
+ ------
+ AttributeError
+ If ``name`` is not a known lazy export.
+ """
+ if name in {"JointObserverResult", "JointStateTopologyObserver"}:
+ from koopman_graph.adaptation.joint_observer import (
+ JointObserverResult,
+ JointStateTopologyObserver,
+ )
+
+ if name == "JointObserverResult":
+ return JointObserverResult
+ return JointStateTopologyObserver
+ msg = f"module {__name__!r} has no attribute {name!r}"
+ raise AttributeError(msg)
+
+
+def __dir__() -> list[str]:
+ """Return sorted public names including lazy joint-observer exports.
+
+ Returns
+ -------
+ list of str
+ Names in :data:`__all__`.
+ """
+ return sorted(__all__)
diff --git a/src/koopman_graph/adaptation/joint_observer.py b/src/koopman_graph/adaptation/joint_observer.py
new file mode 100644
index 0000000..b5d4857
--- /dev/null
+++ b/src/koopman_graph/adaptation/joint_observer.py
@@ -0,0 +1,356 @@
+"""Joint latent-state and graph-factor observer (separable dictionary).
+
+Composes :class:`~koopman_graph.adaptation.KoopmanObserver` with either
+group-sparse :math:`K_{\\mathrm{self}}` / :math:`K_{\\mathrm{nbr}}`
+identification (graph operators) or
+:class:`~koopman_graph.adaptation.RecursiveKoopmanAdapter` (dense
+per-node :math:`K`). This is **not** Koopman-GKFA: it does not run
+ADMM on :math:`A`, does not certify a three-term MSE bound, and does
+not claim the Structural Homomorphism Lemma unless
+``claim_homomorphism=True`` **and** the encoder is a separable
+node-wise dictionary (Peng, Shen & Zhu, arXiv:2606.17797,
+``Peng2026KoopmanGKFA``). Default GNN encoders mix neighbors and are
+not separable.
+
+References
+----------
+Peng, C., Shen, X. & Zhu, Y. (2026). Koopman lifting with certified
+error bounds for joint inference in nonlinear networks. *arXiv*
+2606.17797. (``Peng2026KoopmanGKFA``). Provisional preprint. Cited
+for the separable-dictionary homomorphism precondition only.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import torch
+from torch import Tensor
+
+from koopman_graph.adaptation.kalman import FilterResult
+from koopman_graph.adaptation.observer import KoopmanObserver, ObservationModel
+from koopman_graph.adaptation.rls import AdaptationStepResult, RecursiveKoopmanAdapter
+from koopman_graph.data import GraphSnapshotSequence, resolve_pair_delta_t
+from koopman_graph.graph_utils import snapshot_edge_weight
+from koopman_graph.identification.sparse_factors import (
+ SparseFactorGroup,
+ SparseFactorMethod,
+ SparseFactorReport,
+ identify_sparse_graph_factors,
+)
+from koopman_graph.nn.separable import is_separable_dictionary
+from koopman_graph.operators import (
+ ContinuousKoopmanOperator,
+ GraphKoopmanOperator,
+ KoopmanOperator,
+)
+from koopman_graph.protocols import ModeShapeModel
+
+__all__ = [
+ "JointObserverResult",
+ "JointStateTopologyObserver",
+]
+
+
+@dataclass(frozen=True)
+class JointObserverResult:
+ """One window of joint filtering and operator write-back.
+
+ Attributes
+ ----------
+ filter : FilterResult
+ Kalman filtered latents ``(T, N, d)``.
+ sparse_factors : SparseFactorReport or None
+ Graph-factor identification when the live operator is
+ :class:`~koopman_graph.operators.GraphKoopmanOperator`.
+ rls_steps : tuple of AdaptationStepResult
+ Per-interval RLS updates on dense per-node :math:`K` (empty on
+ the graph path).
+ """
+
+ filter: FilterResult
+ sparse_factors: SparseFactorReport | None
+ rls_steps: tuple[AdaptationStepResult, ...]
+
+
+def _require_separable_graph_operator(
+ model: ModeShapeModel,
+ *,
+ claim_homomorphism: bool,
+) -> None:
+ """Refuse theorem-tagged use without a separable graph lift.
+
+ Parameters
+ ----------
+ model : ModeShapeModel
+ Model exposing ``encoder`` and ``koopman``.
+ claim_homomorphism : bool
+ When ``True``, require a separable encoder and a one-tap graph
+ operator.
+
+ Raises
+ ------
+ ValueError
+ If the homomorphism flag is set without the precondition.
+ """
+ if not claim_homomorphism:
+ return
+ encoder = getattr(model, "encoder", None)
+ if not is_separable_dictionary(encoder):
+ msg = (
+ "claim_homomorphism=True requires encoder_kind='separable' "
+ "(node-wise dictionary). Default GNN encoders mix neighbors "
+ "and the Structural Homomorphism Lemma does not apply "
+ "(Peng2026KoopmanGKFA)."
+ )
+ raise ValueError(msg)
+ if not isinstance(model.koopman, GraphKoopmanOperator):
+ msg = (
+ "claim_homomorphism=True requires GraphKoopmanOperator "
+ "(block sparsity of coupling vs graph edges). Dense per-node "
+ "K has no graph topology to identify."
+ )
+ raise ValueError(msg)
+
+
+def _write_graph_factors(
+ koopman: GraphKoopmanOperator,
+ k_self: Tensor,
+ k_nbr: Tensor,
+) -> None:
+ """Copy identified dense factors onto the live graph operator.
+
+ Parameters
+ ----------
+ koopman : GraphKoopmanOperator
+ Dense one-tap graph operator.
+ k_self, k_nbr : Tensor
+ Factors with shape ``(d, d)``.
+
+ Raises
+ ------
+ ValueError
+ If the operator is not densely parameterized.
+ """
+ if koopman.parameterization != "dense":
+ msg = (
+ "joint observer write-back requires parameterization='dense', "
+ f"got {koopman.parameterization!r}"
+ )
+ raise ValueError(msg)
+ target_self = koopman.K_self
+ target_nbr = koopman.K_nbr
+ with torch.no_grad():
+ target_self.copy_(k_self.to(device=target_self.device, dtype=target_self.dtype))
+ target_nbr.copy_(k_nbr.to(device=target_nbr.device, dtype=target_nbr.dtype))
+
+
+class JointStateTopologyObserver:
+ """Kalman observer plus group-sparse graph factors or dense RLS.
+
+ Graph path
+ :meth:`KoopmanObserver.filter` then
+ :func:`~koopman_graph.identification.identify_sparse_graph_factors`
+ on consecutive filtered latents, writing :math:`K_{\\mathrm{self}}`
+ / :math:`K_{\\mathrm{nbr}}`. Does **not** infer a new edge set.
+
+ Dense per-node path
+ Filter then :class:`RecursiveKoopmanAdapter` on latent pairs.
+ Homomorphism claims are refused (no graph :math:`A`).
+
+ Parameters
+ ----------
+ model : ModeShapeModel
+ Fitted or seeded model with encode / decode / Koopman step.
+ claim_homomorphism : bool, optional
+ Theorem tag. Default ``False``. When ``True``, raise unless the
+ encoder is separable **and** the operator is a one-tap graph
+ Koopman operator.
+ process_noise, observation_noise, observation_model
+ Forwarded to :class:`KoopmanObserver`.
+ sparse_group, sparse_method, sparse_threshold
+ Forwarded to :func:`identify_sparse_graph_factors` on the graph
+ path. ``sparse_threshold`` is dimensionless.
+ rls_forgetting_factor, rls_regularization
+ Forwarded to :class:`RecursiveKoopmanAdapter` on the dense path.
+ ``rls_forgetting_factor`` is dimensionless in ``(0, 1]``.
+ ``rls_regularization`` is the initial covariance scale on the
+ regressor covariance (same units as RLS ``P``).
+ """
+
+ def __init__(
+ self,
+ model: ModeShapeModel,
+ *,
+ claim_homomorphism: bool = False,
+ process_noise: float = 1e-3,
+ observation_noise: float = 1e-2,
+ observation_model: ObservationModel = "latent_encode",
+ sparse_group: SparseFactorGroup = "self_nbr",
+ sparse_method: SparseFactorMethod = "group_lasso",
+ sparse_threshold: float = 0.0,
+ rls_forgetting_factor: float = 0.99,
+ rls_regularization: float = 1e3,
+ ) -> None:
+ """Initialize observer, optional RLS adapter, and theorem flag.
+
+ Parameters
+ ----------
+ model : ModeShapeModel
+ Encode / decode / Koopman façade.
+ claim_homomorphism : bool, optional
+ Require separable graph lift. Default ``False``.
+ process_noise : float, optional
+ Observer process-noise scale. Default ``1e-3``.
+ observation_noise : float, optional
+ Observer observation-noise scale. Default ``1e-2``.
+ observation_model : {"latent_encode", "decoder_jacobian"}, optional
+ Observer measurement linearization. Default ``"latent_encode"``.
+ sparse_group : {"none", "self_nbr", "orbit"}, optional
+ Factor grouping. Default ``"self_nbr"``.
+ sparse_method : {"stlsq", "group_lasso"}, optional
+ Sparse identification method. Default ``"group_lasso"``.
+ sparse_threshold : float, optional
+ Dimensionless cutoff / group-lasso ``λ``. Default ``0.0``.
+ rls_forgetting_factor : float, optional
+ Dense-path RLS ``λ``. Default ``0.99``.
+ rls_regularization : float, optional
+ Dense-path initial ``P`` scale. Default ``1e3``.
+
+ Raises
+ ------
+ ValueError
+ If ``claim_homomorphism=True`` without a separable graph
+ operator, or the graph operator is not a one-tap dense
+ symmetric / random-walk factorization.
+ TypeError
+ If the Koopman module is unsupported.
+ """
+ _require_separable_graph_operator(
+ model,
+ claim_homomorphism=claim_homomorphism,
+ )
+ koopman = model.koopman
+ if isinstance(koopman, GraphKoopmanOperator):
+ if int(koopman.filter_degree) != 1:
+ msg = (
+ "JointStateTopologyObserver graph path requires "
+ f"filter_degree=1, got {koopman.filter_degree}"
+ )
+ raise ValueError(msg)
+ if koopman.adjacency == "dual_random_walk":
+ msg = (
+ "JointStateTopologyObserver does not support "
+ "adjacency='dual_random_walk'"
+ )
+ raise ValueError(msg)
+ if koopman.parameterization != "dense":
+ msg = (
+ "JointStateTopologyObserver graph path requires "
+ "parameterization='dense'"
+ )
+ raise ValueError(msg)
+ self._rls: RecursiveKoopmanAdapter | None = None
+ self._rls_mode: str | None = None
+ elif isinstance(koopman, ContinuousKoopmanOperator):
+ self._rls = RecursiveKoopmanAdapter.from_operator(
+ koopman,
+ mode="continuous",
+ forgetting_factor=rls_forgetting_factor,
+ regularization=rls_regularization,
+ )
+ self._rls_mode = "continuous"
+ elif isinstance(koopman, KoopmanOperator):
+ self._rls = RecursiveKoopmanAdapter.from_operator(
+ koopman,
+ mode="discrete",
+ forgetting_factor=rls_forgetting_factor,
+ regularization=rls_regularization,
+ )
+ self._rls_mode = "discrete"
+ else:
+ msg = (
+ "JointStateTopologyObserver supports GraphKoopmanOperator, "
+ "KoopmanOperator, or ContinuousKoopmanOperator, got "
+ f"{type(koopman).__name__}"
+ )
+ raise TypeError(msg)
+
+ self.model = model
+ self.claim_homomorphism = bool(claim_homomorphism)
+ self.sparse_group: SparseFactorGroup = sparse_group
+ self.sparse_method: SparseFactorMethod = sparse_method
+ self.sparse_threshold = float(sparse_threshold)
+ self.observer = KoopmanObserver(
+ model,
+ process_noise=process_noise,
+ observation_noise=observation_noise,
+ observation_model=observation_model,
+ )
+
+ def filter_and_adapt(self, sequence: GraphSnapshotSequence) -> JointObserverResult:
+ """Filter latents, then update graph factors or dense :math:`K`.
+
+ Parameters
+ ----------
+ sequence : GraphSnapshotSequence
+ Homogeneous trajectory with ``T >= 2``.
+
+ Returns
+ -------
+ JointObserverResult
+ Filtered latents and the operator write-back payload.
+
+ Raises
+ ------
+ ValueError
+ If ``T < 2``.
+ """
+ if len(sequence) < 2:
+ msg = f"filter_and_adapt requires T >= 2 snapshots, got T={len(sequence)}"
+ raise ValueError(msg)
+ filtered = self.observer.filter(sequence)
+ koopman = self.model.koopman
+ if isinstance(koopman, GraphKoopmanOperator):
+ last = sequence[-1]
+ report = identify_sparse_graph_factors(
+ filtered.latents,
+ last.edge_index,
+ group=self.sparse_group,
+ method=self.sparse_method,
+ threshold=self.sparse_threshold,
+ edge_weight=snapshot_edge_weight(last),
+ adjacency=(
+ "random_walk" if koopman.adjacency == "random_walk" else "symmetric"
+ ),
+ )
+ _write_graph_factors(koopman, report.K_self, report.K_nbr)
+ return JointObserverResult(
+ filter=filtered,
+ sparse_factors=report,
+ rls_steps=(),
+ )
+ assert self._rls is not None
+ steps: list[AdaptationStepResult] = []
+ latents = filtered.latents
+ for timestep in range(int(latents.shape[0]) - 1):
+ if self._rls_mode == "continuous":
+ steps.append(
+ self._rls.update(
+ latents[timestep],
+ latents[timestep + 1],
+ delta_t=resolve_pair_delta_t(
+ sequence,
+ timestep,
+ default_time_step=float(self.model.time_step),
+ ),
+ )
+ )
+ else:
+ steps.append(self._rls.update(latents[timestep], latents[timestep + 1]))
+ self._rls.apply_to(koopman)
+ return JointObserverResult(
+ filter=filtered,
+ sparse_factors=None,
+ rls_steps=tuple(steps),
+ )
diff --git a/src/koopman_graph/adaptation/observer.py b/src/koopman_graph/adaptation/observer.py
index eb84cbb..6565758 100644
--- a/src/koopman_graph/adaptation/observer.py
+++ b/src/koopman_graph/adaptation/observer.py
@@ -262,6 +262,11 @@ def impute(
timestamps=(
None if sequence.timestamps is None else sequence.timestamps.clone()
),
+ parameter_trajectory=(
+ None
+ if sequence.parameter_trajectory is None
+ else sequence.parameter_trajectory.clone()
+ ),
observation_masks=(
None
if sequence.observation_masks is None
diff --git a/src/koopman_graph/analysis/__init__.py b/src/koopman_graph/analysis/__init__.py
index 348dc54..b591ed2 100644
--- a/src/koopman_graph/analysis/__init__.py
+++ b/src/koopman_graph/analysis/__init__.py
@@ -12,6 +12,36 @@
:func:`~koopman_graph.spectrum_types.compute_spectrum`,
:func:`~koopman_graph.spectrum_types.compute_generator_spectrum`, and
:func:`~koopman_graph.spectrum_types.discrete_spectrum_at_delta_t`.
+``conditioning``
+ Re-export of :class:`~koopman_graph.spectrum_types.SpectralDiagnostics`
+ and :func:`~koopman_graph.spectrum_types.compute_spectral_diagnostics`.
+ Compute stays on the neutral leaf. Discrete Nyquist frequency is
+ :math:`1/(2\\Delta t)` in cycles per unit time (``None`` on
+ generator spectra). ``mode_amplitudes`` warns when :math:`\\kappa(V)`
+ exceeds ``CONDITION_WARN`` and raises
+ :class:`~koopman_graph.spectrum_types.DefectiveSpectrumError` when
+ :math:`V` is singular. Not a finite-horizon :math:`\\|K^k\\|` bound;
+ resolvent grids remain opt-in.
+``criticality``
+ Sliding-window spectral-gap / near-defectivity monitor
+ (:func:`~koopman_graph.analysis.monitor_critical_transition`,
+ :class:`~koopman_graph.analysis.CriticalityReport`). Heuristic
+ diagnostic on a sequence of spectra — **not** a Ghosh-grade
+ topology-criticality certificate.
+``memory``
+ Innovation-whiteness report
+ (:func:`~koopman_graph.analysis.markov_closure_report`,
+ :class:`~koopman_graph.analysis.MarkovClosureReport`) and a
+ convolution MVP
+ (:class:`~koopman_graph.analysis.FiniteMemoryKoopman`).
+ Diagnostic / parameterization only — **not** Mori–Zwanzig
+ identification, **not** delay embedding, and **not** HAVOK.
+``hodge_modes``
+ Combinatorial Hodge split of stored eigenvector columns
+ (:func:`~koopman_graph.analysis.hodge_decompose_modes`,
+ :class:`~koopman_graph.analysis.HodgeModeComponents`).
+ Analysis-only — **not** physical circulation, **not**
+ ``koopman="hodge"``, and **not** TopologicX / sheaf parity.
``similarity``
Spectral distances, KoopSTD,
:func:`~koopman_graph.analysis.resolve_spectrum`, and
@@ -95,7 +125,12 @@
``causal``
Granger-style influence on mode amplitudes
(:func:`~koopman_graph.analysis.granger_latent_influence`).
- Assumption-laden; not interventional.
+ Assumption-laden; **non-interventional**.
+``causal_intervention``
+ Labeled synthetic SCM fixture and do-intervention recovery
+ (:func:`~koopman_graph.analysis.teaching_three_node_scm`,
+ :func:`~koopman_graph.analysis.recover_synthetic_interventional_edges`).
+ Synthetic only — not Granger and not field-data discovery.
``lmi``
Small-system discrete Lyapunov SDP helper
(:func:`~koopman_graph.analysis.discrete_lyapunov_lmi`). Optional
@@ -118,15 +153,37 @@
CausalInfluenceReport,
granger_latent_influence,
)
+from koopman_graph.analysis.causal_intervention import (
+ SyntheticInterventionReport,
+ SyntheticSCM,
+ recover_synthetic_interventional_edges,
+ sample_synthetic_intervention,
+ sample_synthetic_observational,
+ teaching_three_node_scm,
+)
from koopman_graph.analysis.clustering import (
ClusteringResult,
koopman_spectral_clustering,
)
+from koopman_graph.analysis.conditioning import (
+ CONDITION_WARN,
+ DefectiveSpectrumError,
+ SpectralDiagnostics,
+ compute_spectral_diagnostics,
+)
+from koopman_graph.analysis.criticality import (
+ CriticalityReport,
+ monitor_critical_transition,
+)
from koopman_graph.analysis.dispersion import DispersionRelation, graph_dispersion
from koopman_graph.analysis.explain import (
RepresentationExplanation,
explain_representation,
)
+from koopman_graph.analysis.hodge_modes import (
+ HodgeModeComponents,
+ hodge_decompose_modes,
+)
from koopman_graph.analysis.joint_stability import (
MAX_JOINT_LYAPUNOV_SIZE,
MAX_JOINT_SCHUR_SIZE,
@@ -137,6 +194,11 @@
schur_radius_bound,
)
from koopman_graph.analysis.lmi import LyapunovLMIResult, discrete_lyapunov_lmi
+from koopman_graph.analysis.memory import (
+ FiniteMemoryKoopman,
+ MarkovClosureReport,
+ markov_closure_report,
+)
from koopman_graph.analysis.plotting import (
SpectrumLimits,
plot_dispersion,
@@ -193,13 +255,19 @@
"AnomalyThresholdMethod",
"CausalInfluenceReport",
"ClusteringResult",
+ "CONDITION_WARN",
"CouplingEstimate",
+ "CriticalityReport",
+ "DefectiveSpectrumError",
"DispersionRelation",
"EmpiricalSpectralMeasure",
+ "FiniteMemoryKoopman",
+ "HodgeModeComponents",
"ImpliedTimescales",
"JointStabilityCertificate",
"KoopmanSpectrum",
"LyapunovLMIResult",
+ "MarkovClosureReport",
"MAX_JOINT_LYAPUNOV_SIZE",
"MAX_JOINT_SCHUR_SIZE",
"ModeEnergyAttribution",
@@ -208,10 +276,13 @@
"RepresentationExplanation",
"ResolventNormGrid",
"SINDyReport",
+ "SpectralDiagnostics",
"SpectralResidualReport",
"SpectrumDistanceMethod",
"SpectrumLimits",
"SpectrumSource",
+ "SyntheticInterventionReport",
+ "SyntheticSCM",
"TRANSFER_ADVANTAGE_EPSILON",
"TopologyTransferReport",
"attribute_mode_energy",
@@ -219,6 +290,7 @@
"build_joint_stability_certificate",
"calibrate_anomaly_threshold",
"compute_generator_spectrum",
+ "compute_spectral_diagnostics",
"compute_spectrum",
"decode_mode_shapes",
"detect_anomaly",
@@ -232,18 +304,25 @@
"gershgorin_radius_bound",
"graph_dispersion",
"granger_latent_influence",
+ "hodge_decompose_modes",
"identify_sparse_dynamics",
"implied_timescales",
"koopman_spectral_clustering",
"koopman_std",
"lyapunov_joint_bound",
+ "markov_closure_report",
+ "monitor_critical_transition",
"persistence_diagram_0d",
"plot_dispersion",
"plot_spectrum",
+ "recover_synthetic_interventional_edges",
"resdmd",
"resolvent_norm_grid",
"resolve_spectrum",
+ "sample_synthetic_intervention",
+ "sample_synthetic_observational",
"schur_radius_bound",
"spectral_residuals",
"spectrum_distance",
+ "teaching_three_node_scm",
]
diff --git a/src/koopman_graph/analysis/causal.py b/src/koopman_graph/analysis/causal.py
index 7105645..cc3a8df 100644
--- a/src/koopman_graph/analysis/causal.py
+++ b/src/koopman_graph/analysis/causal.py
@@ -1,7 +1,10 @@
"""Pairwise Granger-style influence scores on latent time series.
-Assumption-laden: linear residual-MSE reduction, not interventional causal
-discovery and not :func:`~koopman_graph.analysis.explain_representation`.
+This helper is **non-interventional**. It reports linear residual-MSE
+reduction on observed latents — not do-operator causal discovery and
+not :func:`~koopman_graph.analysis.explain_representation`. A labeled
+synthetic SCM protocol lives in
+:mod:`koopman_graph.analysis.causal_intervention`.
"""
from __future__ import annotations
@@ -29,6 +32,11 @@ class CausalInfluenceReport:
def granger_latent_influence(latents: Tensor) -> CausalInfluenceReport:
"""Score directed influence among node latent trajectories.
+ This helper is **non-interventional**: it does not apply a
+ do-operator and does not recover interventional edges. Use
+ :func:`~koopman_graph.analysis.recover_synthetic_interventional_edges`
+ for the labeled synthetic SCM fixture.
+
Parameters
----------
latents : Tensor
diff --git a/src/koopman_graph/analysis/causal_intervention.py b/src/koopman_graph/analysis/causal_intervention.py
new file mode 100644
index 0000000..c2a2e48
--- /dev/null
+++ b/src/koopman_graph/analysis/causal_intervention.py
@@ -0,0 +1,572 @@
+"""Labeled synthetic SCM interventions (not Granger discovery).
+
+:func:`teaching_three_node_scm` is a three-node acyclic linear Gaussian
+fixture with a known edge ``0 → 1`` and an independent node ``2``.
+:func:`recover_synthetic_interventional_edges` scores
+``|E[X_j | do(X_i = v_hi)] - E[X_j | do(X_i = v_lo)]|`` on that
+generator. The protocol is **synthetic** and **interventional** on
+the fixture only.
+
+:func:`~koopman_graph.analysis.granger_latent_influence` remains
+**non-interventional**. This module does not change that helper and
+does not claim observational causal discovery on field data.
+
+This module must not import :mod:`koopman_graph.model`.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import torch
+from torch import Tensor
+
+DEFAULT_ATE_THRESHOLD = 0.25
+DEFAULT_INTERVENTION_HIGH = 1.0
+DEFAULT_INTERVENTION_LOW = -1.0
+DEFAULT_SCM_EDGE_WEIGHT = 0.8
+DEFAULT_SCM_NOISE_SCALE = 0.05
+DEFAULT_SCM_SAMPLES = 512
+DEFAULT_SCM_SEED = 0
+TEACHING_SCM_N_NODES = 3
+
+__all__ = [
+ "DEFAULT_ATE_THRESHOLD",
+ "DEFAULT_INTERVENTION_HIGH",
+ "DEFAULT_INTERVENTION_LOW",
+ "DEFAULT_SCM_EDGE_WEIGHT",
+ "DEFAULT_SCM_NOISE_SCALE",
+ "DEFAULT_SCM_SAMPLES",
+ "DEFAULT_SCM_SEED",
+ "SyntheticInterventionReport",
+ "SyntheticSCM",
+ "TEACHING_SCM_N_NODES",
+ "recover_synthetic_interventional_edges",
+ "sample_synthetic_intervention",
+ "sample_synthetic_observational",
+ "teaching_three_node_scm",
+]
+
+
+def _require_square_weights(weights: Tensor) -> Tensor:
+ """Cast and validate a square contemporaneous weight matrix.
+
+ Parameters
+ ----------
+ weights : Tensor
+ Candidate ``(N, N)`` edge weights. Entry ``(i, j)`` is the
+ coefficient on edge ``i → j``.
+
+ Returns
+ -------
+ Tensor
+ ``float64`` clone on CPU.
+
+ Raises
+ ------
+ ValueError
+ If the rank or shape is invalid, the diagonal is nonzero, or
+ an entry is non-finite.
+ """
+ if weights.ndim != 2 or int(weights.shape[0]) != int(weights.shape[1]):
+ msg = f"weights must have shape (N, N), got {tuple(weights.shape)}"
+ raise ValueError(msg)
+ if int(weights.shape[0]) < 2:
+ msg = f"synthetic SCM requires N >= 2, got N={int(weights.shape[0])}"
+ raise ValueError(msg)
+ table = weights.detach().to(dtype=torch.float64, device="cpu").clone()
+ if not bool(torch.isfinite(table).all()):
+ msg = "weights must be finite"
+ raise ValueError(msg)
+ if bool(torch.any(table.diag().abs() > 1e-12)):
+ msg = "synthetic SCM weights must have a zero diagonal"
+ raise ValueError(msg)
+ return table
+
+
+def _topological_order(weights: Tensor) -> tuple[int, ...]:
+ """Kahn topological order for an acyclic weight matrix.
+
+ Parameters
+ ----------
+ weights : Tensor
+ Square ``(N, N)`` matrix with ``weights[i, j]`` = edge ``i → j``.
+
+ Returns
+ -------
+ tuple of int
+ A parent-before-child order.
+
+ Raises
+ ------
+ ValueError
+ If the directed graph contains a cycle.
+ """
+ n_nodes = int(weights.shape[0])
+ remaining = {
+ (int(source), int(target))
+ for source in range(n_nodes)
+ for target in range(n_nodes)
+ if float(weights[source, target].abs()) > 1e-12
+ }
+ indegree = [0] * n_nodes
+ for _source, target in remaining:
+ indegree[target] += 1
+ ready = [node for node, degree in enumerate(indegree) if degree == 0]
+ order: list[int] = []
+ while ready:
+ node = ready.pop(0)
+ order.append(node)
+ outgoing = [edge for edge in remaining if edge[0] == node]
+ for edge in outgoing:
+ remaining.remove(edge)
+ child = edge[1]
+ indegree[child] -= 1
+ if indegree[child] == 0:
+ ready.append(child)
+ if remaining:
+ msg = "synthetic SCM weights must be acyclic"
+ raise ValueError(msg)
+ return tuple(order)
+
+
+def _true_edges(weights: Tensor, *, atol: float = 1e-12) -> tuple[tuple[int, int], ...]:
+ """Return directed edges with absolute weight above ``atol``.
+
+ Parameters
+ ----------
+ weights : Tensor
+ Square ``(N, N)`` matrix with ``weights[i, j]`` = edge ``i → j``.
+ atol : float, optional
+ Absolute cutoff for a zero coefficient.
+
+ Returns
+ -------
+ tuple of tuple of int
+ Sorted ``(source, target)`` pairs.
+ """
+ n_nodes = int(weights.shape[0])
+ edges = [
+ (source, target)
+ for source in range(n_nodes)
+ for target in range(n_nodes)
+ if source != target and float(weights[source, target].abs()) > atol
+ ]
+ return tuple(sorted(edges))
+
+
+def _draw_assignment(
+ weights: Tensor,
+ noise: Tensor,
+ order: tuple[int, ...],
+ *,
+ intervene_source: int | None,
+ intervene_value: float | None,
+) -> Tensor:
+ """Solve one contemporaneous assignment, optionally under a do-set.
+
+ Parameters
+ ----------
+ weights : Tensor
+ Square ``(N, N)`` matrix with ``weights[i, j]`` = edge ``i → j``.
+ noise : Tensor
+ Exogenous noise with shape ``(N,)``.
+ order : tuple of int
+ Topological order.
+ intervene_source : int or None
+ Node whose structural equation is replaced. ``None`` is
+ observational.
+ intervene_value : float or None
+ Value assigned under ``do(X_i = v)``.
+
+ Returns
+ -------
+ Tensor
+ One draw with shape ``(N,)``.
+ """
+ n_nodes = int(weights.shape[0])
+ assigned = torch.zeros(n_nodes, dtype=weights.dtype)
+ for node in order:
+ if intervene_source is not None and node == int(intervene_source):
+ assigned[node] = float(intervene_value)
+ continue
+ parents = weights[:, node] * assigned
+ assigned[node] = parents.sum() + noise[node]
+ return assigned
+
+
+def _sample_rows(
+ scm: SyntheticSCM,
+ n_samples: int,
+ *,
+ generator: torch.Generator,
+ intervene_source: int | None,
+ intervene_value: float | None,
+) -> Tensor:
+ """Draw independent rows from the observational or do-law.
+
+ Parameters
+ ----------
+ scm : SyntheticSCM
+ Labeled synthetic fixture.
+ n_samples : int
+ Number of independent draws.
+ generator : torch.Generator
+ CPU generator for the exogenous noise.
+ intervene_source : int or None
+ Intervened node, or ``None`` for the observational law.
+ intervene_value : float or None
+ do-value when ``intervene_source`` is set.
+
+ Returns
+ -------
+ Tensor
+ Table with shape ``(n_samples, N)``.
+
+ Raises
+ ------
+ ValueError
+ If ``n_samples`` or the intervention index is invalid.
+ """
+ if n_samples < 1:
+ msg = f"n_samples must be >= 1, got {n_samples}"
+ raise ValueError(msg)
+ n_nodes = int(scm.weights.shape[0])
+ if intervene_source is not None:
+ index = int(intervene_source)
+ if index < 0 or index >= n_nodes:
+ msg = f"intervention source {index} is outside [0, {n_nodes})"
+ raise ValueError(msg)
+ if intervene_value is None:
+ msg = "intervene_value is required when intervene_source is set"
+ raise ValueError(msg)
+ noise = scm.noise_scale * torch.randn(
+ n_samples,
+ n_nodes,
+ dtype=scm.weights.dtype,
+ generator=generator,
+ )
+ rows = [
+ _draw_assignment(
+ scm.weights,
+ noise[row],
+ scm.order,
+ intervene_source=intervene_source,
+ intervene_value=intervene_value,
+ )
+ for row in range(n_samples)
+ ]
+ return torch.stack(rows, dim=0)
+
+
+@dataclass(frozen=True)
+class SyntheticSCM:
+ """Acyclic linear Gaussian teaching SCM (labeled synthetic).
+
+ Attributes
+ ----------
+ weights : Tensor
+ Shape ``(N, N)``. Entry ``(i, j)`` is the coefficient on the
+ directed edge ``i → j``. The diagonal is zero.
+ noise_scale : float
+ Standard deviation of independent Gaussian exogenous noise.
+ seed : int
+ Default seed used by the sampling helpers.
+ labeled_synthetic : bool
+ Always ``True``. This fixture is not a field-data SCM.
+ order : tuple of int
+ Topological order filled at construction.
+ """
+
+ weights: Tensor
+ noise_scale: float
+ seed: int
+ labeled_synthetic: bool = True
+ order: tuple[int, ...] = ()
+
+ def __post_init__(self) -> None:
+ """Validate the labeled synthetic fixture.
+
+ Raises
+ ------
+ ValueError
+ If the weights, noise scale, seed, or synthetic flag are
+ invalid.
+ """
+ table = _require_square_weights(self.weights)
+ object.__setattr__(self, "weights", table)
+ if float(self.noise_scale) <= 0.0:
+ msg = f"noise_scale must be > 0, got {self.noise_scale}"
+ raise ValueError(msg)
+ object.__setattr__(self, "noise_scale", float(self.noise_scale))
+ object.__setattr__(self, "seed", int(self.seed))
+ object.__setattr__(self, "order", _topological_order(table))
+ if self.labeled_synthetic is not True:
+ msg = "SyntheticSCM.labeled_synthetic must be True"
+ raise ValueError(msg)
+
+ @property
+ def true_edges(self) -> tuple[tuple[int, int], ...]:
+ """Directed edges with a nonzero coefficient.
+
+ Returns
+ -------
+ tuple of tuple of int
+ Sorted ``(source, target)`` pairs.
+ """
+ return _true_edges(self.weights)
+
+
+@dataclass(frozen=True)
+class SyntheticInterventionReport:
+ """Recovered do-edges on a labeled synthetic SCM.
+
+ Attributes
+ ----------
+ scores : Tensor
+ Shape ``(N, N)``. Entry ``(i, j)`` is the absolute mean shift
+ of node ``j`` under ``do(X_i = v_hi)`` versus
+ ``do(X_i = v_lo)``. The diagonal is zero.
+ recovered_edges : tuple of tuple of int
+ Pairs with ``scores[i, j] > threshold``.
+ true_edges : tuple of tuple of int
+ Ground-truth edges of the synthetic fixture.
+ threshold : float
+ Absolute ATE cutoff used to form ``recovered_edges``.
+ labeled_synthetic : bool
+ Always ``True``. This report is not observational Granger.
+ """
+
+ scores: Tensor
+ recovered_edges: tuple[tuple[int, int], ...]
+ true_edges: tuple[tuple[int, int], ...]
+ threshold: float
+ labeled_synthetic: bool = True
+
+ def __post_init__(self) -> None:
+ """Validate the interventional recovery record.
+
+ Raises
+ ------
+ ValueError
+ If shapes, flags, or the threshold are invalid.
+ """
+ if self.scores.ndim != 2 or int(self.scores.shape[0]) != int(
+ self.scores.shape[1]
+ ):
+ msg = f"scores must have shape (N, N), got {tuple(self.scores.shape)}"
+ raise ValueError(msg)
+ object.__setattr__(
+ self,
+ "scores",
+ self.scores.detach().to(dtype=torch.float64, device="cpu").clone(),
+ )
+ object.__setattr__(self, "recovered_edges", tuple(sorted(self.recovered_edges)))
+ object.__setattr__(self, "true_edges", tuple(sorted(self.true_edges)))
+ if float(self.threshold) <= 0.0:
+ msg = f"threshold must be > 0, got {self.threshold}"
+ raise ValueError(msg)
+ object.__setattr__(self, "threshold", float(self.threshold))
+ if self.labeled_synthetic is not True:
+ msg = "SyntheticInterventionReport.labeled_synthetic must be True"
+ raise ValueError(msg)
+
+
+def teaching_three_node_scm(
+ *,
+ seed: int = DEFAULT_SCM_SEED,
+ edge_weight: float = DEFAULT_SCM_EDGE_WEIGHT,
+ noise_scale: float = DEFAULT_SCM_NOISE_SCALE,
+) -> SyntheticSCM:
+ """Build the three-node teaching SCM with a known edge ``0 → 1``.
+
+ Parameters
+ ----------
+ seed : int, optional
+ Default sampling seed.
+ edge_weight : float, optional
+ Coefficient on ``0 → 1``. Must be nonzero.
+ noise_scale : float, optional
+ Exogenous Gaussian scale.
+
+ Returns
+ -------
+ SyntheticSCM
+ Labeled synthetic fixture. Node ``2`` is independent.
+
+ Raises
+ ------
+ ValueError
+ If ``edge_weight`` is zero.
+ """
+ if abs(float(edge_weight)) <= 1e-12:
+ msg = "teaching SCM edge_weight must be nonzero"
+ raise ValueError(msg)
+ weights = torch.zeros(TEACHING_SCM_N_NODES, TEACHING_SCM_N_NODES)
+ weights[0, 1] = float(edge_weight)
+ table = _require_square_weights(weights)
+ return SyntheticSCM(
+ weights=table,
+ noise_scale=float(noise_scale),
+ seed=int(seed),
+ labeled_synthetic=True,
+ )
+
+
+def sample_synthetic_observational(
+ scm: SyntheticSCM,
+ n_samples: int,
+ *,
+ generator: torch.Generator | None = None,
+) -> Tensor:
+ """Draw observational samples from a labeled synthetic SCM.
+
+ Parameters
+ ----------
+ scm : SyntheticSCM
+ Labeled synthetic fixture.
+ n_samples : int
+ Number of independent draws.
+ generator : torch.Generator or None, optional
+ CPU generator. Default seeds from ``scm.seed``.
+
+ Returns
+ -------
+ Tensor
+ Table with shape ``(n_samples, N)``.
+ """
+ rng = torch.Generator()
+ if generator is None:
+ rng.manual_seed(int(scm.seed))
+ else:
+ rng = generator
+ return _sample_rows(
+ scm,
+ n_samples,
+ generator=rng,
+ intervene_source=None,
+ intervene_value=None,
+ )
+
+
+def sample_synthetic_intervention(
+ scm: SyntheticSCM,
+ *,
+ source: int,
+ value: float,
+ n_samples: int,
+ generator: torch.Generator | None = None,
+) -> Tensor:
+ """Draw samples from ``do(X_source = value)`` on a synthetic SCM.
+
+ Parameters
+ ----------
+ scm : SyntheticSCM
+ Labeled synthetic fixture.
+ source : int
+ Intervened node. Its structural equation is replaced.
+ value : float
+ Assigned value.
+ n_samples : int
+ Number of independent draws.
+ generator : torch.Generator or None, optional
+ CPU generator. Default seeds from ``scm.seed + 1 + source``.
+
+ Returns
+ -------
+ Tensor
+ Table with shape ``(n_samples, N)``.
+ """
+ rng = torch.Generator()
+ if generator is None:
+ rng.manual_seed(int(scm.seed) + 1 + int(source))
+ else:
+ rng = generator
+ return _sample_rows(
+ scm,
+ n_samples,
+ generator=rng,
+ intervene_source=int(source),
+ intervene_value=float(value),
+ )
+
+
+def recover_synthetic_interventional_edges(
+ scm: SyntheticSCM,
+ *,
+ n_samples: int = DEFAULT_SCM_SAMPLES,
+ intervention_low: float = DEFAULT_INTERVENTION_LOW,
+ intervention_high: float = DEFAULT_INTERVENTION_HIGH,
+ threshold: float = DEFAULT_ATE_THRESHOLD,
+ seed: int | None = None,
+) -> SyntheticInterventionReport:
+ """Recover directed edges from paired do-interventions on a toy SCM.
+
+ For each source ``i`` the helper compares mean node features under
+ ``do(X_i = intervention_high)`` and ``do(X_i = intervention_low)``.
+ This is a labeled synthetic protocol, not
+ :func:`~koopman_graph.analysis.granger_latent_influence`.
+
+ Parameters
+ ----------
+ scm : SyntheticSCM
+ Labeled synthetic fixture.
+ n_samples : int, optional
+ Independent draws per do-value.
+ intervention_low, intervention_high : float, optional
+ Pair of do-values. They must differ.
+ threshold : float, optional
+ Absolute mean-shift cutoff that marks a recovered edge.
+ seed : int or None, optional
+ Base seed for the paired interventions. Default is ``scm.seed``.
+
+ Returns
+ -------
+ SyntheticInterventionReport
+ Absolute mean-shift scores and the recovered edge set.
+
+ Raises
+ ------
+ ValueError
+ If the do-values coincide or ``threshold`` is not positive.
+ """
+ if abs(float(intervention_high) - float(intervention_low)) <= 1e-12:
+ msg = "intervention_high and intervention_low must differ"
+ raise ValueError(msg)
+ if float(threshold) <= 0.0:
+ msg = f"threshold must be > 0, got {threshold}"
+ raise ValueError(msg)
+ base_seed = int(scm.seed if seed is None else seed)
+ n_nodes = int(scm.weights.shape[0])
+ scores = torch.zeros(n_nodes, n_nodes, dtype=torch.float64)
+ for source in range(n_nodes):
+ low = sample_synthetic_intervention(
+ scm,
+ source=source,
+ value=float(intervention_low),
+ n_samples=n_samples,
+ generator=torch.Generator().manual_seed(base_seed + 10 * source),
+ )
+ high = sample_synthetic_intervention(
+ scm,
+ source=source,
+ value=float(intervention_high),
+ n_samples=n_samples,
+ generator=torch.Generator().manual_seed(base_seed + 10 * source + 1),
+ )
+ shift = (high.mean(dim=0) - low.mean(dim=0)).abs()
+ scores[source] = shift
+ scores[source, source] = 0.0
+ recovered = tuple(
+ (source, target)
+ for source in range(n_nodes)
+ for target in range(n_nodes)
+ if source != target and float(scores[source, target]) > float(threshold)
+ )
+ return SyntheticInterventionReport(
+ scores=scores,
+ recovered_edges=recovered,
+ true_edges=scm.true_edges,
+ threshold=float(threshold),
+ labeled_synthetic=True,
+ )
diff --git a/src/koopman_graph/analysis/conditioning.py b/src/koopman_graph/analysis/conditioning.py
new file mode 100644
index 0000000..7c9fabe
--- /dev/null
+++ b/src/koopman_graph/analysis/conditioning.py
@@ -0,0 +1,28 @@
+"""Re-export spectral conditioning diagnostics from the neutral leaf.
+
+Compute lives in :mod:`koopman_graph.spectrum_types` so operators and the
+model façade never import this package for eigendecomposition. Resolvent
+grids remain opt-in in :mod:`koopman_graph.analysis.pseudospectra` and are
+not populated by :func:`~koopman_graph.spectrum_types.compute_spectrum`.
+Discrete Nyquist frequency is :math:`1/(2\\Delta t)` in cycles per unit
+time; generator spectra leave it unset. ``mode_amplitudes`` warns when
+:math:`\\kappa(V)` exceeds ``CONDITION_WARN`` and raises
+:class:`~koopman_graph.spectrum_types.DefectiveSpectrumError` when
+:math:`V` is singular.
+"""
+
+from __future__ import annotations
+
+from koopman_graph.spectrum_types import (
+ CONDITION_WARN,
+ DefectiveSpectrumError,
+ SpectralDiagnostics,
+ compute_spectral_diagnostics,
+)
+
+__all__ = [
+ "CONDITION_WARN",
+ "DefectiveSpectrumError",
+ "SpectralDiagnostics",
+ "compute_spectral_diagnostics",
+]
diff --git a/src/koopman_graph/analysis/criticality.py b/src/koopman_graph/analysis/criticality.py
new file mode 100644
index 0000000..94ae19e
--- /dev/null
+++ b/src/koopman_graph/analysis/criticality.py
@@ -0,0 +1,299 @@
+"""Sliding-window spectral-gap and near-defectivity monitor.
+
+Honesty contract
+----------------
+:func:`monitor_critical_transition` scores a *sequence* of already-computed
+:class:`~koopman_graph.spectrum_types.KoopmanSpectrum` objects. The gap
+
+.. math::
+
+ \\gamma_t = \\min_{i\\neq j}\\lvert\\lambda_i(t)-\\lambda_j(t)\\rvert
+
+and the windowed rate :math:`(\\gamma_{t-w+1}-\\gamma_t)/(\\tau_t-
+\\tau_{t-w+1})` are heuristics. Positive rate means the gap shrank.
+They are **not** a topology-criticality certificate, not an
+early-warning score for infrastructure forecasting, and not a
+replacement for
+:meth:`~koopman_graph.operators.KoopmanOperator.stability_certificate`
+or :func:`~koopman_graph.analysis.resolvent_norm_grid`. Near-defectivity
+is a flag on :math:`\\kappa(V)`, not a Schur form and not
+:class:`~koopman_graph.spectrum_types.DefectiveSpectrumError`.
+
+This module does not implement Ghosh, *Intelligent Systems with
+Applications*, 2025 (``Ghosh2025``). That paper is cited as related
+literature and as the honesty ceiling for this helper.
+
+See Also
+--------
+:class:`~koopman_graph.spectrum_types.SpectralDiagnostics`
+ Per-spectrum :math:`\\kappa(V)` used for the near-defectivity flag.
+:func:`~koopman_graph.operators.continuous_van_loan.matrix_log`
+ Structured error on Jordan / singular eigenbases, distinct from
+ this sequence monitor.
+
+References
+----------
+Ghosh, R. (2025). Neural Koopman forecasting for critical transitions
+in infrastructure networks. *Intelligent Systems with Applications*,
+27, 200575. https://doi.org/10.1016/j.iswa.2025.200575
+(``Ghosh2025``; related literature / non-goal — this helper does not
+implement that forecasting method.)
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+from torch import Tensor
+
+from koopman_graph.spectrum_types import (
+ CONDITION_WARN,
+ KoopmanSpectrum,
+ _column_normalized_condition,
+)
+
+
+@dataclass(frozen=True)
+class CriticalityReport:
+ """Sliding-window spectral-gap trajectory and near-defectivity flags.
+
+ Attributes
+ ----------
+ spectral_gap : Tensor
+ Per-spectrum gap :math:`\\gamma_t` with shape ``(T,)``. Same
+ units as the stored eigenvalues (dimensionless for discrete
+ ``K``; 1 / time unit for generator spectra). Non-negative.
+ gap_closure_rate : Tensor
+ Windowed :math:`(\\gamma_{t-w+1}-\\gamma_t)/(\\tau_t-
+ \\tau_{t-w+1})` with shape ``(T - window + 1,)``. Positive
+ values mean the gap shrank. Units: eigenvalue units / sample
+ time unit (1 / step when ``sample_times`` is omitted).
+ near_defective : Tensor
+ Boolean mask with shape ``(T,)``. ``True`` when
+ :math:`\\kappa(V)` is non-finite or exceeds ``CONDITION_WARN``.
+ max_gap_closure_rate : float
+ Maximum of ``gap_closure_rate`` (the scalar “score”).
+ """
+
+ spectral_gap: Tensor
+ gap_closure_rate: Tensor
+ near_defective: Tensor
+ max_gap_closure_rate: float
+
+ def __post_init__(self) -> None:
+ """Validate trajectory shapes and non-negativity of the gap.
+
+ Raises
+ ------
+ ValueError
+ If tensors are empty or misaligned, the gap is negative, or
+ ``max_gap_closure_rate`` is NaN.
+ """
+ gap = self.spectral_gap
+ rate = self.gap_closure_rate
+ flags = self.near_defective
+ if gap.ndim != 1 or int(gap.numel()) == 0:
+ raise ValueError("spectral_gap must be a nonempty 1-D tensor")
+ n_times = int(gap.numel())
+ if flags.ndim != 1 or int(flags.numel()) != n_times:
+ msg = (
+ "near_defective must have shape (T,), "
+ f"got {tuple(flags.shape)} for T={n_times}"
+ )
+ raise ValueError(msg)
+ if flags.dtype != torch.bool:
+ raise ValueError("near_defective must be a boolean tensor")
+ if rate.ndim != 1 or int(rate.numel()) == 0 or int(rate.numel()) > n_times:
+ msg = (
+ "gap_closure_rate must be a nonempty 1-D tensor with "
+ f"length <= T={n_times}, got {tuple(rate.shape)}"
+ )
+ raise ValueError(msg)
+ if bool((gap < 0).any().item()):
+ raise ValueError("spectral_gap must be non-negative")
+ if math.isnan(self.max_gap_closure_rate):
+ raise ValueError("max_gap_closure_rate must not be NaN")
+
+
+def _pairwise_spectral_gap(eigenvalues: Tensor) -> float:
+ """Return :math:`\\min_{i\\neq j}|\\lambda_i-\\lambda_j|`.
+
+ Parameters
+ ----------
+ eigenvalues : Tensor
+ 1-D eigenvalues, real or complex, length ``n >= 2``.
+
+ Returns
+ -------
+ float
+ Non-negative gap in the same units as ``eigenvalues``.
+
+ Raises
+ ------
+ ValueError
+ If there are fewer than two eigenvalues or any value is
+ non-finite.
+ """
+ values = eigenvalues.reshape(-1)
+ n_modes = int(values.numel())
+ if n_modes < 2:
+ msg = (
+ f"spectral gap requires at least two eigenvalues, got latent_dim={n_modes}"
+ )
+ raise ValueError(msg)
+ if values.is_complex():
+ finite = torch.isfinite(values.real) & torch.isfinite(values.imag)
+ else:
+ finite = torch.isfinite(values)
+ if not bool(finite.all().item()):
+ raise ValueError("eigenvalues must be finite")
+ delta = values.unsqueeze(0) - values.unsqueeze(1)
+ distance = delta.abs()
+ eye = torch.eye(n_modes, dtype=torch.bool, device=values.device)
+ off_diagonal = distance.masked_fill(eye, math.inf)
+ gap = off_diagonal.min()
+ return float(gap.item())
+
+
+def _resolve_sample_times(
+ n_times: int,
+ sample_times: Tensor | Sequence[float] | None,
+) -> Tensor:
+ """Return strictly increasing sample times of length ``n_times``.
+
+ Parameters
+ ----------
+ n_times : int
+ Number of spectra ``T``.
+ sample_times : Tensor or sequence of float or None
+ Caller times, or ``None`` for ``0, 1, ..., T-1``.
+
+ Returns
+ -------
+ Tensor
+ 1-D float64 times with shape ``(T,)``.
+
+ Raises
+ ------
+ ValueError
+ If the length differs from ``T``, any time is non-finite, or
+ the sequence is not strictly increasing.
+ """
+ if sample_times is None:
+ return torch.arange(n_times, dtype=torch.float64)
+ times = torch.as_tensor(sample_times, dtype=torch.float64).reshape(-1)
+ if int(times.numel()) != n_times:
+ msg = f"sample_times must have length T={n_times}, got {int(times.numel())}"
+ raise ValueError(msg)
+ if not bool(torch.isfinite(times).all().item()):
+ raise ValueError("sample_times must be finite")
+ if n_times >= 2 and bool((times[1:] - times[:-1] <= 0).any().item()):
+ raise ValueError("sample_times must be strictly increasing")
+ return times
+
+
+def _near_defective_flag(spectrum: KoopmanSpectrum) -> bool:
+ """Return True when column-normalized :math:`\\kappa(V)` is unusable.
+
+ Parameters
+ ----------
+ spectrum : KoopmanSpectrum
+ Spectrum whose eigenbasis is tested.
+
+ Returns
+ -------
+ bool
+ ``True`` when :math:`\\kappa(V)` is non-finite or exceeds
+ ``CONDITION_WARN``.
+ """
+ if spectrum.diagnostics is not None:
+ kappa = spectrum.diagnostics.eigenvector_condition
+ else:
+ kappa = _column_normalized_condition(spectrum.eigenvectors)
+ return (not math.isfinite(kappa)) or kappa > CONDITION_WARN
+
+
+def monitor_critical_transition(
+ spectra: Sequence[KoopmanSpectrum],
+ *,
+ window: int,
+ sample_times: Tensor | Sequence[float] | None = None,
+) -> CriticalityReport:
+ """Score spectral-gap closure and near-defectivity on a spectrum sequence.
+
+ The pairwise gap :math:`\\gamma_t` uses every eigenpair, not
+ consecutive magnitude-sorted neighbors. The windowed rate is
+
+ .. math::
+
+ s_t = \\frac{\\gamma_{t-w+1} - \\gamma_t}{\\tau_t - \\tau_{t-w+1}}
+
+ so a **positive** rate means the gap closed. Default :math:`\\tau`
+ is unit spacing (one step per spectrum). This is a heuristic
+ diagnostic, not a Ghosh-grade critical-transition certificate.
+
+ Parameters
+ ----------
+ spectra : sequence of KoopmanSpectrum
+ Nonempty sequence of length ``T``. Every spectrum must have the
+ same ``latent_dim >= 2``.
+ window : int
+ Sliding-window length ``w``. Must satisfy ``2 <= w <= T``.
+ sample_times : Tensor or sequence of float or None, optional
+ Strictly increasing times of length ``T`` in the caller time
+ unit. ``None`` (default) uses ``0, 1, ..., T-1``.
+
+ Returns
+ -------
+ CriticalityReport
+ Gap trajectory, windowed closure rates, near-defectivity flags,
+ and ``max_gap_closure_rate``.
+
+ Raises
+ ------
+ ValueError
+ If ``spectra`` is empty, widths differ, ``latent_dim < 2``,
+ ``window`` is invalid, eigenvalues are non-finite, or
+ ``sample_times`` is not strictly increasing of length ``T``.
+ """
+ n_times = len(spectra)
+ if n_times == 0:
+ raise ValueError("spectra must be a nonempty sequence")
+ if isinstance(window, bool) or not isinstance(window, int) or window < 2:
+ msg = f"window must be an int >= 2, got {window!r}"
+ raise ValueError(msg)
+ if window > n_times:
+ msg = f"window must be <= len(spectra) ({n_times}), got {window}"
+ raise ValueError(msg)
+
+ latent_dim = int(spectra[0].eigenvalues.numel())
+ gaps: list[float] = []
+ flags: list[bool] = []
+ for index, spectrum in enumerate(spectra):
+ width = int(spectrum.eigenvalues.numel())
+ if width != latent_dim:
+ msg = (
+ "all spectra must share latent_dim="
+ f"{latent_dim}, got {width} at index {index}"
+ )
+ raise ValueError(msg)
+ gaps.append(_pairwise_spectral_gap(spectrum.eigenvalues))
+ flags.append(_near_defective_flag(spectrum))
+
+ times = _resolve_sample_times(n_times, sample_times)
+ gap_tensor = torch.tensor(gaps, dtype=torch.float64)
+ n_windows = n_times - window + 1
+ gap_start = gap_tensor[:n_windows]
+ gap_end = gap_tensor[window - 1 :]
+ delta_tau = times[window - 1 :] - times[:n_windows]
+ rates = (gap_start - gap_end) / delta_tau
+ max_rate = float(rates.max().item())
+ return CriticalityReport(
+ spectral_gap=gap_tensor,
+ gap_closure_rate=rates,
+ near_defective=torch.tensor(flags, dtype=torch.bool),
+ max_gap_closure_rate=max_rate,
+ )
diff --git a/src/koopman_graph/analysis/hodge_modes.py b/src/koopman_graph/analysis/hodge_modes.py
new file mode 100644
index 0000000..f62e5ff
--- /dev/null
+++ b/src/koopman_graph/analysis/hodge_modes.py
@@ -0,0 +1,280 @@
+"""Combinatorial Hodge split of Koopman mode shapes.
+
+:func:`hodge_decompose_modes` projects eigenvector columns onto the
+gradient / curl / harmonic subspaces of a static signed incidence
+:math:`B_1` (``Lim2020Hodge``):
+
+.. math::
+
+ z_k = d_{k-1}\\alpha + \\delta_{k+1}\\beta + \\gamma.
+
+For :math:`k=0`, :math:`d_{-1}=0` so the gradient block is zero and
+``curl`` is the image of :math:`\\delta_1=B_1`. For :math:`k=1`
+without faces, :math:`\\delta_2=0` so the curl block is zero and
+``gradient`` is the image of :math:`d_0=B_1^{\\top}`. A consistently
+oriented cycle has a one-dimensional harmonic 1-cochain (circulation).
+That algebraic kernel is **not** a physical current or validated
+circulation.
+
+This helper is analysis-only. It does not change the factory default,
+is **not** :class:`~koopman_graph.operators.HodgeKoopmanOperator`, and
+is **not** TopologicX / sheaf parity (``TopoX2024``).
+
+This module must not import :mod:`koopman_graph.model`.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import torch
+from torch import Tensor
+
+from koopman_graph.observables import boundary_incidence_b1
+from koopman_graph.spectrum_types import KoopmanSpectrum
+
+DEFAULT_HODGE_ATOL = 1e-6
+
+__all__ = [
+ "DEFAULT_HODGE_ATOL",
+ "HodgeModeComponents",
+ "hodge_decompose_modes",
+]
+
+
+@dataclass(frozen=True)
+class HodgeModeComponents:
+ """Gradient / curl / harmonic parts of stored mode-shape columns.
+
+ Attributes
+ ----------
+ gradient : Tensor
+ Image of :math:`d_{k-1}`. Shape ``(n_cells, n_modes)``. Same
+ units as the eigenvector entries (dimensionless mode
+ coordinates). Zero for :math:`k=0`.
+ curl : Tensor
+ Image of :math:`\\delta_{k+1}`. Shape ``(n_cells, n_modes)``.
+ Zero for :math:`k=1` when no faces are supplied.
+ harmonic : Tensor
+ Component in :math:`\\ker L_k`. Shape ``(n_cells, n_modes)``.
+ Not a physical circulation certificate.
+ """
+
+ gradient: Tensor
+ curl: Tensor
+ harmonic: Tensor
+
+ def __post_init__(self) -> None:
+ """Validate matching column layouts.
+
+ Raises
+ ------
+ ValueError
+ If the three blocks do not share a 2-D shape.
+ """
+ shape = tuple(self.gradient.shape)
+ if self.gradient.ndim != 2 or int(self.gradient.shape[1]) < 1:
+ msg = f"gradient must have shape (n_cells, n_modes), got {shape}"
+ raise ValueError(msg)
+ if tuple(self.curl.shape) != shape or tuple(self.harmonic.shape) != shape:
+ msg = (
+ "gradient, curl, and harmonic must share shape "
+ f"{shape}; got {tuple(self.curl.shape)} and "
+ f"{tuple(self.harmonic.shape)}"
+ )
+ raise ValueError(msg)
+
+
+def _project_image(basis: Tensor, vectors: Tensor) -> Tensor:
+ """Project columns of ``vectors`` onto :math:`\\operatorname{im}(basis)`.
+
+ Parameters
+ ----------
+ basis : Tensor
+ Matrix whose columns span the target subspace, shape
+ ``(n_cells, n_gen)``.
+ vectors : Tensor
+ Columns to project, shape ``(n_cells, n_modes)``.
+
+ Returns
+ -------
+ Tensor
+ Projected columns, same shape as ``vectors``.
+ """
+ if int(basis.shape[1]) == 0:
+ return torch.zeros_like(vectors)
+ coefficients = torch.linalg.lstsq(basis, vectors).solution
+ return basis @ coefficients
+
+
+def _resolve_degree(
+ n_rows: int,
+ *,
+ num_nodes: int,
+ num_edges: int,
+ degree: int | None,
+) -> int:
+ """Infer or validate the cochain degree from eigenvector length.
+
+ Parameters
+ ----------
+ n_rows : int
+ Eigenvector row count.
+ num_nodes : int
+ Number of 0-cells.
+ num_edges : int
+ Number of 1-cells.
+ degree : int or None
+ Caller degree, or ``None`` to infer.
+
+ Returns
+ -------
+ int
+ ``0`` or ``1``.
+
+ Raises
+ ------
+ ValueError
+ If the degree is unsupported or the row count does not match.
+ """
+ if degree is None:
+ if n_rows == int(num_nodes):
+ return 0
+ if n_rows == int(num_edges):
+ return 1
+ msg = (
+ "eigenvector rows must equal num_nodes or num_edges to infer "
+ f"degree, got rows={n_rows}, N={num_nodes}, E={num_edges}"
+ )
+ raise ValueError(msg)
+ if isinstance(degree, bool) or not isinstance(degree, int):
+ raise ValueError(f"degree must be 0 or 1, got {degree!r}")
+ if int(degree) not in {0, 1}:
+ msg = f"degree must be 0 or 1, got {degree}; k=2 curl is not shipped"
+ raise ValueError(msg)
+ expected = int(num_nodes) if int(degree) == 0 else int(num_edges)
+ if n_rows != expected:
+ kind = "num_nodes" if int(degree) == 0 else "num_edges"
+ msg = (
+ f"degree={degree} requires eigenvector rows equal to {kind} "
+ f"{expected}, got {n_rows}"
+ )
+ raise ValueError(msg)
+ return int(degree)
+
+
+def hodge_decompose_modes(
+ spectrum: KoopmanSpectrum,
+ edge_index: Tensor,
+ *,
+ num_nodes: int,
+ degree: int | None = None,
+) -> HodgeModeComponents:
+ """Split stored eigenvectors into gradient / curl / harmonic parts.
+
+ Eigenvector columns are treated as real or complex cochains on the
+ oriented 1-skeleton. Complex columns are split into real and
+ imaginary parts, projected, and reassembled. The split is the
+ linear algebra of :math:`B_1`, not a physical circulation and not
+ a sheaf Laplacian (``Lim2020Hodge``, ``TopoX2024``).
+
+ Parameters
+ ----------
+ spectrum : KoopmanSpectrum
+ Stored eigenpairs. Eigenvector rows must equal ``num_nodes``
+ (:math:`k=0`) or the number of ``edge_index`` columns
+ (:math:`k=1`).
+ edge_index : Tensor
+ Oriented 1-cells ``(2, E)``. One column per undirected edge.
+ num_nodes : int
+ Number of 0-cells :math:`N`.
+ degree : int or None, optional
+ Cochain degree ``0`` or ``1``. Default infers from the
+ eigenvector row count. When ``num_nodes == num_edges``,
+ that inference is ``0``; pass ``degree=1`` for a
+ 1-cochain on a cycle.
+
+ Returns
+ -------
+ HodgeModeComponents
+ Three blocks with shape ``(n_cells, n_modes)``. Reconstruction
+ is ``gradient + curl + harmonic``.
+
+ Raises
+ ------
+ TypeError
+ If ``spectrum`` is not a :class:`KoopmanSpectrum`.
+ ValueError
+ If ``num_nodes``, ``edge_index``, ``degree``, or eigenvector
+ shape is invalid.
+
+ Notes
+ -----
+ Face-based :math:`k=1` curl (:math:`\\operatorname{im} B_2`) is
+ not implemented. Isolated-node graphs have :math:`L_0=0`, so every
+ 0-cochain is harmonic. On a consistently oriented cycle the
+ constant 1-cochain spans :math:`\\ker L_1`. When
+ ``num_nodes == num_edges`` and ``degree`` is omitted, inference
+ returns ``0``.
+
+ References
+ ----------
+ Lim, L.-H. (2020). Hodge Laplacians on graphs. *SIAM Review*
+ 62:685–715. doi:10.1137/18M1223101 (``Lim2020Hodge``).
+ Hajij, M. et al. (2024). TopoX: a suite of Python packages for
+ machine learning on topological domains. arXiv:2402.02441
+ (``TopoX2024``).
+ """
+ if not isinstance(spectrum, KoopmanSpectrum):
+ msg = f"spectrum must be a KoopmanSpectrum, got {type(spectrum).__name__}"
+ raise TypeError(msg)
+ if isinstance(num_nodes, bool) or not isinstance(num_nodes, int):
+ raise ValueError(f"num_nodes must be a positive int, got {num_nodes!r}")
+ if int(num_nodes) < 1:
+ raise ValueError(f"num_nodes must be >= 1, got {num_nodes}")
+ vectors = spectrum.eigenvectors
+ if vectors.ndim != 2 or int(vectors.shape[0]) < 1 or int(vectors.shape[1]) < 1:
+ msg = (
+ "spectrum.eigenvectors must have shape (n_cells, n_modes) "
+ f"with n_cells >= 1, got {tuple(vectors.shape)}"
+ )
+ raise ValueError(msg)
+ if not bool(torch.isfinite(vectors).all().item()):
+ raise ValueError("spectrum.eigenvectors must be finite")
+ incidence = boundary_incidence_b1(edge_index, num_nodes=int(num_nodes))
+ resolved = _resolve_degree(
+ int(vectors.shape[0]),
+ num_nodes=int(num_nodes),
+ num_edges=int(incidence.shape[1]),
+ degree=degree,
+ )
+ working = vectors.detach().to(dtype=torch.complex128)
+ real = working.real.to(dtype=torch.float64)
+ imag = working.imag.to(dtype=torch.float64)
+ incidence64 = incidence.to(dtype=torch.float64, device=working.device)
+ if resolved == 0:
+ gradient_real = torch.zeros_like(real)
+ curl_real = _project_image(incidence64, real)
+ harmonic_real = real - curl_real
+ gradient_imag = torch.zeros_like(imag)
+ curl_imag = _project_image(incidence64, imag)
+ harmonic_imag = imag - curl_imag
+ else:
+ coboundary = incidence64.transpose(0, 1)
+ gradient_real = _project_image(coboundary, real)
+ curl_real = torch.zeros_like(real)
+ harmonic_real = real - gradient_real
+ gradient_imag = _project_image(coboundary, imag)
+ curl_imag = torch.zeros_like(imag)
+ harmonic_imag = imag - gradient_imag
+ if not vectors.is_complex():
+ return HodgeModeComponents(
+ gradient=gradient_real.to(dtype=vectors.dtype),
+ curl=curl_real.to(dtype=vectors.dtype),
+ harmonic=harmonic_real.to(dtype=vectors.dtype),
+ )
+ return HodgeModeComponents(
+ gradient=torch.complex(gradient_real, gradient_imag).to(dtype=vectors.dtype),
+ curl=torch.complex(curl_real, curl_imag).to(dtype=vectors.dtype),
+ harmonic=torch.complex(harmonic_real, harmonic_imag).to(dtype=vectors.dtype),
+ )
diff --git a/src/koopman_graph/analysis/memory.py b/src/koopman_graph/analysis/memory.py
new file mode 100644
index 0000000..eb470ae
--- /dev/null
+++ b/src/koopman_graph/analysis/memory.py
@@ -0,0 +1,493 @@
+"""Innovation-whiteness diagnostic and a finite-memory convolution MVP.
+
+:func:`markov_closure_report` scores residual autocorrelation with a
+Ljung–Box-style portmanteau (``Ljung1978Box``). It is a diagnostic on
+already-formed innovations, not Mori–Zwanzig projection-operator
+identification (``Lin2021MoriZwanzig``).
+
+:class:`FiniteMemoryKoopman` applies the convolution
+
+.. math::
+
+ z_{t+1} = z_t \\Omega^{\\top} + \\sum_{s=1}^{M} z_{t-s} K_s^{\\top}.
+
+That map is **not** a factory kind, **not**
+:class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder` (Takens channel
+stacking), and **not** :class:`~koopman_graph.baselines.HAVOKBaseline`
+(delay-plus-forcing; ``Brunton2017HAVOK``). Recovered memory length is
+an oracle test, not a general theorem.
+
+This module must not import :mod:`koopman_graph.model`.
+"""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass
+
+import torch
+from torch import Tensor, nn
+
+DEFAULT_MAX_LAG = 10
+DEFAULT_ALPHA = 0.05
+DEFAULT_MEMORY_ORDER = 1
+LAG_UNIT = "timestep"
+
+__all__ = [
+ "DEFAULT_ALPHA",
+ "DEFAULT_MAX_LAG",
+ "DEFAULT_MEMORY_ORDER",
+ "FiniteMemoryKoopman",
+ "LAG_UNIT",
+ "MarkovClosureReport",
+ "markov_closure_report",
+]
+
+
+@dataclass(frozen=True)
+class MarkovClosureReport:
+ """Ljung–Box-style whiteness flags on a residual-energy series.
+
+ Attributes
+ ----------
+ autocorrelation : Tensor
+ Sample autocorrelation at lags ``1, …, max_lag``. Dimensionless.
+ Shape ``(max_lag,)``.
+ ljung_box_statistic : float
+ Portmanteau statistic :math:`Q` (dimensionless).
+ ljung_box_pvalue : float
+ Survival function of :math:`\\chi^2` with
+ ``max_lag - n_fit_parameters`` degrees of freedom.
+ max_abs_autocorrelation : float
+ Maximum absolute sample autocorrelation over those lags.
+ max_lag : int
+ Highest lag included in :math:`Q`.
+ n_timesteps : int
+ Length of the residual-energy series.
+ n_fit_parameters : int
+ Degrees of freedom subtracted for a previously fitted Markov
+ map. ``0`` treats the series as given.
+ alpha : float
+ Whiteness test size in ``(0, 1)``.
+ white : bool
+ ``True`` when ``ljung_box_pvalue >= alpha`` (fail to reject
+ residual whiteness).
+ lag_unit : str
+ Always ``"timestep"``. Lags are steps in the residual series,
+ not a physical time unit.
+ """
+
+ autocorrelation: Tensor
+ ljung_box_statistic: float
+ ljung_box_pvalue: float
+ max_abs_autocorrelation: float
+ max_lag: int
+ n_timesteps: int
+ n_fit_parameters: int
+ alpha: float
+ white: bool
+ lag_unit: str = LAG_UNIT
+
+ def __post_init__(self) -> None:
+ """Validate report fields.
+
+ Raises
+ ------
+ ValueError
+ If shapes, ranges, or the lag unit are inconsistent.
+ """
+ acf = self.autocorrelation
+ if acf.ndim != 1 or int(acf.numel()) != int(self.max_lag):
+ msg = (
+ "autocorrelation must have shape (max_lag,), "
+ f"got {tuple(acf.shape)} for max_lag={self.max_lag}"
+ )
+ raise ValueError(msg)
+ if int(self.max_lag) < 1:
+ raise ValueError(f"max_lag must be >= 1, got {self.max_lag}")
+ if int(self.n_timesteps) <= int(self.max_lag):
+ msg = (
+ "n_timesteps must exceed max_lag, "
+ f"got n={self.n_timesteps} and max_lag={self.max_lag}"
+ )
+ raise ValueError(msg)
+ if int(self.n_fit_parameters) < 0:
+ msg = f"n_fit_parameters must be >= 0, got {self.n_fit_parameters}"
+ raise ValueError(msg)
+ degrees = int(self.max_lag) - int(self.n_fit_parameters)
+ if degrees < 1:
+ msg = (
+ "max_lag - n_fit_parameters must be >= 1, "
+ f"got {self.max_lag} - {self.n_fit_parameters}"
+ )
+ raise ValueError(msg)
+ if not 0.0 < float(self.alpha) < 1.0:
+ raise ValueError(f"alpha must lie in (0, 1), got {self.alpha!r}")
+ if self.lag_unit != LAG_UNIT:
+ msg = f"lag_unit must be {LAG_UNIT!r}, got {self.lag_unit!r}"
+ raise ValueError(msg)
+ if not math.isfinite(self.ljung_box_statistic):
+ raise ValueError("ljung_box_statistic must be finite")
+ if not (0.0 <= float(self.ljung_box_pvalue) <= 1.0):
+ msg = f"ljung_box_pvalue must lie in [0, 1], got {self.ljung_box_pvalue!r}"
+ raise ValueError(msg)
+ if not math.isfinite(self.max_abs_autocorrelation):
+ raise ValueError("max_abs_autocorrelation must be finite")
+
+
+def _residual_energy_series(innovations: Tensor) -> Tensor:
+ """Reduce innovations to a real 1-D residual-energy series.
+
+ Parameters
+ ----------
+ innovations : Tensor
+ Finite real residual tensor. Leading axis is time.
+
+ Returns
+ -------
+ Tensor
+ Shape ``(T,)`` Euclidean norms over trailing axes. A 1-D input
+ is returned as a contiguous copy.
+
+ Raises
+ ------
+ TypeError
+ If ``innovations`` is not a tensor.
+ ValueError
+ If the tensor is empty, non-floating, non-finite, or complex.
+ """
+ if not isinstance(innovations, Tensor):
+ msg = f"innovations must be a Tensor, got {type(innovations).__name__}"
+ raise TypeError(msg)
+ if innovations.is_complex():
+ raise ValueError("innovations must be real")
+ if not innovations.is_floating_point():
+ raise ValueError("innovations must be a floating-point tensor")
+ if int(innovations.ndim) < 1 or int(innovations.shape[0]) == 0:
+ raise ValueError("innovations must be a nonempty time series")
+ if not bool(torch.isfinite(innovations).all().item()):
+ raise ValueError("innovations must be finite")
+ if int(innovations.ndim) == 1:
+ return innovations.detach().to(dtype=torch.float64).contiguous()
+ flat = innovations.detach().reshape(int(innovations.shape[0]), -1)
+ return flat.to(dtype=torch.float64).norm(dim=-1)
+
+
+def _sample_autocorrelation(series: Tensor, max_lag: int) -> Tensor:
+ """Sample autocorrelation at lags ``1, …, max_lag``.
+
+ Parameters
+ ----------
+ series : Tensor
+ 1-D finite series of length ``n > max_lag``.
+ max_lag : int
+ Highest lag.
+
+ Returns
+ -------
+ Tensor
+ Shape ``(max_lag,)``, dimensionless.
+
+ Raises
+ ------
+ ValueError
+ If the series has zero variance.
+ """
+ centered = series - series.mean()
+ denom = torch.dot(centered, centered)
+ if float(denom.item()) <= 0.0 or not math.isfinite(float(denom.item())):
+ raise ValueError("innovations must have positive residual energy")
+ rows = [
+ torch.dot(centered[lag:], centered[:-lag]) / denom
+ for lag in range(1, max_lag + 1)
+ ]
+ return torch.stack(rows)
+
+
+def _resolve_max_lag(n_timesteps: int, max_lag: int | None) -> int:
+ """Choose a portmanteau lag that leaves degrees of freedom.
+
+ Parameters
+ ----------
+ n_timesteps : int
+ Residual-series length.
+ max_lag : int or None
+ Caller lag, or ``None`` for ``min(10, (n-1)//5)``.
+
+ Returns
+ -------
+ int
+ Accepted ``max_lag >= 1``.
+
+ Raises
+ ------
+ ValueError
+ If the series is too short or ``max_lag`` is invalid.
+ """
+ if max_lag is None:
+ resolved = min(DEFAULT_MAX_LAG, (int(n_timesteps) - 1) // 5)
+ else:
+ if isinstance(max_lag, bool) or not isinstance(max_lag, int):
+ raise ValueError(f"max_lag must be a positive int, got {max_lag!r}")
+ resolved = int(max_lag)
+ if resolved < 1:
+ msg = (
+ "need enough timesteps for at least one lag "
+ f"(n={n_timesteps}, max_lag={max_lag!r})"
+ )
+ raise ValueError(msg)
+ if int(n_timesteps) <= resolved:
+ msg = (
+ "n_timesteps must exceed max_lag, "
+ f"got n={n_timesteps} and max_lag={resolved}"
+ )
+ raise ValueError(msg)
+ return resolved
+
+
+def markov_closure_report(
+ innovations: Tensor,
+ *,
+ max_lag: int | None = None,
+ alpha: float = DEFAULT_ALPHA,
+ n_fit_parameters: int = 0,
+) -> MarkovClosureReport:
+ """Score residual autocorrelation with a Ljung–Box-style flag.
+
+ The input is reduced to residual energy :math:`\\|\\eta_t\\|_2` when
+ it has trailing axes. That is a **scalar** portmanteau on pooled
+ energy, not a matrix-variate (Hosking) test and not Mori–Zwanzig
+ identification (``Lin2021MoriZwanzig``).
+
+ Parameters
+ ----------
+ innovations : Tensor
+ Finite real residuals. Leading axis is time (timesteps, not a
+ physical unit unless the caller sampled uniformly).
+ max_lag : int or None, optional
+ Highest lag. Default is ``min(10, (T-1)//5)``.
+ alpha : float, optional
+ Test size in ``(0, 1)``. Default is ``0.05``.
+ n_fit_parameters : int, optional
+ Degrees of freedom already spent on a fitted Markov map.
+ Default ``0`` treats ``innovations`` as given.
+
+ Returns
+ -------
+ MarkovClosureReport
+ Autocorrelation, :math:`Q`, :math:`p`-value, and ``white``.
+
+ Raises
+ ------
+ TypeError
+ If ``innovations`` is not a tensor.
+ ValueError
+ If the series is empty, non-finite, degenerate, or the lag /
+ alpha / degrees-of-freedom arguments are invalid.
+
+ Notes
+ -----
+ :math:`Q = n(n+2)\\sum_{k=1}^{h}\\rho_k^2/(n-k)` is compared to
+ :math:`\\chi^2(h-p)` (``Ljung1978Box``). ``white`` is a hypothesis
+ flag, not a certificate that the latent map is Markov.
+
+ References
+ ----------
+ Ljung, G. M. and Box, G. E. P. (1978). On a measure of lack of fit
+ in time series models. *Biometrika* 65:297–303.
+ doi:10.1093/biomet/65.2.297 (``Ljung1978Box``).
+ Lin, Y. T., Tian, Y., Anghel, M. and Livescu, D. (2021).
+ Data-driven learning for the Mori–Zwanzig formalism: a
+ generalization of the Koopman learning framework. *SIAM Journal on
+ Applied Dynamical Systems* 20:2558–2601. doi:10.1137/21M1401759
+ (``Lin2021MoriZwanzig``).
+ """
+ series = _residual_energy_series(innovations)
+ n_timesteps = int(series.shape[0])
+ resolved_lag = _resolve_max_lag(n_timesteps, max_lag)
+ if isinstance(n_fit_parameters, bool) or not isinstance(n_fit_parameters, int):
+ msg = f"n_fit_parameters must be a non-negative int, got {n_fit_parameters!r}"
+ raise ValueError(msg)
+ if int(n_fit_parameters) < 0:
+ msg = f"n_fit_parameters must be >= 0, got {n_fit_parameters}"
+ raise ValueError(msg)
+ degrees = resolved_lag - int(n_fit_parameters)
+ if degrees < 1:
+ msg = (
+ "max_lag - n_fit_parameters must be >= 1, "
+ f"got {resolved_lag} - {n_fit_parameters}"
+ )
+ raise ValueError(msg)
+ if not 0.0 < float(alpha) < 1.0:
+ raise ValueError(f"alpha must lie in (0, 1), got {alpha!r}")
+ autocorrelation = _sample_autocorrelation(series, resolved_lag)
+ lags = torch.arange(
+ 1,
+ resolved_lag + 1,
+ dtype=autocorrelation.dtype,
+ device=autocorrelation.device,
+ )
+ lag_terms = autocorrelation.square() / (float(n_timesteps) - lags)
+ statistic = (
+ float(n_timesteps) * (float(n_timesteps) + 2.0) * float(lag_terms.sum().item())
+ )
+ try:
+ from scipy.stats import chi2
+ except ImportError as exc:
+ msg = (
+ "markov_closure_report requires SciPy. "
+ "Install with: pip install scipy or pip install "
+ "'koopman-graph[dev]' or 'koopman-graph[mpc]'"
+ )
+ raise ImportError(msg) from exc
+ pvalue = float(chi2.sf(statistic, degrees))
+ max_abs = float(autocorrelation.abs().max().item())
+ return MarkovClosureReport(
+ autocorrelation=autocorrelation,
+ ljung_box_statistic=statistic,
+ ljung_box_pvalue=pvalue,
+ max_abs_autocorrelation=max_abs,
+ max_lag=resolved_lag,
+ n_timesteps=n_timesteps,
+ n_fit_parameters=int(n_fit_parameters),
+ alpha=float(alpha),
+ white=pvalue >= float(alpha),
+ lag_unit=LAG_UNIT,
+ )
+
+
+class FiniteMemoryKoopman(nn.Module):
+ """Convolution :math:`z_{t+1}=z_t\\Omega^{\\top}+\\sum_s z_{t-s}K_s^{\\top}`.
+
+ Row-vector convention matches discrete operators
+ (:math:`z \\mapsto z K^{\\top}`). Default ``omega`` is
+ :math:`\\tfrac12 I` and default kernels are zero, so the map is
+ Markov until the caller writes memory taps. This is an MVP
+ parameterization, **not** Mori–Zwanzig identification
+ (``Lin2021MoriZwanzig``), **not** a factory kind, **not**
+ :class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder`, and **not**
+ :class:`~koopman_graph.baselines.HAVOKBaseline`
+ (``Brunton2017HAVOK``). Delay stacking changes encoder
+ ``in_channels``; this class keeps latent width ``d`` and stores
+ ``M`` dense taps.
+
+ Parameters
+ ----------
+ latent_dim : int
+ Latent width :math:`d`.
+ memory_order : int, optional
+ Number of memory taps :math:`M \\ge 1`. Default is 1.
+
+ Notes
+ -----
+ ``omega`` and ``kernels`` are dimensionless maps on latent
+ coordinates, the same convention as discrete ``K``. There is no
+ checkpoint key and no ``koopman="finite_memory"`` factory string.
+ Memory-order selection by held-out rollout is not implemented;
+ recovered length is an oracle test, not a theorem.
+
+ References
+ ----------
+ Lin, Y. T., Tian, Y., Anghel, M. and Livescu, D. (2021).
+ Data-driven learning for the Mori–Zwanzig formalism: a
+ generalization of the Koopman learning framework. *SIAM Journal on
+ Applied Dynamical Systems* 20:2558–2601. doi:10.1137/21M1401759
+ (``Lin2021MoriZwanzig``).
+ Brunton, S. L., Brunton, B. W., Proctor, J. L., Kaiser, E. and
+ Kutz, J. N. (2017). Chaos as an intermittently forced linear
+ system. *Nature Communications* 8:19. doi:10.1038/s41467-017-00030-8
+ (``Brunton2017HAVOK``).
+ """
+
+ def __init__(
+ self,
+ latent_dim: int,
+ memory_order: int = DEFAULT_MEMORY_ORDER,
+ ) -> None:
+ """Allocate the Markov map and memory taps.
+
+ Parameters
+ ----------
+ latent_dim : int
+ Latent width :math:`d`.
+ memory_order : int, optional
+ Number of taps :math:`M \\ge 1`. Default is 1.
+
+ Raises
+ ------
+ ValueError
+ If ``latent_dim`` or ``memory_order`` is invalid.
+ """
+ super().__init__()
+ if isinstance(latent_dim, bool) or not isinstance(latent_dim, int):
+ raise ValueError(f"latent_dim must be a positive int, got {latent_dim!r}")
+ if int(latent_dim) < 1:
+ raise ValueError(f"latent_dim must be >= 1, got {latent_dim}")
+ if isinstance(memory_order, bool) or not isinstance(memory_order, int):
+ msg = f"memory_order must be a positive int, got {memory_order!r}"
+ raise ValueError(msg)
+ if int(memory_order) < 1:
+ raise ValueError(f"memory_order must be >= 1, got {memory_order}")
+ width = int(latent_dim)
+ order = int(memory_order)
+ self.latent_dim = width
+ self.memory_order = order
+ self.omega = nn.Parameter(0.5 * torch.eye(width))
+ self.kernels = nn.Parameter(torch.zeros(order, width, width))
+
+ def advance(self, z: Tensor, history: Tensor) -> Tensor:
+ """Apply one convolution step.
+
+ Parameters
+ ----------
+ z : Tensor
+ Current latent with trailing width ``latent_dim``.
+ history : Tensor
+ Past latents with shape ``(memory_order, *z.shape)``.
+ ``history[0]`` is :math:`z_{t-1}` and ``history[s]`` is
+ :math:`z_{t-s-1}` (equivalently :math:`z_{t-s}` at
+ ``s`` in ``1..M`` uses ``history[s-1]``).
+
+ Returns
+ -------
+ Tensor
+ Next latent, same shape as ``z``.
+
+ Raises
+ ------
+ ValueError
+ If shapes do not match the allocated width and order.
+ """
+ if z.shape[-1] != self.latent_dim:
+ msg = (
+ "z trailing dimension must equal latent_dim "
+ f"{self.latent_dim}, got {tuple(z.shape)}"
+ )
+ raise ValueError(msg)
+ expected = (self.memory_order, *z.shape)
+ if tuple(history.shape) != expected:
+ msg = (
+ "history must have shape "
+ f"(memory_order, *z.shape)={expected}, got {tuple(history.shape)}"
+ )
+ raise ValueError(msg)
+ markov = z @ self.omega.transpose(-1, -2)
+ memory = torch.einsum("m...d,med->...e", history, self.kernels)
+ return markov + memory
+
+ def forward(self, z: Tensor, history: Tensor) -> Tensor:
+ """Alias of :meth:`advance`.
+
+ Parameters
+ ----------
+ z : Tensor
+ Current latent.
+ history : Tensor
+ Past latents ``(memory_order, *z.shape)``.
+
+ Returns
+ -------
+ Tensor
+ Next latent.
+ """
+ return self.advance(z, history)
diff --git a/src/koopman_graph/analysis/spectrum.py b/src/koopman_graph/analysis/spectrum.py
index ee19a9d..f9a11be 100644
--- a/src/koopman_graph/analysis/spectrum.py
+++ b/src/koopman_graph/analysis/spectrum.py
@@ -16,6 +16,7 @@
from koopman_graph.graph_utils.topology import resolve_edge_index, resolve_edge_weight
from koopman_graph.protocols import ModeShapeModel
from koopman_graph.spectrum_types import (
+ aliasing_warning_mask,
compute_generator_spectrum,
compute_spectrum,
discrete_spectrum_at_delta_t,
@@ -50,7 +51,9 @@ class ImpliedTimescales:
when ``ε < |λ| < 1 - ε``. Entries with ``|λ| ≥ 1 - ε`` (including the
stationary eigenvalue near 1) or ``|λ| ≤ ε`` are **invalid**: the
corresponding timescale is ``+∞`` and :attr:`valid` is ``False``. This
- is never a silent ``NaN``.
+ is never a silent ``NaN``. :attr:`aliasing_warning` flags modes with
+ argument near :math:`\\pi` (Nyquist phase) but does **not** change
+ :attr:`valid`; :math:`t=-\\tau/\\ln|\\lambda|` still ignores sign.
Timescales inherit the unit of ``τ``. With ``lag_steps`` alone,
``τ = lag_steps`` and :attr:`unit` is ``\"steps\"``. With an optional
@@ -70,7 +73,14 @@ class ImpliedTimescales:
eigenvalues; invalid entries are ``+∞``.
valid : Tensor
Boolean mask aligned with ``timescales`` (``True`` when the
- timescale is finite and meaningful).
+ timescale is finite and meaningful). Independent of
+ :attr:`aliasing_warning`.
+ aliasing_warning : Tensor
+ Boolean mask aligned with ``timescales``. ``True`` when
+ :math:`\\pi-|\\arg\\lambda|\\le` ``ALIASING_ARG_ATOL`` (radians).
+ Negative-real eigenvalues flag. Does not invalidate
+ :math:`\\lambda=-0.7` or other magnitudes inside the ``ε``
+ window.
magnitudes : Tensor
``|λ|`` used in the formula (real, non-negative).
lag_steps : int
@@ -88,6 +98,7 @@ class ImpliedTimescales:
timescales: Tensor
valid: Tensor
+ aliasing_warning: Tensor
magnitudes: Tensor
lag_steps: int
tau: float
@@ -109,6 +120,11 @@ def implied_timescales(
Uses :math:`t_i = -\\tau / \\ln|\\lambda_i|` with
:math:`\\tau =` ``lag_steps`` (unit ``\"steps\"``) or
``lag_steps * timestep`` when ``timestep`` is provided.
+ Nyquist-adjacent arguments set ``aliasing_warning`` using the same
+ criterion as discrete
+ :class:`~koopman_graph.spectrum_types.SpectralDiagnostics` (Nyquist
+ frequency there is in cycles per unit time). The flag does not
+ change the :math:`|\\lambda|` validity window.
Parameters
----------
@@ -130,7 +146,9 @@ def implied_timescales(
Returns
-------
ImpliedTimescales
- Timescales, validity mask, magnitudes, and unit metadata.
+ Timescales, validity mask, Nyquist-adjacent aliasing flags,
+ magnitudes, and unit metadata. Aliasing is a flag only; it does
+ not change the :math:`|\\lambda|` validity window.
Raises
------
@@ -183,6 +201,7 @@ def implied_timescales(
return ImpliedTimescales(
timescales=timescales,
valid=valid,
+ aliasing_warning=aliasing_warning_mask(eigenvalues),
magnitudes=magnitudes,
lag_steps=int(lag_steps),
tau=tau,
diff --git a/src/koopman_graph/baselines/__init__.py b/src/koopman_graph/baselines/__init__.py
index 7f0f624..240b410 100644
--- a/src/koopman_graph/baselines/__init__.py
+++ b/src/koopman_graph/baselines/__init__.py
@@ -16,6 +16,24 @@
:class:`~koopman_graph.baselines.EDMDBaseline` (polynomial / RBF / kernel
dictionaries; Nyström / random-feature kernel approximations; full
kernel centers are O(T^2)).
+``mpedmd``
+ :class:`~koopman_graph.baselines.MpEDMDBaseline` — measure-preserving
+ EDMD (Gram-weighted Procrustes polar factor; Colbrook 2023). Does
+ not obsolete Euclidean conditioning on general directed
+ :math:`K_{\\mathrm{eff}}`.
+``gedmd``
+ :class:`~koopman_graph.baselines.GEDMDBaseline` — generator EDMD from
+ supplied dictionary derivatives (Klus et al. 2020). Distinct from
+ derivative-mode SINDy. Irregular timestamps do not create
+ :math:`L`.
+``hankel_dmd``
+ :class:`~koopman_graph.baselines.HankelDMDBaseline` — Hankel-DMD on
+ delay-embedded flattened states (Arbabi and Mezić 2017). Not
+ :class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder`.
+``havok``
+ :class:`~koopman_graph.baselines.HAVOKBaseline` — teaching HAVOK
+ (Brunton et al. 2017) on the same delay rows. Not
+ :class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder`.
``fbdmd``
:class:`~koopman_graph.baselines.FBDMDBaseline` — forward–backward DMD
on flattened node states (topology-blind).
@@ -82,6 +100,7 @@
from koopman_graph.baselines.dmdc import DMDcBaseline
from koopman_graph.baselines.edmd import EDMDBaseline
from koopman_graph.baselines.fbdmd import FBDMDBaseline
+from koopman_graph.baselines.gedmd import GEDMDBaseline
from koopman_graph.baselines.gnn import (
AGCRNBaseline,
DCRNNBaseline,
@@ -92,6 +111,9 @@
STGODEBaseline,
)
from koopman_graph.baselines.graph_vamp import GraphVAMPBaseline
+from koopman_graph.baselines.hankel_dmd import HankelDMDBaseline
+from koopman_graph.baselines.havok import HAVOKBaseline
+from koopman_graph.baselines.mpedmd import MpEDMDBaseline
from koopman_graph.baselines.mrdmd import MRDMDBaseline
from koopman_graph.baselines.optdmd import OptDMDBaseline
from koopman_graph.baselines.streaming_dmd import StreamingDMDBaseline
@@ -107,11 +129,15 @@
"DMDcBaseline",
"EDMDBaseline",
"FBDMDBaseline",
+ "GEDMDBaseline",
"GraphCastBaseline",
"GraphVAMPBaseline",
"GraphWaveNetBaseline",
+ "HAVOKBaseline",
+ "HankelDMDBaseline",
"MRDMDBaseline",
"MTGNNBaseline",
+ "MpEDMDBaseline",
"OptDMDBaseline",
"STGCNBaseline",
"STGODEBaseline",
diff --git a/src/koopman_graph/baselines/edmd.py b/src/koopman_graph/baselines/edmd.py
index f3268bb..8e1f648 100644
--- a/src/koopman_graph/baselines/edmd.py
+++ b/src/koopman_graph/baselines/edmd.py
@@ -289,7 +289,7 @@ def fit(
resolved = resolve_sequence(sequence)
require_static_topology(resolved)
if resolved.num_timesteps < 2:
- msg = "EDMDBaseline.fit requires at least two snapshots"
+ msg = f"{type(self).__name__}.fit requires at least two snapshots"
raise ValueError(msg)
states = flatten_snapshots(resolved)
@@ -306,7 +306,7 @@ def fit(
observables = self._observables(states)
left = observables[:-1]
self.selected_rank = resolve_fit_rank(left, self.rank)
- self.K = fit_row_operator(left, observables[1:], self.selected_rank)
+ self.K = self._fit_observable_operator(left, observables[1:])
self.reconstruction_matrix = torch.linalg.lstsq(observables, states).solution.T
self.num_nodes = resolved.num_nodes
self.in_channels = resolved.in_channels
@@ -314,6 +314,23 @@ def fit(
self.observable_dim = observables.shape[1]
return self
+ def _fit_observable_operator(self, left: Tensor, right: Tensor) -> Tensor:
+ """Fit the row-convention map on consecutive dictionary snapshots.
+
+ Parameters
+ ----------
+ left : Tensor
+ Observables at time ``t`` with shape ``(num_pairs, m)``.
+ right : Tensor
+ Observables at time ``t + 1`` with the same shape as ``left``.
+
+ Returns
+ -------
+ Tensor
+ Operator ``K`` with shape ``(m, m)`` for ``psi_next = psi @ K.T``.
+ """
+ return fit_row_operator(left, right, self.selected_rank)
+
def predict(self, initial_graph: Data, steps: int) -> list[Data]:
"""Autoregressively predict future graph snapshots (Data-only).
diff --git a/src/koopman_graph/baselines/gedmd.py b/src/koopman_graph/baselines/gedmd.py
new file mode 100644
index 0000000..65d5408
--- /dev/null
+++ b/src/koopman_graph/baselines/gedmd.py
@@ -0,0 +1,377 @@
+"""Generator EDMD (gEDMD) from supplied dictionary derivatives.
+
+Klus et al. (2020) fit a finite-dimensional generator by least squares
+on dictionary samples and their time derivatives,
+:math:`\\dot\\psi \\approx \\psi L^{\\top}` in the package row convention.
+This baseline does **not** form :math:`L` from snapshot finite differences
+or from irregular timestamps alone. It is distinct from
+:func:`~koopman_graph.analysis.identify_sparse_dynamics` (SINDy / STLSQ
+on learned latents, including ``mode="derivative"``).
+
+References
+----------
+Klus, S., Nüske, F., Peitz, S., Niemann, J.-H. & Schütte, C. (2020).
+Data-driven approximation of the Koopman generator: Model reduction,
+system identification, and control. *Physica D: Nonlinear Phenomena*,
+406, 132416. https://doi.org/10.1016/j.physd.2020.132416
+(``Klus2020gEDMD``)
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import torch
+from torch import Tensor
+from torch_geometric.data import Data
+
+from koopman_graph.baselines.base import (
+ check_initial_graph,
+ copy_topology,
+ fit_row_operator,
+ flatten_snapshots,
+ require_static_topology,
+ resolve_fit_rank,
+)
+from koopman_graph.baselines.edmd import EDMDBaseline
+from koopman_graph.data import GraphSnapshotSequence, resolve_sequence
+from koopman_graph.spectrum_types import KoopmanSpectrum, compute_generator_spectrum
+
+__all__ = [
+ "GEDMDBaseline",
+]
+
+
+def _missing_derivative_message() -> str:
+ """Return the shared missing-derivative error.
+
+ Returns
+ -------
+ str
+ Actionable ``ValueError`` text.
+ """
+ return (
+ "GEDMDBaseline.fit requires generator-action data: set Data.dx_dt "
+ "on every snapshot (same shape as x) or pass derivatives= with shape "
+ "(T, state_dim) or (T, N, d). Finite differences of snapshots or "
+ "timestamps alone do not identify the generator."
+ )
+
+
+def polynomial_observable_derivatives(
+ states: Tensor,
+ state_derivatives: Tensor,
+ *,
+ polynomial_degree: int,
+) -> Tensor:
+ """Chain-rule the polynomial dictionary through state derivatives.
+
+ Degree 1 is the identity lift (``dψ = dx/dt``). Degree 2 appends
+ elementwise squares, so ``d(x²)/dt = 2 x ⊙ dx/dt``.
+
+ Parameters
+ ----------
+ states : Tensor
+ Flattened states with shape ``(num_samples, state_dim)``.
+ state_derivatives : Tensor
+ Matching ``dx/dt`` with the same shape.
+ polynomial_degree : int
+ ``1`` or ``2``.
+
+ Returns
+ -------
+ Tensor
+ Dictionary derivatives with shape ``(num_samples, observable_dim)``.
+
+ Raises
+ ------
+ ValueError
+ If shapes disagree or ``polynomial_degree`` is unsupported.
+ """
+ if states.shape != state_derivatives.shape:
+ msg = (
+ "states and state_derivatives must share shape, got "
+ f"{tuple(states.shape)} and {tuple(state_derivatives.shape)}"
+ )
+ raise ValueError(msg)
+ if polynomial_degree == 1:
+ return state_derivatives
+ if polynomial_degree == 2:
+ return torch.cat(
+ [state_derivatives, 2.0 * states * state_derivatives],
+ dim=-1,
+ )
+ msg = f"polynomial_degree must be 1 or 2, got {polynomial_degree}"
+ raise ValueError(msg)
+
+
+def _as_state_derivatives(
+ derivatives: Tensor,
+ *,
+ num_timesteps: int,
+ state_dim: int,
+ num_nodes: int,
+ in_channels: int,
+) -> Tensor:
+ """Validate and flatten a stacked derivative tensor.
+
+ Parameters
+ ----------
+ derivatives : Tensor
+ ``(T, state_dim)`` or ``(T, N, d)``.
+ num_timesteps, state_dim, num_nodes, in_channels : int
+ Fitted layout.
+
+ Returns
+ -------
+ Tensor
+ Flattened derivatives with shape ``(T, state_dim)``.
+
+ Raises
+ ------
+ ValueError
+ If rank or shape does not match the snapshot layout.
+ """
+ if derivatives.ndim == 2:
+ expected = (num_timesteps, state_dim)
+ if tuple(derivatives.shape) != expected:
+ msg = (
+ "derivatives must have shape (T, state_dim) "
+ f"{expected}, got {tuple(derivatives.shape)}"
+ )
+ raise ValueError(msg)
+ return derivatives
+ if derivatives.ndim == 3:
+ expected = (num_timesteps, num_nodes, in_channels)
+ if tuple(derivatives.shape) != expected:
+ msg = (
+ "derivatives must have shape (T, N, d) "
+ f"{expected}, got {tuple(derivatives.shape)}"
+ )
+ raise ValueError(msg)
+ return derivatives.reshape(num_timesteps, state_dim)
+ msg = (
+ "derivatives must have shape (T, state_dim) or (T, N, d), "
+ f"got ndim={derivatives.ndim} shape={tuple(derivatives.shape)}"
+ )
+ raise ValueError(msg)
+
+
+def resolve_state_derivatives(
+ sequence: GraphSnapshotSequence,
+ derivatives: Tensor | None,
+ *,
+ num_timesteps: int,
+ state_dim: int,
+ num_nodes: int,
+ in_channels: int,
+) -> Tensor:
+ """Collect ``dx/dt`` from ``derivatives=`` or per-snapshot ``Data.dx_dt``.
+
+ Parameters
+ ----------
+ sequence : GraphSnapshotSequence
+ Training snapshots.
+ derivatives : Tensor or None
+ Optional stacked override. When set, snapshot ``dx_dt`` is ignored.
+ num_timesteps, state_dim, num_nodes, in_channels : int
+ Fitted layout.
+
+ Returns
+ -------
+ Tensor
+ Flattened derivatives with shape ``(T, state_dim)``.
+
+ Raises
+ ------
+ ValueError
+ If derivatives are missing, incomplete, or the wrong shape.
+ Finite differences of snapshots are not substituted.
+ """
+ if derivatives is not None:
+ return _as_state_derivatives(
+ derivatives,
+ num_timesteps=num_timesteps,
+ state_dim=state_dim,
+ num_nodes=num_nodes,
+ in_channels=in_channels,
+ )
+
+ rows: list[Tensor] = []
+ for index, snapshot in enumerate(sequence):
+ if "dx_dt" not in snapshot or snapshot.dx_dt is None:
+ raise ValueError(_missing_derivative_message())
+ if snapshot.dx_dt.shape != snapshot.x.shape:
+ msg = (
+ f"snapshot {index} dx_dt shape {tuple(snapshot.dx_dt.shape)} "
+ f"does not match x shape {tuple(snapshot.x.shape)}"
+ )
+ raise ValueError(msg)
+ rows.append(snapshot.dx_dt.reshape(-1))
+ stacked = torch.stack(rows)
+ if tuple(stacked.shape) != (num_timesteps, state_dim):
+ msg = (
+ "stacked Data.dx_dt must have shape "
+ f"{(num_timesteps, state_dim)}, got {tuple(stacked.shape)}"
+ )
+ raise ValueError(msg)
+ return stacked
+
+
+class GEDMDBaseline(EDMDBaseline):
+ """Generator EDMD baseline from supplied dictionary derivatives.
+
+ Same polynomial lift and Data-only ``predict`` surface as
+ :class:`~koopman_graph.baselines.EDMDBaseline`, but ``K`` stores the
+ generator :math:`L` for :math:`\\dot\\psi \\approx \\psi L^{\\top}`
+ rather than a discrete one-step map. ``predict`` advances by
+ :math:`\\exp(L\\,\\Delta t)` with fitted ``time_step``. ``spectrum``
+ uses :func:`~koopman_graph.spectrum_types.compute_generator_spectrum`.
+ Training timestamps are unused: irregular :math:`\\Delta t` does not
+ create :math:`L`. RBF / kernel dictionaries raise at ``fit``. Distinct
+ from :func:`~koopman_graph.analysis.identify_sparse_dynamics`.
+
+ Notes
+ -----
+ Constructor arguments match
+ :class:`~koopman_graph.baselines.EDMDBaseline`. Only
+ ``dictionary="polynomial"`` is supported. Pass generator-action data
+ as ``Data.dx_dt`` (same shape as ``x``) or
+ ``fit(..., derivatives=)``.
+ """
+
+ def fit(
+ self,
+ sequence: GraphSnapshotSequence | Sequence[Data],
+ *,
+ derivatives: Tensor | None = None,
+ ) -> GEDMDBaseline:
+ """Fit the generator and linear reconstruction matrix.
+
+ Parameters
+ ----------
+ sequence : GraphSnapshotSequence or sequence of Data
+ Training snapshots with shared topology. Each snapshot must
+ carry ``dx_dt`` unless ``derivatives`` is passed.
+ derivatives : Tensor or None, optional
+ Optional stacked ``dx/dt`` with shape ``(T, state_dim)`` or
+ ``(T, N, d)``. When set, snapshot ``dx_dt`` is ignored.
+
+ Returns
+ -------
+ GEDMDBaseline
+ The fitted baseline (``self``) for sklearn-style chaining.
+
+ Raises
+ ------
+ ValueError
+ If the dictionary is not polynomial, derivatives are missing
+ or misshapen, topology is dynamic, or rank is invalid.
+ """
+ if self.dictionary != "polynomial":
+ msg = (
+ "GEDMDBaseline supports dictionary='polynomial' only, "
+ f"got {self.dictionary!r}. RBF/kernel Jacobians are not "
+ "implemented; use EDMDBaseline for those lifts."
+ )
+ raise ValueError(msg)
+
+ resolved = resolve_sequence(sequence)
+ require_static_topology(resolved)
+ if resolved.num_timesteps < 1:
+ msg = f"{type(self).__name__}.fit requires at least one snapshot"
+ raise ValueError(msg)
+
+ states = flatten_snapshots(resolved)
+ dx_dt = resolve_state_derivatives(
+ resolved,
+ derivatives,
+ num_timesteps=int(states.shape[0]),
+ state_dim=int(states.shape[1]),
+ num_nodes=int(resolved.num_nodes),
+ in_channels=int(resolved.in_channels),
+ ).to(dtype=states.dtype, device=states.device)
+
+ self._nystrom_whitener = None
+ self._rff_weight = None
+ self._rff_bias = None
+ self.centers = None
+ observables = self._observables(states)
+ d_observables = polynomial_observable_derivatives(
+ states,
+ dx_dt,
+ polynomial_degree=int(self.polynomial_degree),
+ )
+ self.selected_rank = resolve_fit_rank(observables, self.rank)
+ self.K = fit_row_operator(observables, d_observables, self.selected_rank)
+ self.reconstruction_matrix = torch.linalg.lstsq(observables, states).solution.T
+ self.num_nodes = resolved.num_nodes
+ self.in_channels = resolved.in_channels
+ self.state_dim = states.shape[1]
+ self.observable_dim = observables.shape[1]
+ return self
+
+ def predict(self, initial_graph: Data, steps: int) -> list[Data]:
+ """Advance snapshots by ``exp(L Δt)`` on the dictionary (Data-only).
+
+ Each step uses fitted ``time_step`` as :math:`\\Delta t`. Training
+ timestamps are not consulted. Uncontrolled peer call site shared
+ with :class:`~koopman_graph.baselines.EDMDBaseline`.
+
+ Parameters
+ ----------
+ initial_graph : Data
+ Initial graph snapshot. Its topology is copied to every prediction.
+ steps : int
+ Number of future snapshots to predict.
+
+ Returns
+ -------
+ list of Data
+ Predicted graph snapshots with the same node/feature shape as the
+ fitted training data.
+
+ Raises
+ ------
+ RuntimeError
+ If the baseline has not been fit.
+ ValueError
+ If ``steps < 1`` or graph metadata does not match the fit data.
+ """
+ operator = self._require_operator()
+ reconstruction = self._require_reconstruction_matrix()
+ num_nodes, in_channels = self._require_graph_metadata()
+ if steps < 1:
+ msg = f"steps must be >= 1, got {steps}"
+ raise ValueError(msg)
+ check_initial_graph(
+ initial_graph,
+ num_nodes=num_nodes,
+ in_channels=in_channels,
+ )
+
+ observable = self._observables(initial_graph.x.reshape(1, -1)).squeeze(0)
+ step = torch.linalg.matrix_exp(operator * self.time_step)
+ topology = copy_topology(initial_graph)
+ predictions: list[Data] = []
+ for _ in range(steps):
+ observable = observable @ step.T
+ state = observable @ reconstruction.T
+ x = state.reshape(num_nodes, in_channels)
+ predictions.append(Data(x=x, **topology))
+ return predictions
+
+ def spectrum(self) -> KoopmanSpectrum:
+ """Return the fitted generator spectrum.
+
+ Takes no kwargs. Uses
+ :func:`~koopman_graph.spectrum_types.compute_generator_spectrum`
+ (native continuous-time growth rates), not a discrete map of
+ :math:`L`.
+
+ Returns
+ -------
+ KoopmanSpectrum
+ Eigendecomposition of the fitted generator.
+ """
+ return compute_generator_spectrum(self._require_operator())
diff --git a/src/koopman_graph/baselines/gnn/leaderboard.py b/src/koopman_graph/baselines/gnn/leaderboard.py
index 3e3673a..e1223aa 100644
--- a/src/koopman_graph/baselines/gnn/leaderboard.py
+++ b/src/koopman_graph/baselines/gnn/leaderboard.py
@@ -4,6 +4,25 @@
LibCity / BasicTS-style split rather than a teaching simplification.
Teaching :class:`~koopman_graph.baselines.gnn.ForecasterProtocol` still
rejects empty deviations.
+
+References
+----------
+Wang, J., Jiang, J., Jiang, W., Li, C. & Zhao, W. X. (2021). LibCity:
+An open library for traffic prediction. In *Proceedings of the 29th
+International Conference on Advances in Geographic Information Systems*
+(pp. 145–148). https://doi.org/10.1145/3474717.3483923
+(``LibCity2021``)
+
+Shao, Z., Wang, F., Xu, Y., Wei, W., Yu, C., Zhang, Z., Yao, D.,
+Sun, T., Jin, G., Cao, X., Cong, G., Jensen, C. S. & Cheng, X. (2025).
+Exploring progress in multivariate time series forecasting:
+comprehensive benchmarking and heterogeneity analysis. *IEEE
+Transactions on Knowledge and Data Engineering*, 37(1), 291–305.
+https://doi.org/10.1109/TKDE.2024.3484454
+(``BasicTS2024``)
+
+Named split / horizon / metric objects follow those protocol papers.
+The adapters are **not** LibCity or BasicTS implementations.
"""
from __future__ import annotations
diff --git a/src/koopman_graph/baselines/hankel_dmd.py b/src/koopman_graph/baselines/hankel_dmd.py
new file mode 100644
index 0000000..fd249dd
--- /dev/null
+++ b/src/koopman_graph/baselines/hankel_dmd.py
@@ -0,0 +1,313 @@
+"""Hankel-DMD baseline on delay-embedded flattened snapshots.
+
+Arbabi and Mezić (2017) apply DMD to consecutive columns of a Hankel
+matrix of observables. This teaching baseline delay-embeds flattened
+graph states (topology-blind) and fits the package row-convention map
+``delay_{t+1} = delay_t @ K.T``. It is **not**
+:class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder` (Takens-style
+channel stacking around a GNN).
+
+References
+----------
+Arbabi, H. & Mezić, I. (2017). Ergodic theory, dynamic mode
+decomposition, and computation of spectral properties of the Koopman
+operator. *SIAM Journal on Applied Dynamical Systems*, 16(4),
+2096–2126. https://doi.org/10.1137/17M1125236
+(``Arbabi2017HankelDMD``)
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import torch
+from torch import Tensor
+from torch_geometric.data import Data
+
+from koopman_graph.baselines.base import (
+ ClassicalBaseline,
+ RankSpec,
+ check_initial_graph,
+ copy_topology,
+ fit_row_operator,
+ flatten_snapshots,
+ require_static_topology,
+ resolve_fit_rank,
+)
+from koopman_graph.data import GraphSnapshotSequence, resolve_sequence
+from koopman_graph.spectrum_types import KoopmanSpectrum, compute_spectrum
+
+__all__ = [
+ "HankelDMDBaseline",
+ "assemble_delay_state",
+ "delay_embed_rows",
+]
+
+
+def delay_embed_rows(states: Tensor, n_delays: int) -> Tensor:
+ """Stack consecutive flattened states into delay rows (oldest → newest).
+
+ Parameters
+ ----------
+ states : Tensor
+ Flattened snapshot matrix with shape ``(T, state_dim)``.
+ n_delays : int
+ Window length. Must be ``>= 1``.
+
+ Returns
+ -------
+ Tensor
+ Delay rows with shape ``(T - n_delays + 1, n_delays * state_dim)``.
+
+ Raises
+ ------
+ ValueError
+ If ``n_delays < 1``, ``states`` is not 2-D, or ``T < n_delays``.
+ """
+ if n_delays < 1:
+ msg = f"n_delays must be >= 1, got {n_delays}"
+ raise ValueError(msg)
+ if states.ndim != 2:
+ msg = f"states must be 2-D (T, state_dim), got shape {tuple(states.shape)}"
+ raise ValueError(msg)
+ num_timesteps, state_dim = int(states.shape[0]), int(states.shape[1])
+ if num_timesteps < n_delays:
+ msg = (
+ f"need at least n_delays={n_delays} snapshots to form a Hankel "
+ f"window, got T={num_timesteps}"
+ )
+ raise ValueError(msg)
+ windows = states.unfold(0, n_delays, 1).permute(0, 2, 1)
+ return windows.reshape(windows.shape[0], n_delays * state_dim)
+
+
+def assemble_delay_state(
+ initial_graph: Data,
+ *,
+ history: Sequence[Data] | None,
+ n_delays: int,
+ num_nodes: int,
+ in_channels: int,
+ state_dim: int,
+) -> Tensor:
+ """Build one delay vector from an initial snapshot and optional history.
+
+ Older delay slots are **zero-padded** when ``history`` is missing or
+ shorter than ``n_delays - 1``. Extra history snapshots are truncated
+ to the newest ``n_delays - 1`` frames (oldest → newest).
+
+ Parameters
+ ----------
+ initial_graph : Data
+ Newest physical snapshot (copied topology is not used here).
+ history : sequence of Data or None
+ Optional prior snapshots, oldest → newest.
+ n_delays : int
+ Window length.
+ num_nodes, in_channels, state_dim : int
+ Fitted layout.
+
+ Returns
+ -------
+ Tensor
+ Delay vector with shape ``(n_delays * state_dim,)``.
+
+ Raises
+ ------
+ ValueError
+ If a snapshot layout does not match the fit metadata.
+ """
+ check_initial_graph(
+ initial_graph,
+ num_nodes=num_nodes,
+ in_channels=in_channels,
+ )
+ blocks: list[Tensor] = []
+ needed = n_delays - 1
+ priors: list[Data] = []
+ if history:
+ priors = list(history)[-needed:] if needed > 0 else []
+ pad = needed - len(priors)
+ zero = torch.zeros(
+ state_dim,
+ dtype=initial_graph.x.dtype,
+ device=initial_graph.x.device,
+ )
+ for _ in range(max(pad, 0)):
+ blocks.append(zero)
+ for snapshot in priors:
+ check_initial_graph(
+ snapshot,
+ num_nodes=num_nodes,
+ in_channels=in_channels,
+ )
+ blocks.append(snapshot.x.reshape(-1))
+ blocks.append(initial_graph.x.reshape(-1))
+ return torch.cat(blocks, dim=0)
+
+
+class HankelDMDBaseline(ClassicalBaseline):
+ """Hankel-DMD on delay-embedded flattened graph states (teaching).
+
+ Distinct from :class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder`.
+ ``predict`` advances the delay vector with ``K`` and emits the newest
+ physical block as ``Data.x``. Without ``history``, older delays are
+ zero-padded (teaching-thin).
+
+ Parameters
+ ----------
+ time_step : float, optional
+ Physical duration of one snapshot transition. Default is ``1.0``.
+ rank : int or None or {"auto"}, optional
+ Truncated-SVD rank for the delay data matrix. Default is ``None``.
+ n_delays : int, optional
+ Hankel window length. Default is ``2``.
+ """
+
+ def __init__(
+ self,
+ *,
+ time_step: float = 1.0,
+ rank: RankSpec = None,
+ n_delays: int = 2,
+ ) -> None:
+ """Initialize Hankel-DMD hyperparameters.
+
+ Parameters
+ ----------
+ time_step : float, optional
+ Physical duration of one snapshot transition. Default is ``1.0``.
+ rank : int or None or {"auto"}, optional
+ Truncated-SVD rank for the delay data matrix. Default is ``None``.
+ n_delays : int, optional
+ Hankel window length. Default is ``2``.
+
+ Raises
+ ------
+ ValueError
+ If ``time_step`` is not positive, ``rank`` is invalid, or
+ ``n_delays < 1``.
+ """
+ super().__init__(time_step=time_step, rank=rank)
+ if n_delays < 1:
+ msg = f"n_delays must be >= 1, got {n_delays}"
+ raise ValueError(msg)
+ self.n_delays = int(n_delays)
+
+ def _is_fitted(self) -> bool:
+ """Return whether the delay-coordinate operator has been fit.
+
+ Returns
+ -------
+ bool
+ ``True`` when ``K`` is available.
+ """
+ return self.K is not None
+
+ def fit(
+ self,
+ sequence: GraphSnapshotSequence | Sequence[Data],
+ ) -> HankelDMDBaseline:
+ """Fit DMD on consecutive Hankel delay rows.
+
+ Parameters
+ ----------
+ sequence : GraphSnapshotSequence or sequence of Data
+ Training snapshots with shared topology.
+
+ Returns
+ -------
+ HankelDMDBaseline
+ The fitted baseline (``self``) for sklearn-style chaining.
+
+ Raises
+ ------
+ ValueError
+ If topology is dynamic, ``T < n_delays + 1``, or rank is invalid.
+ """
+ resolved = resolve_sequence(sequence)
+ require_static_topology(resolved)
+ min_steps = self.n_delays + 1
+ if resolved.num_timesteps < min_steps:
+ msg = (
+ f"{type(self).__name__}.fit requires at least "
+ f"n_delays+1={min_steps} snapshots, got {resolved.num_timesteps}"
+ )
+ raise ValueError(msg)
+
+ states = flatten_snapshots(resolved)
+ rows = delay_embed_rows(states, self.n_delays)
+ left = rows[:-1]
+ self.selected_rank = resolve_fit_rank(left, self.rank)
+ self.K = fit_row_operator(left, rows[1:], self.selected_rank)
+ self.num_nodes = resolved.num_nodes
+ self.in_channels = resolved.in_channels
+ self.state_dim = states.shape[1]
+ return self
+
+ def predict(
+ self,
+ initial_graph: Data,
+ steps: int,
+ *,
+ history: Sequence[Data] | None = None,
+ ) -> list[Data]:
+ """Advance delay coordinates and emit newest physical snapshots.
+
+ Optional ``history`` supplies older delay slots (oldest → newest).
+ Without it, older slots are zeros. Uncontrolled Data-only call site
+ remains ``predict(data, steps)``.
+
+ Parameters
+ ----------
+ initial_graph : Data
+ Newest physical snapshot. Topology is copied to predictions.
+ steps : int
+ Number of future snapshots to predict.
+ history : sequence of Data or None, optional
+ Prior snapshots, oldest → newest. Default is ``None`` (zero-pad).
+
+ Returns
+ -------
+ list of Data
+ Predicted physical snapshots.
+
+ Raises
+ ------
+ RuntimeError
+ If the baseline has not been fit.
+ ValueError
+ If ``steps < 1`` or snapshot layout does not match the fit.
+ """
+ operator = self._require_operator()
+ num_nodes, in_channels = self._require_graph_metadata()
+ if self.state_dim is None:
+ raise RuntimeError(self._unfitted_message())
+ if steps < 1:
+ msg = f"steps must be >= 1, got {steps}"
+ raise ValueError(msg)
+ delay = assemble_delay_state(
+ initial_graph,
+ history=history,
+ n_delays=self.n_delays,
+ num_nodes=num_nodes,
+ in_channels=in_channels,
+ state_dim=int(self.state_dim),
+ )
+ topology = copy_topology(initial_graph)
+ predictions: list[Data] = []
+ for _ in range(steps):
+ delay = delay @ operator.T
+ x = delay[-int(self.state_dim) :].reshape(num_nodes, in_channels)
+ predictions.append(Data(x=x, **topology))
+ return predictions
+
+ def spectrum(self) -> KoopmanSpectrum:
+ """Return the delay-coordinate operator spectrum.
+
+ Returns
+ -------
+ KoopmanSpectrum
+ Spectrum of the fitted Hankel-DMD matrix.
+ """
+ return compute_spectrum(self._require_operator(), self.time_step)
diff --git a/src/koopman_graph/baselines/havok.py b/src/koopman_graph/baselines/havok.py
new file mode 100644
index 0000000..82c575c
--- /dev/null
+++ b/src/koopman_graph/baselines/havok.py
@@ -0,0 +1,247 @@
+"""HAVOK teaching baseline on delay-embedded flattened snapshots.
+
+Brunton et al. (2017) form a Hankel matrix of a measurement, take an SVD,
+and fit a linear model on the leading time coordinates forced by the last
+retained mode. This teaching path uses the same vector delay rows as
+:class:`~koopman_graph.baselines.HankelDMDBaseline` (topology-blind). It is
+**not** :class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder` and **not**
+a Mori–Zwanzig memory kernel. Autonomous ``predict`` sets the forcing
+coordinate to zero.
+
+References
+----------
+Brunton, S. L., Brunton, B. W., Proctor, J. L., Kaiser, E. & Kutz, J. N.
+(2017). Chaos as an intermittently forced linear system. *Nature
+Communications*, 8, 19. https://doi.org/10.1038/s41467-017-00030-8
+(``Brunton2017HAVOK``)
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import torch
+from torch import Tensor
+from torch_geometric.data import Data
+
+from koopman_graph.baselines.base import (
+ ClassicalBaseline,
+ copy_topology,
+ flatten_snapshots,
+ require_static_topology,
+)
+from koopman_graph.baselines.hankel_dmd import assemble_delay_state, delay_embed_rows
+from koopman_graph.data import GraphSnapshotSequence, resolve_sequence
+from koopman_graph.spectrum_types import KoopmanSpectrum, compute_spectrum
+
+__all__ = [
+ "HAVOKBaseline",
+]
+
+
+class HAVOKBaseline(ClassicalBaseline):
+ """Hankel alternative view of Koopman (teaching-thin SVD + forced linear map).
+
+ Distinct from :class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder`.
+ ``K`` stores the autonomous factor :math:`A` on the first
+ ``havok_rank - 1`` time coordinates. ``predict`` without ``history``
+ zero-pads older delays and uses forcing :math:`u=0`.
+
+ Parameters
+ ----------
+ time_step : float, optional
+ Physical duration of one snapshot transition. Default is ``1.0``.
+ n_delays : int, optional
+ Hankel window length. Default is ``2``.
+ havok_rank : int, optional
+ SVD truncation ``r >= 2`` (last mode is forcing). Default is ``3``.
+ Must not exceed ``min(num_windows, n_delays * state_dim)`` at fit.
+ """
+
+ def __init__(
+ self,
+ *,
+ time_step: float = 1.0,
+ n_delays: int = 2,
+ havok_rank: int = 3,
+ ) -> None:
+ """Initialize HAVOK hyperparameters.
+
+ Parameters
+ ----------
+ time_step : float, optional
+ Physical duration of one snapshot transition. Default is ``1.0``.
+ n_delays : int, optional
+ Hankel window length. Default is ``2``.
+ havok_rank : int, optional
+ SVD truncation ``r >= 2``. Default is ``3``.
+
+ Raises
+ ------
+ ValueError
+ If ``time_step`` is not positive, ``n_delays < 1``, or
+ ``havok_rank < 2``.
+ """
+ super().__init__(time_step=time_step, rank=None)
+ if n_delays < 1:
+ msg = f"n_delays must be >= 1, got {n_delays}"
+ raise ValueError(msg)
+ if havok_rank < 2:
+ msg = f"havok_rank must be >= 2, got {havok_rank}"
+ raise ValueError(msg)
+ self.n_delays = int(n_delays)
+ self.havok_rank = int(havok_rank)
+ self.B: Tensor | None = None
+ self.singular_values: Tensor | None = None
+ self._spatial_modes: Tensor | None = None
+
+ def _is_fitted(self) -> bool:
+ """Return whether the HAVOK linear factors have been fit.
+
+ Returns
+ -------
+ bool
+ ``True`` when ``K`` (the autonomous ``A``) is available.
+ """
+ return self.K is not None and self._spatial_modes is not None
+
+ def fit(
+ self,
+ sequence: GraphSnapshotSequence | Sequence[Data],
+ ) -> HAVOKBaseline:
+ """Fit SVD time coordinates and a forced linear map.
+
+ Parameters
+ ----------
+ sequence : GraphSnapshotSequence or sequence of Data
+ Training snapshots with shared topology.
+
+ Returns
+ -------
+ HAVOKBaseline
+ The fitted baseline (``self``) for sklearn-style chaining.
+
+ Raises
+ ------
+ ValueError
+ If topology is dynamic, the Hankel is too small for
+ ``havok_rank``, or ``T < n_delays + 1``.
+ """
+ resolved = resolve_sequence(sequence)
+ require_static_topology(resolved)
+ min_steps = self.n_delays + 1
+ if resolved.num_timesteps < min_steps:
+ msg = (
+ f"{type(self).__name__}.fit requires at least "
+ f"n_delays+1={min_steps} snapshots, got {resolved.num_timesteps}"
+ )
+ raise ValueError(msg)
+
+ states = flatten_snapshots(resolved)
+ rows = delay_embed_rows(states, self.n_delays)
+ hankel = rows.T.contiguous()
+ max_rank = int(min(hankel.shape))
+ if self.havok_rank > max_rank:
+ msg = (
+ f"havok_rank={self.havok_rank} exceeds Hankel rank bound "
+ f"{max_rank} (shape {tuple(hankel.shape)}); increase "
+ "n_delays or T, or reduce havok_rank"
+ )
+ raise ValueError(msg)
+
+ spatial, singular_values, time_factors = torch.linalg.svd(
+ hankel,
+ full_matrices=False,
+ )
+ rank = self.havok_rank
+ modes = spatial[:, :rank] * singular_values[:rank].unsqueeze(0)
+ coords = time_factors[:rank, :].T.contiguous()
+ state_coords = coords[:-1, :-1]
+ forcing = coords[:-1, -1:]
+ targets = coords[1:, :-1]
+ augmented = torch.cat([state_coords, forcing], dim=1)
+ solution = torch.linalg.lstsq(augmented, targets).solution
+ self.K = solution[:-1, :].T.contiguous()
+ self.B = solution[-1:, :].T.contiguous()
+ self._spatial_modes = modes
+ self.singular_values = singular_values[:rank].clone()
+ self.selected_rank = rank
+ self.num_nodes = resolved.num_nodes
+ self.in_channels = resolved.in_channels
+ self.state_dim = states.shape[1]
+ return self
+
+ def predict(
+ self,
+ initial_graph: Data,
+ steps: int,
+ *,
+ history: Sequence[Data] | None = None,
+ ) -> list[Data]:
+ """Advance HAVOK coordinates with forcing ``u=0`` and decode delays.
+
+ Optional ``history`` supplies older delay slots (oldest → newest).
+ Without it, older slots are zeros.
+
+ Parameters
+ ----------
+ initial_graph : Data
+ Newest physical snapshot. Topology is copied to predictions.
+ steps : int
+ Number of future snapshots to predict.
+ history : sequence of Data or None, optional
+ Prior snapshots, oldest → newest. Default is ``None`` (zero-pad).
+
+ Returns
+ -------
+ list of Data
+ Predicted physical snapshots.
+
+ Raises
+ ------
+ RuntimeError
+ If the baseline has not been fit.
+ ValueError
+ If ``steps < 1`` or snapshot layout does not match the fit.
+ """
+ operator = self._require_operator()
+ if self._spatial_modes is None or self.state_dim is None:
+ raise RuntimeError(self._unfitted_message())
+ num_nodes, in_channels = self._require_graph_metadata()
+ if steps < 1:
+ msg = f"steps must be >= 1, got {steps}"
+ raise ValueError(msg)
+ delay = assemble_delay_state(
+ initial_graph,
+ history=history,
+ n_delays=self.n_delays,
+ num_nodes=num_nodes,
+ in_channels=in_channels,
+ state_dim=int(self.state_dim),
+ )
+ coords = torch.linalg.lstsq(self._spatial_modes, delay.unsqueeze(1)).solution
+ state = coords[:-1, 0]
+ topology = copy_topology(initial_graph)
+ predictions: list[Data] = []
+ zero_force = torch.zeros(
+ 1,
+ dtype=state.dtype,
+ device=state.device,
+ )
+ for _ in range(steps):
+ state = state @ operator.T
+ full = torch.cat([state, zero_force], dim=0)
+ delay = self._spatial_modes @ full
+ x = delay[-int(self.state_dim) :].reshape(num_nodes, in_channels)
+ predictions.append(Data(x=x, **topology))
+ return predictions
+
+ def spectrum(self) -> KoopmanSpectrum:
+ """Return the spectrum of the autonomous HAVOK factor ``A``.
+
+ Returns
+ -------
+ KoopmanSpectrum
+ Spectrum of ``K`` (the ``r-1`` linear block, not the forcing).
+ """
+ return compute_spectrum(self._require_operator(), self.time_step)
diff --git a/src/koopman_graph/baselines/mpedmd.py b/src/koopman_graph/baselines/mpedmd.py
new file mode 100644
index 0000000..00b0861
--- /dev/null
+++ b/src/koopman_graph/baselines/mpedmd.py
@@ -0,0 +1,174 @@
+"""Measure-preserving EDMD (mpEDMD) via a Gram-weighted Procrustes polar factor.
+
+Colbrook (2023) replaces the unconstrained EDMD map with the orthogonal
+Procrustes solution in the dictionary Gram inner product, so the fitted
+matrix is similar to a unitary matrix. Convergence claims apply to
+measure-preserving systems in that Gram geometry. This baseline does
+**not** certify Euclidean spectral conditioning of a general directed
+:math:`K_{\\mathrm{eff}}`.
+
+References
+----------
+Colbrook, M. J. (2023). The mpEDMD algorithm for data-driven computations
+of measure-preserving dynamical systems. *SIAM Journal on Numerical
+Analysis*, 61(3), 1585–1608. https://doi.org/10.1137/22M1521407
+(``Colbrook2023mpEDMD``)
+"""
+
+from __future__ import annotations
+
+import torch
+from torch import Tensor
+
+from koopman_graph.analysis._galerkin import assemble_galerkin_grams
+from koopman_graph.baselines.edmd import EDMDBaseline
+
+__all__ = [
+ "MpEDMDBaseline",
+ "fit_mpedmd_row_operator",
+]
+
+_GRAM_EIG_RTOL = 1e-10
+
+
+def _hermitian_sqrt_factors(
+ gram: Tensor,
+ *,
+ rank: int | None,
+) -> tuple[Tensor, Tensor]:
+ """Return truncated Hermitian square-root and pseudoinverse square-root.
+
+ Parameters
+ ----------
+ gram : Tensor
+ Hermitian Gram ``G`` with shape ``(m, m)``.
+ rank : int or None
+ If set, keep the largest ``rank`` eigenpairs that pass the relative
+ cutoff. ``None`` keeps every eigenpair above the cutoff.
+
+ Returns
+ -------
+ sqrt, inv_sqrt : Tensor
+ ``G^{1/2}`` and ``G^{+/2}`` on the retained subspace.
+
+ Raises
+ ------
+ ValueError
+ If no eigenvalue exceeds the relative cutoff.
+ """
+ eigenvalues, eigenvectors = torch.linalg.eigh(gram)
+ eigenvalues = eigenvalues.real.clamp(min=0.0)
+ peak = float(eigenvalues.max().item())
+ cutoff = _GRAM_EIG_RTOL * max(peak, 1.0)
+ mask = eigenvalues > cutoff
+ if rank is not None:
+ keep = torch.zeros_like(mask)
+ n_keep = min(int(rank), int(eigenvalues.numel()))
+ if n_keep > 0:
+ keep[-n_keep:] = True
+ mask = mask & keep
+ if not bool(mask.any()):
+ msg = (
+ f"mpEDMD Gram square-root is empty (rank={rank!r}, max eigenvalue={peak!r})"
+ )
+ raise ValueError(msg)
+ sqrt_vals = torch.zeros_like(eigenvalues)
+ inv_sqrt_vals = torch.zeros_like(eigenvalues)
+ sqrt_vals[mask] = eigenvalues[mask].sqrt()
+ inv_sqrt_vals[mask] = eigenvalues[mask].rsqrt()
+ basis = eigenvectors.to(dtype=gram.dtype)
+ diag_dtype = basis.dtype
+ sqrt = basis @ torch.diag(sqrt_vals.to(dtype=diag_dtype)) @ basis.mH
+ inv_sqrt = basis @ torch.diag(inv_sqrt_vals.to(dtype=diag_dtype)) @ basis.mH
+ return sqrt, inv_sqrt
+
+
+def fit_mpedmd_row_operator(
+ left: Tensor,
+ right: Tensor,
+ rank: int | None,
+) -> Tensor:
+ """Fit a Gram-weighted mpEDMD operator in the package row convention.
+
+ Forms Galerkin Grams ``G=Ψ₀ᴴΨ₀`` and ``A=Ψ₀ᴴΨ₁`` (Colbrook
+ Algorithm 4.1 with ``W = I``), takes the polar factor of
+ ``G^{+/2} A G^{+/2}`` (equivalent to the paper's SVD of
+ ``G^{-1/2} A^{\\ast} G^{-1/2}``), and returns ``K`` so that
+ ``psi_next = psi @ K.T``. Eigenvalues of ``K`` lie on the unit
+ circle when ``G`` is full rank (``K`` is similar to a unitary
+ matrix). This is **not** a Euclidean certificate on a general
+ directed :math:`K_{\\mathrm{eff}}`.
+
+ Parameters
+ ----------
+ left : Tensor
+ Dictionary snapshots at time ``t`` with shape ``(num_pairs, m)``.
+ right : Tensor
+ Dictionary snapshots at time ``t + 1`` with the same shape.
+ rank : int or None
+ Optional Gram truncation (largest eigenpairs of ``G``). ``None``
+ keeps every eigenpair above a relative cutoff of ``1e-10``.
+
+ Returns
+ -------
+ Tensor
+ Row-convention mpEDMD matrix with shape ``(m, m)``.
+
+ Raises
+ ------
+ ValueError
+ If pair shapes are invalid or the truncated Gram is empty.
+
+ References
+ ----------
+ Colbrook, M. J. (2023). The mpEDMD algorithm for data-driven
+ computations of measure-preserving dynamical systems. *SIAM Journal
+ on Numerical Analysis*, 61(3), 1585–1608.
+ https://doi.org/10.1137/22M1521407 (``Colbrook2023mpEDMD``)
+ """
+ grams = assemble_galerkin_grams(left, right)
+ sqrt, inv_sqrt = _hermitian_sqrt_factors(grams.g00, rank=rank)
+ whitened = inv_sqrt @ grams.g01 @ inv_sqrt
+ left_factors, _, right_factors = torch.linalg.svd(whitened, full_matrices=False)
+ polar = left_factors @ right_factors
+ column_map = inv_sqrt @ polar @ sqrt
+ return column_map.mT.real.to(dtype=left.dtype)
+
+
+class MpEDMDBaseline(EDMDBaseline):
+ """Measure-preserving EDMD baseline (Gram-weighted Procrustes polar factor).
+
+ Same topology-blind dictionaries and Data-only ``predict`` surface as
+ :class:`~koopman_graph.baselines.EDMDBaseline`. After the lift, the
+ observable map is the Colbrook mpEDMD polar factor in the dictionary
+ Gram inner product rather than unconstrained least squares. Fitted
+ eigenvalues lie on the unit circle when the Gram is full rank.
+ Convergence claims apply to measure-preserving systems in that Gram
+ geometry — **not** a Euclidean spectral-conditioning certificate for
+ a general directed graph :math:`K_{\\mathrm{eff}}`.
+
+ Notes
+ -----
+ Constructor arguments match
+ :class:`~koopman_graph.baselines.EDMDBaseline` (``time_step``,
+ ``rank``, and the same dictionary knobs). Here ``rank`` truncates
+ dictionary Gram eigenpairs (Colbrook Algorithm 4.1), not the
+ snapshot SVD used by unconstrained EDMD least squares.
+ """
+
+ def _fit_observable_operator(self, left: Tensor, right: Tensor) -> Tensor:
+ """Fit the mpEDMD polar factor on consecutive dictionary snapshots.
+
+ Parameters
+ ----------
+ left : Tensor
+ Observables at time ``t`` with shape ``(num_pairs, m)``.
+ right : Tensor
+ Observables at time ``t + 1`` with the same shape as ``left``.
+
+ Returns
+ -------
+ Tensor
+ Operator ``K`` with shape ``(m, m)`` for ``psi_next = psi @ K.T``.
+ """
+ return fit_mpedmd_row_operator(left, right, rank=self.selected_rank)
diff --git a/src/koopman_graph/benchmark/__init__.py b/src/koopman_graph/benchmark/__init__.py
new file mode 100644
index 0000000..6993fb9
--- /dev/null
+++ b/src/koopman_graph/benchmark/__init__.py
@@ -0,0 +1,111 @@
+"""Protocol-locked experiment manifests and identity-bound summaries.
+
+Capability layout
+-----------------
+``schema``
+ Frozen :class:`~koopman_graph.benchmark.ExperimentManifest` and nested
+ specs (``benchmark_manifest_v1``).
+``io``
+ :func:`~koopman_graph.benchmark.load_manifest` /
+ :func:`~koopman_graph.benchmark.dump_manifest` (JSON always; YAML via
+ PyYAML / ``[cli]``) and
+ :func:`~koopman_graph.benchmark.verify_dataset_hash`.
+``runner``
+ :func:`~koopman_graph.benchmark.run_manifest` /
+ :func:`~koopman_graph.benchmark.verify_summary` write and check
+ canonical ``summary.json`` (schema ``benchmark_summary_v1``).
+
+Honesty
+-------
+This package is importable and **off** root ``__all__``. ``run_manifest``
+does not train models, download telemetry, or reproduce LibCity /
+BasicTS leaderboards. Teaching GNN methods require non-empty
+``deviations``. The optional ``[benchmark]`` extra is empty; YAML still
+needs PyYAML. The CLI ``benchmark run`` / ``verify`` commands are
+identity-bound (dataset SHA-256 plus a canonical summary digest).
+
+Import rules
+------------
+No other :mod:`koopman_graph` package may import this package at module
+load. :mod:`koopman_graph.cli` lazy-imports it inside ``benchmark``
+handlers. This package may import ``baselines``, ``datasets``,
+``metrics``, and ``model``; the schema/load path imports
+``datasets.download`` only for SHA-256 helpers. The identity-bound
+runner does not import ``model`` or ``training``.
+"""
+
+from koopman_graph.benchmark.io import dump_manifest, load_manifest, verify_dataset_hash
+from koopman_graph.benchmark.runner import (
+ SUMMARY_FILENAME,
+ SUMMARY_SCHEMA_VERSION,
+ BenchmarkSummary,
+ SummaryError,
+ SummaryMethodRef,
+ build_summary,
+ canonical_sha256,
+ dump_summary,
+ load_summary,
+ resolve_summary_path,
+ run_manifest,
+ summary_from_mapping,
+ summary_to_mapping,
+ verify_summary,
+)
+from koopman_graph.benchmark.schema import (
+ BENCHMARK_METRICS,
+ BENCHMARK_TRACKS,
+ CONTROL_TOKENS,
+ METHOD_ROLES,
+ MIN_MANIFEST_SEEDS,
+ SCHEMA_VERSION,
+ ComputeBudget,
+ DatasetRef,
+ EmptyMethodDeviationsError,
+ ExperimentManifest,
+ ManifestError,
+ MethodSpec,
+ OODShiftSpec,
+ PreprocessingSpec,
+ SplitSpec,
+ UQSpec,
+ manifest_from_mapping,
+ manifest_to_mapping,
+)
+
+__all__ = [
+ "BENCHMARK_METRICS",
+ "BENCHMARK_TRACKS",
+ "CONTROL_TOKENS",
+ "METHOD_ROLES",
+ "MIN_MANIFEST_SEEDS",
+ "SCHEMA_VERSION",
+ "SUMMARY_FILENAME",
+ "SUMMARY_SCHEMA_VERSION",
+ "BenchmarkSummary",
+ "ComputeBudget",
+ "DatasetRef",
+ "EmptyMethodDeviationsError",
+ "ExperimentManifest",
+ "ManifestError",
+ "MethodSpec",
+ "OODShiftSpec",
+ "PreprocessingSpec",
+ "SplitSpec",
+ "SummaryError",
+ "SummaryMethodRef",
+ "UQSpec",
+ "build_summary",
+ "canonical_sha256",
+ "dump_manifest",
+ "dump_summary",
+ "load_manifest",
+ "load_summary",
+ "manifest_from_mapping",
+ "manifest_to_mapping",
+ "resolve_summary_path",
+ "run_manifest",
+ "summary_from_mapping",
+ "summary_to_mapping",
+ "verify_dataset_hash",
+ "verify_summary",
+]
diff --git a/src/koopman_graph/benchmark/io.py b/src/koopman_graph/benchmark/io.py
new file mode 100644
index 0000000..a7d7a1c
--- /dev/null
+++ b/src/koopman_graph/benchmark/io.py
@@ -0,0 +1,185 @@
+"""Load and dump :class:`ExperimentManifest` documents.
+
+JSON is always available. YAML requires PyYAML
+(``pip install 'koopman-graph[cli]'``). Dataset SHA-256 checks use
+:func:`~koopman_graph.datasets.download.verify_sha256_bytes` and do
+**not** hash this manifest document.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+from koopman_graph.benchmark.schema import (
+ DatasetRef,
+ ExperimentManifest,
+ ManifestError,
+ manifest_from_mapping,
+ manifest_to_mapping,
+)
+from koopman_graph.datasets.download import verify_sha256, verify_sha256_bytes
+
+__all__ = [
+ "dump_manifest",
+ "load_manifest",
+ "verify_dataset_hash",
+]
+
+
+def _read_mapping(path: Path) -> dict[str, Any]:
+ """Parse a JSON or YAML file into a mapping.
+
+ Parameters
+ ----------
+ path : Path
+ Document path.
+
+ Returns
+ -------
+ dict
+ Top-level mapping.
+
+ Raises
+ ------
+ ManifestError
+ If the file is missing, the suffix is unsupported, or parse fails.
+ ImportError
+ If a YAML file is requested but PyYAML is not installed.
+ """
+ if not path.is_file():
+ msg = f"manifest file not found: {path}"
+ raise ManifestError(msg)
+ suffix = path.suffix.lower()
+ text = path.read_text(encoding="utf-8")
+ if suffix == ".json":
+ try:
+ payload = json.loads(text)
+ except json.JSONDecodeError as exc:
+ msg = f"Invalid JSON in {path}: {exc.msg}"
+ raise ManifestError(msg) from exc
+ elif suffix in {".yaml", ".yml"}:
+ try:
+ import yaml
+ except ImportError as exc:
+ msg = (
+ "YAML manifests require PyYAML. Install with: "
+ "pip install 'koopman-graph[cli]' (or: pip install 'pyyaml>=6')"
+ )
+ raise ImportError(msg) from exc
+ try:
+ payload = yaml.safe_load(text)
+ except yaml.YAMLError as exc:
+ msg = f"Invalid YAML in {path}: {exc}"
+ raise ManifestError(msg) from exc
+ else:
+ msg = (
+ f"Unsupported manifest suffix {suffix!r} for {path}; "
+ "use .json, .yaml, or .yml"
+ )
+ raise ManifestError(msg)
+ if not isinstance(payload, dict):
+ msg = f"manifest root must be a mapping, got {type(payload).__name__}"
+ raise ManifestError(msg)
+ return payload
+
+
+def load_manifest(path: str | Path) -> ExperimentManifest:
+ """Load and validate a manifest file.
+
+ Parameters
+ ----------
+ path : str or Path
+ ``.json``, ``.yaml``, or ``.yml`` path.
+
+ Returns
+ -------
+ ExperimentManifest
+ Frozen validated record.
+
+ Raises
+ ------
+ ManifestError
+ If the file or schema is invalid.
+ ImportError
+ If YAML is requested without PyYAML.
+ """
+ return manifest_from_mapping(_read_mapping(Path(path)))
+
+
+def dump_manifest(manifest: ExperimentManifest, path: str | Path) -> None:
+ """Write a manifest as JSON or YAML.
+
+ Parameters
+ ----------
+ manifest : ExperimentManifest
+ Validated record.
+ path : str or Path
+ Destination; suffix selects JSON vs YAML.
+
+ Raises
+ ------
+ ManifestError
+ If the suffix is unsupported.
+ ImportError
+ If YAML is requested without PyYAML.
+ """
+ destination = Path(path)
+ payload = manifest_to_mapping(manifest)
+ suffix = destination.suffix.lower()
+ if suffix == ".json":
+ destination.write_text(
+ json.dumps(payload, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ return
+ if suffix in {".yaml", ".yml"}:
+ try:
+ import yaml
+ except ImportError as exc:
+ msg = (
+ "YAML manifests require PyYAML. Install with: "
+ "pip install 'koopman-graph[cli]' (or: pip install 'pyyaml>=6')"
+ )
+ raise ImportError(msg) from exc
+ destination.write_text(
+ yaml.safe_dump(payload, sort_keys=False),
+ encoding="utf-8",
+ )
+ return
+ msg = (
+ f"Unsupported manifest suffix {suffix!r} for {destination}; "
+ "use .json, .yaml, or .yml"
+ )
+ raise ManifestError(msg)
+
+
+def verify_dataset_hash(
+ dataset: DatasetRef,
+ payload: bytes | str | Path,
+) -> None:
+ """Reject a SHA-256 mismatch against ``dataset.sha256``.
+
+ This checks dataset payload bytes, not the manifest document.
+
+ Parameters
+ ----------
+ dataset : DatasetRef
+ Declared digest and label.
+ payload : bytes or path-like
+ Raw bytes or a file to hash.
+
+ Raises
+ ------
+ ManifestError
+ If the digest does not match.
+ """
+ label = dataset.name
+ try:
+ if isinstance(payload, bytes):
+ verify_sha256_bytes(payload, dataset.sha256, label=label)
+ else:
+ verify_sha256(Path(payload), dataset.sha256)
+ except ValueError as exc:
+ raise ManifestError(str(exc)) from exc
diff --git a/src/koopman_graph/benchmark/runner.py b/src/koopman_graph/benchmark/runner.py
new file mode 100644
index 0000000..0d37bc7
--- /dev/null
+++ b/src/koopman_graph/benchmark/runner.py
@@ -0,0 +1,644 @@
+"""Identity-bound benchmark summaries (not a training loop).
+
+``run_manifest`` verifies the dataset payload digest, records protocol
+identity, and writes a canonical SHA-256. It does **not** fit
+:class:`~koopman_graph.model.GraphKoopmanModel` or GNN teaching ports
+and does not invent forecast metrics. Method execution is a later
+increment.
+
+``verify_summary`` recomputes that digest and binds the summary to the
+loaded manifest. Tampering a field or the stored hash fails.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass, replace
+from pathlib import Path
+from typing import Any, Literal
+
+from koopman_graph import __version__
+from koopman_graph.benchmark.io import load_manifest, verify_dataset_hash
+from koopman_graph.benchmark.schema import (
+ ExperimentManifest,
+ ManifestError,
+ _as_int_tuple,
+ _as_str_tuple,
+ _nonempty_str,
+ _reject_unknown,
+ _require_bool,
+ _require_mapping,
+ manifest_to_mapping,
+)
+
+__all__ = [
+ "SUMMARY_FILENAME",
+ "SUMMARY_SCHEMA_VERSION",
+ "BenchmarkSummary",
+ "SummaryError",
+ "SummaryMethodRef",
+ "build_summary",
+ "canonical_sha256",
+ "dump_summary",
+ "load_summary",
+ "resolve_summary_path",
+ "run_manifest",
+ "summary_from_mapping",
+ "summary_to_mapping",
+ "verify_summary",
+]
+
+SUMMARY_SCHEMA_VERSION = "benchmark_summary_v1"
+SUMMARY_FILENAME = "summary.json"
+_SHA256_HEX = 64
+
+SUMMARY_KEYS: frozenset[str] = frozenset(
+ {
+ "schema_version",
+ "manifest_id",
+ "manifest_sha256",
+ "dataset_sha256",
+ "track",
+ "methods",
+ "seeds",
+ "horizons",
+ "metrics",
+ "controls",
+ "package_version",
+ "executed",
+ "summary_sha256",
+ }
+)
+_METHOD_REF_KEYS: frozenset[str] = frozenset({"name", "role"})
+
+
+class SummaryError(ValueError):
+ """Tampered, unbound, or invalid benchmark summary.
+
+ Notes
+ -----
+ Raised by summary load and :func:`verify_summary`.
+ """
+
+
+@dataclass(frozen=True)
+class SummaryMethodRef:
+ """Method identity copied onto a summary.
+
+ Attributes
+ ----------
+ name : str
+ Method identifier.
+ role : str
+ Honesty class from the manifest.
+ """
+
+ name: str
+ role: str
+
+ def __post_init__(self) -> None:
+ """Validate name and role strings.
+
+ Raises
+ ------
+ SummaryError
+ If a field is empty.
+ """
+ try:
+ object.__setattr__(
+ self, "name", _nonempty_str(self.name, name="summary.method.name")
+ )
+ object.__setattr__(
+ self, "role", _nonempty_str(self.role, name="summary.method.role")
+ )
+ except ManifestError as exc:
+ raise SummaryError(str(exc)) from exc
+
+
+@dataclass(frozen=True)
+class BenchmarkSummary:
+ """Hashed protocol identity for ``benchmark run`` / ``verify``.
+
+ ``executed`` is ``False`` in this increment: the summary does not
+ contain fitted forecast metrics.
+
+ Attributes
+ ----------
+ schema_version : {"benchmark_summary_v1"}
+ Summary schema name.
+ manifest_id : str
+ Copied from the manifest.
+ manifest_sha256, dataset_sha256 : str
+ Hex SHA-256 of the canonical manifest mapping and dataset payload.
+ track : str
+ Evidence track.
+ methods : tuple of SummaryMethodRef
+ Method names and roles.
+ seeds, horizons : tuple of int
+ Copied grids.
+ metrics, controls : tuple of str
+ Copied names.
+ package_version : str
+ ``koopman_graph.__version__`` at run time.
+ executed : bool
+ Whether method metrics were computed. Default ``False``.
+ summary_sha256 : str
+ Hex SHA-256 of the canonical mapping with this field omitted.
+ """
+
+ schema_version: Literal["benchmark_summary_v1"]
+ manifest_id: str
+ manifest_sha256: str
+ dataset_sha256: str
+ track: str
+ methods: tuple[SummaryMethodRef, ...]
+ seeds: tuple[int, ...]
+ horizons: tuple[int, ...]
+ metrics: tuple[str, ...]
+ controls: tuple[str, ...]
+ package_version: str
+ executed: bool
+ summary_sha256: str
+
+ def __post_init__(self) -> None:
+ """Validate identity fields and digests.
+
+ Raises
+ ------
+ SummaryError
+ If a required field is invalid.
+ """
+ if self.schema_version != SUMMARY_SCHEMA_VERSION:
+ msg = (
+ f"schema_version must be {SUMMARY_SCHEMA_VERSION!r}, "
+ f"got {self.schema_version!r}"
+ )
+ raise SummaryError(msg)
+ try:
+ object.__setattr__(
+ self,
+ "manifest_id",
+ _nonempty_str(self.manifest_id, name="summary.manifest_id"),
+ )
+ object.__setattr__(
+ self, "track", _nonempty_str(self.track, name="summary.track")
+ )
+ object.__setattr__(
+ self,
+ "package_version",
+ _nonempty_str(self.package_version, name="summary.package_version"),
+ )
+ object.__setattr__(
+ self, "executed", _require_bool(self.executed, name="summary.executed")
+ )
+ object.__setattr__(
+ self,
+ "seeds",
+ _as_int_tuple(self.seeds, name="summary.seeds", minimum=0),
+ )
+ object.__setattr__(
+ self,
+ "horizons",
+ _as_int_tuple(self.horizons, name="summary.horizons", minimum=1),
+ )
+ object.__setattr__(
+ self,
+ "metrics",
+ _as_str_tuple(self.metrics, name="summary.metrics"),
+ )
+ object.__setattr__(
+ self,
+ "controls",
+ _as_str_tuple(self.controls, name="summary.controls"),
+ )
+ except ManifestError as exc:
+ raise SummaryError(str(exc)) from exc
+ object.__setattr__(
+ self, "manifest_sha256", _require_sha256(self.manifest_sha256)
+ )
+ object.__setattr__(self, "dataset_sha256", _require_sha256(self.dataset_sha256))
+ object.__setattr__(self, "summary_sha256", _require_sha256(self.summary_sha256))
+ if not self.methods:
+ msg = "summary.methods must be non-empty"
+ raise SummaryError(msg)
+ for method in self.methods:
+ if not isinstance(method, SummaryMethodRef):
+ msg = (
+ "summary.methods entries must be SummaryMethodRef, "
+ f"got {type(method).__name__}"
+ )
+ raise SummaryError(msg)
+
+
+def _require_sha256(value: object) -> str:
+ """Return a lowercase 64-character hex digest.
+
+ Parameters
+ ----------
+ value : object
+ Candidate digest.
+
+ Returns
+ -------
+ str
+ Lowercase hex SHA-256.
+
+ Raises
+ ------
+ SummaryError
+ If ``value`` is not a 64-character hex string.
+ """
+ if not isinstance(value, str) or len(value) != _SHA256_HEX:
+ msg = "digest must be a 64-character hex SHA-256"
+ raise SummaryError(msg)
+ try:
+ int(value, 16)
+ except ValueError as exc:
+ msg = "digest must be a 64-character hex SHA-256"
+ raise SummaryError(msg) from exc
+ return value.lower()
+
+
+def canonical_sha256(payload: Mapping[str, Any]) -> str:
+ """Return SHA-256 of canonical UTF-8 JSON.
+
+ Keys are sorted. Separators are compact ``(",", ":")``.
+
+ Parameters
+ ----------
+ payload : mapping
+ JSON-friendly mapping.
+
+ Returns
+ -------
+ str
+ Lowercase hex digest.
+ """
+ encoded = json.dumps(
+ payload,
+ sort_keys=True,
+ separators=(",", ":"),
+ ensure_ascii=False,
+ ).encode("utf-8")
+ return hashlib.sha256(encoded).hexdigest()
+
+
+def summary_to_mapping(summary: BenchmarkSummary) -> dict[str, Any]:
+ """Return a JSON-friendly nested mapping.
+
+ Parameters
+ ----------
+ summary : BenchmarkSummary
+ Validated record.
+
+ Returns
+ -------
+ dict
+ Nested mapping suitable for JSON dump.
+ """
+ return {
+ "schema_version": summary.schema_version,
+ "manifest_id": summary.manifest_id,
+ "manifest_sha256": summary.manifest_sha256,
+ "dataset_sha256": summary.dataset_sha256,
+ "track": summary.track,
+ "methods": [
+ {"name": method.name, "role": method.role} for method in summary.methods
+ ],
+ "seeds": list(summary.seeds),
+ "horizons": list(summary.horizons),
+ "metrics": list(summary.metrics),
+ "controls": list(summary.controls),
+ "package_version": summary.package_version,
+ "executed": summary.executed,
+ "summary_sha256": summary.summary_sha256,
+ }
+
+
+def _body_mapping(summary: BenchmarkSummary) -> dict[str, Any]:
+ """Return the canonical mapping without ``summary_sha256``.
+
+ Parameters
+ ----------
+ summary : BenchmarkSummary
+ Record whose digest is computed.
+
+ Returns
+ -------
+ dict
+ JSON-friendly mapping.
+ """
+ payload = summary_to_mapping(summary)
+ payload.pop("summary_sha256", None)
+ return payload
+
+
+def _method_ref_from_mapping(payload: Mapping[str, Any]) -> SummaryMethodRef:
+ """Build :class:`SummaryMethodRef` from a mapping.
+
+ Parameters
+ ----------
+ payload : mapping
+ Method identity.
+
+ Returns
+ -------
+ SummaryMethodRef
+ Validated reference.
+
+ Raises
+ ------
+ SummaryError
+ If keys are missing or unknown.
+ """
+ try:
+ mapping = _require_mapping(payload, name="summary.method")
+ _reject_unknown(mapping, _METHOD_REF_KEYS, name="summary.method")
+ except ManifestError as exc:
+ raise SummaryError(str(exc)) from exc
+ missing = sorted(_METHOD_REF_KEYS - set(mapping))
+ if missing:
+ msg = f"summary.method missing required keys: {', '.join(missing)}"
+ raise SummaryError(msg)
+ return SummaryMethodRef(name=mapping["name"], role=mapping["role"])
+
+
+def summary_from_mapping(payload: Mapping[str, Any]) -> BenchmarkSummary:
+ """Build :class:`BenchmarkSummary` from a JSON mapping.
+
+ Parameters
+ ----------
+ payload : mapping
+ Summary document.
+
+ Returns
+ -------
+ BenchmarkSummary
+ Validated record.
+
+ Raises
+ ------
+ SummaryError
+ If keys or nested objects are invalid.
+ """
+ try:
+ mapping = _require_mapping(payload, name="summary")
+ _reject_unknown(mapping, SUMMARY_KEYS, name="summary")
+ except ManifestError as exc:
+ raise SummaryError(str(exc)) from exc
+ missing = sorted(SUMMARY_KEYS - set(mapping))
+ if missing:
+ msg = f"summary missing required keys: {', '.join(missing)}"
+ raise SummaryError(msg)
+ methods_raw = mapping["methods"]
+ if not isinstance(methods_raw, Sequence) or isinstance(methods_raw, (str, bytes)):
+ msg = "summary.methods must be a sequence of mappings"
+ raise SummaryError(msg)
+ return BenchmarkSummary(
+ schema_version=mapping["schema_version"],
+ manifest_id=mapping["manifest_id"],
+ manifest_sha256=mapping["manifest_sha256"],
+ dataset_sha256=mapping["dataset_sha256"],
+ track=mapping["track"],
+ methods=tuple(_method_ref_from_mapping(item) for item in methods_raw),
+ seeds=tuple(mapping["seeds"]),
+ horizons=tuple(mapping["horizons"]),
+ metrics=tuple(mapping["metrics"]),
+ controls=tuple(mapping["controls"]),
+ package_version=mapping["package_version"],
+ executed=mapping["executed"],
+ summary_sha256=mapping["summary_sha256"],
+ )
+
+
+def _attach_digest(summary: BenchmarkSummary) -> BenchmarkSummary:
+ """Return ``summary`` with ``summary_sha256`` filled.
+
+ Parameters
+ ----------
+ summary : BenchmarkSummary
+ Record whose hash field is ignored when hashing the body.
+
+ Returns
+ -------
+ BenchmarkSummary
+ Record with a matching digest.
+ """
+ digest = canonical_sha256(_body_mapping(summary))
+ return replace(summary, summary_sha256=digest)
+
+
+def build_summary(manifest: ExperimentManifest) -> BenchmarkSummary:
+ """Build an identity-bound summary from a validated manifest.
+
+ Parameters
+ ----------
+ manifest : ExperimentManifest
+ Protocol record.
+
+ Returns
+ -------
+ BenchmarkSummary
+ Summary with ``executed=False`` and a matching digest.
+ """
+ methods = tuple(
+ SummaryMethodRef(name=method.name, role=method.role)
+ for method in manifest.methods
+ )
+ draft = BenchmarkSummary(
+ schema_version=SUMMARY_SCHEMA_VERSION,
+ manifest_id=manifest.manifest_id,
+ manifest_sha256=canonical_sha256(manifest_to_mapping(manifest)),
+ dataset_sha256=manifest.dataset.sha256,
+ track=manifest.track,
+ methods=methods,
+ seeds=manifest.seeds,
+ horizons=manifest.horizons,
+ metrics=manifest.metrics,
+ controls=manifest.controls,
+ package_version=__version__,
+ executed=False,
+ summary_sha256="0" * _SHA256_HEX,
+ )
+ return _attach_digest(draft)
+
+
+def dump_summary(summary: BenchmarkSummary, path: str | Path) -> None:
+ """Write a summary JSON document.
+
+ Parameters
+ ----------
+ summary : BenchmarkSummary
+ Validated record.
+ path : str or Path
+ Destination ``.json`` path.
+ """
+ destination = Path(path)
+ destination.write_text(
+ json.dumps(summary_to_mapping(summary), indent=2) + "\n",
+ encoding="utf-8",
+ )
+
+
+def load_summary(path: str | Path) -> BenchmarkSummary:
+ """Load a summary JSON document.
+
+ Parameters
+ ----------
+ path : str or Path
+ ``summary.json`` path.
+
+ Returns
+ -------
+ BenchmarkSummary
+ Validated record.
+
+ Raises
+ ------
+ SummaryError
+ If the file is missing or invalid.
+ """
+ destination = Path(path)
+ if not destination.is_file():
+ msg = f"summary file not found: {destination}"
+ raise SummaryError(msg)
+ try:
+ payload = json.loads(destination.read_text(encoding="utf-8"))
+ except json.JSONDecodeError as exc:
+ msg = f"Invalid JSON in {destination}: {exc.msg}"
+ raise SummaryError(msg) from exc
+ if not isinstance(payload, dict):
+ msg = f"summary root must be a mapping, got {type(payload).__name__}"
+ raise SummaryError(msg)
+ return summary_from_mapping(payload)
+
+
+def resolve_summary_path(against: str | Path) -> Path:
+ """Resolve ``--against`` to a ``summary.json`` file.
+
+ Parameters
+ ----------
+ against : str or Path
+ Directory containing ``summary.json``, or the file itself.
+
+ Returns
+ -------
+ Path
+ Summary file path.
+ """
+ destination = Path(against)
+ if destination.is_dir():
+ return destination / SUMMARY_FILENAME
+ return destination
+
+
+def run_manifest(
+ manifest_path: str | Path,
+ data_path: str | Path,
+ out_dir: str | Path,
+) -> Path:
+ """Verify the dataset digest and write an identity-bound summary.
+
+ Parameters
+ ----------
+ manifest_path : str or Path
+ Manifest JSON/YAML.
+ data_path : str or Path
+ Dataset payload whose SHA-256 must match ``dataset.sha256``.
+ out_dir : str or Path
+ Directory that will receive ``summary.json``.
+
+ Returns
+ -------
+ Path
+ Written summary path.
+
+ Raises
+ ------
+ ManifestError
+ If the manifest or dataset digest is invalid.
+ """
+ manifest = load_manifest(manifest_path)
+ verify_dataset_hash(manifest.dataset, data_path)
+ summary = build_summary(manifest)
+ destination_dir = Path(out_dir)
+ destination_dir.mkdir(parents=True, exist_ok=True)
+ destination = destination_dir / SUMMARY_FILENAME
+ dump_summary(summary, destination)
+ return destination
+
+
+def verify_summary(manifest_path: str | Path, against: str | Path) -> BenchmarkSummary:
+ """Reject a tampered or unbound summary.
+
+ Recomputes ``summary_sha256`` and checks identity fields against
+ the loaded manifest (id, track, dataset digest, method names/roles,
+ seeds, horizons, metrics, controls).
+
+ Parameters
+ ----------
+ manifest_path : str or Path
+ Manifest JSON/YAML.
+ against : str or Path
+ Summary file or a directory containing ``summary.json``.
+
+ Returns
+ -------
+ BenchmarkSummary
+ Loaded summary when verification succeeds.
+
+ Raises
+ ------
+ SummaryError
+ If the digest or binding does not match.
+ ManifestError
+ If the manifest cannot be loaded.
+ """
+ manifest = load_manifest(manifest_path)
+ summary = load_summary(resolve_summary_path(against))
+ expected = canonical_sha256(_body_mapping(summary))
+ if summary.summary_sha256 != expected:
+ msg = (
+ "summary_sha256 mismatch: "
+ f"got {summary.summary_sha256}, expected {expected}"
+ )
+ raise SummaryError(msg)
+ expected_manifest = canonical_sha256(manifest_to_mapping(manifest))
+ if summary.manifest_sha256 != expected_manifest:
+ msg = "summary.manifest_sha256 does not match the loaded manifest"
+ raise SummaryError(msg)
+ if summary.manifest_id != manifest.manifest_id:
+ msg = (
+ f"summary.manifest_id {summary.manifest_id!r} does not match "
+ f"manifest {manifest.manifest_id!r}"
+ )
+ raise SummaryError(msg)
+ if summary.dataset_sha256 != manifest.dataset.sha256:
+ msg = "summary.dataset_sha256 does not match manifest.dataset.sha256"
+ raise SummaryError(msg)
+ if summary.track != manifest.track:
+ msg = (
+ f"summary.track {summary.track!r} does not match "
+ f"manifest {manifest.track!r}"
+ )
+ raise SummaryError(msg)
+ manifest_methods = tuple((method.name, method.role) for method in manifest.methods)
+ summary_methods = tuple((method.name, method.role) for method in summary.methods)
+ if summary_methods != manifest_methods:
+ msg = "summary.methods do not match the loaded manifest"
+ raise SummaryError(msg)
+ if summary.seeds != manifest.seeds:
+ msg = "summary.seeds do not match the loaded manifest"
+ raise SummaryError(msg)
+ if summary.horizons != manifest.horizons:
+ msg = "summary.horizons do not match the loaded manifest"
+ raise SummaryError(msg)
+ if summary.metrics != manifest.metrics:
+ msg = "summary.metrics do not match the loaded manifest"
+ raise SummaryError(msg)
+ if summary.controls != manifest.controls:
+ msg = "summary.controls do not match the loaded manifest"
+ raise SummaryError(msg)
+ return summary
diff --git a/src/koopman_graph/benchmark/schema.py b/src/koopman_graph/benchmark/schema.py
new file mode 100644
index 0000000..c88aa6d
--- /dev/null
+++ b/src/koopman_graph/benchmark/schema.py
@@ -0,0 +1,1104 @@
+"""Frozen ``benchmark_manifest_v1`` types for protocol-locked experiments.
+
+This schema records dataset identity, splits, methods, seeds, horizons,
+metrics, controls, and an optional UQ block. It does **not** run a
+benchmark, write artifacts, or claim LibCity / BasicTS leaderboard
+parity. Teaching GNN ports listed as methods must keep a non-empty
+``deviations`` tuple.
+
+Ratios, coverage, and SHA-256 digests are dimensionless. Horizon and
+``history_len`` are snapshot counts. ``max_epochs`` is an epoch count.
+"""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Mapping, Sequence
+from dataclasses import asdict, dataclass
+from typing import Any, Literal
+
+__all__ = [
+ "BENCHMARK_METRICS",
+ "BENCHMARK_TRACKS",
+ "CONTROL_TOKENS",
+ "DATASET_REF_KEYS",
+ "EmptyMethodDeviationsError",
+ "MANIFEST_KEYS",
+ "METHOD_ROLES",
+ "MIN_MANIFEST_SEEDS",
+ "ManifestError",
+ "METHOD_SPEC_KEYS",
+ "RATIO_SUM_ATOL",
+ "SCHEMA_VERSION",
+ "ComputeBudget",
+ "DatasetRef",
+ "ExperimentManifest",
+ "MethodSpec",
+ "OODShiftSpec",
+ "PreprocessingSpec",
+ "SplitSpec",
+ "UQSpec",
+ "manifest_from_mapping",
+ "manifest_to_mapping",
+]
+
+SCHEMA_VERSION = "benchmark_manifest_v1"
+MIN_MANIFEST_SEEDS = 3
+RATIO_SUM_ATOL = 1e-6
+_SHA256_HEX = re.compile(r"^[0-9a-fA-F]{64}$")
+
+BENCHMARK_TRACKS: frozenset[str] = frozenset(
+ {"telemetry", "multiphysics", "topology_transfer"}
+)
+BENCHMARK_METRICS: frozenset[str] = frozenset({"mae", "rmse", "mape", "mse"})
+CONTROL_TOKENS: frozenset[str] = frozenset({"pernode", "joint_ls", "hold_last"})
+METHOD_ROLES: frozenset[str] = frozenset(
+ {
+ "koopman",
+ "teaching_gnn",
+ "leaderboard",
+ "control",
+ "dedicated_library",
+ }
+)
+_TOPOLOGY_REQUIRED_CONTROLS = frozenset({"hold_last", "pernode", "joint_ls"})
+_FACTORIZATION_CONTROLS = frozenset({"pernode", "joint_ls"})
+
+MANIFEST_KEYS: frozenset[str] = frozenset(
+ {
+ "manifest_id",
+ "schema_version",
+ "track",
+ "dataset",
+ "split",
+ "preprocessing",
+ "methods",
+ "seeds",
+ "horizons",
+ "metrics",
+ "ood_shifts",
+ "uq",
+ "compute_budget",
+ "controls",
+ }
+)
+DATASET_REF_KEYS: frozenset[str] = frozenset({"name", "version", "sha256", "card"})
+_SPLIT_KEYS: frozenset[str] = frozenset(
+ {"train_ratio", "val_ratio", "test_ratio", "history_len"}
+)
+_PREPROCESS_KEYS: frozenset[str] = frozenset({"zscore", "notes"})
+METHOD_SPEC_KEYS: frozenset[str] = frozenset({"name", "role", "deviations", "version"})
+_OOD_KEYS: frozenset[str] = frozenset({"name", "kind", "description"})
+_UQ_KEYS: frozenset[str] = frozenset({"method", "coverage"})
+_BUDGET_KEYS: frozenset[str] = frozenset({"max_epochs", "device", "notes"})
+
+BenchmarkTrack = Literal["telemetry", "multiphysics", "topology_transfer"]
+BenchmarkMetric = Literal["mae", "rmse", "mape", "mse"]
+ControlToken = Literal["pernode", "joint_ls", "hold_last"]
+MethodRole = Literal[
+ "koopman",
+ "teaching_gnn",
+ "leaderboard",
+ "control",
+ "dedicated_library",
+]
+
+
+class ManifestError(ValueError):
+ """Invalid benchmark-manifest mapping, field, or file.
+
+ Notes
+ -----
+ Raised by schema construction and YAML/JSON loaders.
+ """
+
+
+class EmptyMethodDeviationsError(ValueError):
+ """Raised when a teaching GNN method has empty ``deviations``.
+
+ Notes
+ -----
+ Teaching ports cannot claim zero deviation from a paper / LibCity
+ protocol when listed on a manifest.
+ """
+
+
+def _finite_float(value: float) -> bool:
+ """Return whether ``value`` is a finite Python float.
+
+ Parameters
+ ----------
+ value : float
+ Scalar to test.
+
+ Returns
+ -------
+ bool
+ ``True`` when ``value`` is finite.
+ """
+ return value == value and value not in (float("inf"), float("-inf"))
+
+
+def _require_mapping(value: object, *, name: str) -> dict[str, Any]:
+ """Return ``value`` as a string-key mapping.
+
+ Parameters
+ ----------
+ value : object
+ Candidate mapping.
+ name : str
+ Field name for errors.
+
+ Returns
+ -------
+ dict
+ The mapping.
+
+ Raises
+ ------
+ ManifestError
+ If ``value`` is not a ``dict`` with string keys.
+ """
+ if not isinstance(value, dict):
+ msg = f"{name} must be a mapping, got {type(value).__name__}"
+ raise ManifestError(msg)
+ for key in value:
+ if not isinstance(key, str):
+ msg = f"{name} keys must be strings, got {type(key).__name__}"
+ raise ManifestError(msg)
+ return value
+
+
+def _reject_unknown(
+ mapping: Mapping[str, Any],
+ allowed: frozenset[str],
+ *,
+ name: str,
+) -> None:
+ """Raise if ``mapping`` contains keys outside ``allowed``.
+
+ Parameters
+ ----------
+ mapping : mapping
+ Parsed object.
+ allowed : frozenset of str
+ Permitted keys.
+ name : str
+ Field name for errors.
+
+ Raises
+ ------
+ ManifestError
+ If an unknown key is present.
+ """
+ extra = sorted(set(mapping) - allowed)
+ if extra:
+ msg = f"unknown {name} keys: {', '.join(extra)}"
+ raise ManifestError(msg)
+
+
+def _nonempty_str(value: object, *, name: str) -> str:
+ """Return a stripped non-empty string.
+
+ Parameters
+ ----------
+ value : object
+ Candidate string.
+ name : str
+ Field name for errors.
+
+ Returns
+ -------
+ str
+ Non-empty string.
+
+ Raises
+ ------
+ ManifestError
+ If ``value`` is empty or not a string.
+ """
+ if not isinstance(value, str) or not value.strip():
+ msg = f"{name} must be a non-empty string, got {value!r}"
+ raise ManifestError(msg)
+ return value
+
+
+def _as_str_tuple(value: object, *, name: str) -> tuple[str, ...]:
+ """Return a tuple of strings (empty strings rejected).
+
+ Parameters
+ ----------
+ value : object
+ Sequence of strings.
+ name : str
+ Field name for errors.
+
+ Returns
+ -------
+ tuple of str
+ Coerced strings.
+
+ Raises
+ ------
+ ManifestError
+ If ``value`` is not a string sequence.
+ """
+ if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
+ msg = f"{name} must be a sequence of strings"
+ raise ManifestError(msg)
+ parsed: list[str] = []
+ for item in value:
+ if not isinstance(item, str) or item == "":
+ msg = f"{name} must contain non-empty strings, got {item!r}"
+ raise ManifestError(msg)
+ parsed.append(item)
+ return tuple(parsed)
+
+
+def _as_int_tuple(value: object, *, name: str, minimum: int) -> tuple[int, ...]:
+ """Return a tuple of ints each at least ``minimum``.
+
+ Parameters
+ ----------
+ value : object
+ Sequence of ints.
+ name : str
+ Field name for errors.
+ minimum : int
+ Inclusive lower bound.
+
+ Returns
+ -------
+ tuple of int
+ Coerced ints.
+
+ Raises
+ ------
+ ManifestError
+ If ``value`` is not a sequence of ints in range.
+ """
+ if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
+ msg = f"{name} must be a sequence of ints"
+ raise ManifestError(msg)
+ parsed: list[int] = []
+ for item in value:
+ if type(item) is not int:
+ msg = f"{name} must contain ints, got {item!r}"
+ raise ManifestError(msg)
+ if item < minimum:
+ msg = f"{name} entries must be >= {minimum}, got {item}"
+ raise ManifestError(msg)
+ parsed.append(item)
+ return tuple(parsed)
+
+
+def _require_unique(values: tuple[Any, ...], *, name: str) -> None:
+ """Reject duplicate entries.
+
+ Parameters
+ ----------
+ values : tuple
+ Sequence to check.
+ name : str
+ Field name for errors.
+
+ Raises
+ ------
+ ManifestError
+ If any value is repeated.
+ """
+ if len(set(values)) != len(values):
+ msg = f"{name} must not contain duplicates, got {values!r}"
+ raise ManifestError(msg)
+
+
+def _ratio(value: object, *, name: str) -> float:
+ """Return a split ratio in ``(0, 1]``.
+
+ Parameters
+ ----------
+ value : object
+ Candidate ratio (dimensionless).
+ name : str
+ Field name for errors.
+
+ Returns
+ -------
+ float
+ Finite ratio.
+
+ Raises
+ ------
+ ManifestError
+ If the ratio is out of range.
+ """
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ msg = f"{name} must be a float, got {type(value).__name__}"
+ raise ManifestError(msg)
+ ratio = float(value)
+ if not _finite_float(ratio) or not (0.0 < ratio <= 1.0):
+ msg = f"{name} must satisfy 0 < ratio <= 1, got {ratio}"
+ raise ManifestError(msg)
+ return ratio
+
+
+def _require_bool(value: object, *, name: str) -> bool:
+ """Return a genuine ``bool``.
+
+ Parameters
+ ----------
+ value : object
+ Candidate flag.
+ name : str
+ Field name for errors.
+
+ Returns
+ -------
+ bool
+ The flag.
+
+ Raises
+ ------
+ ManifestError
+ If ``value`` is not a ``bool``.
+ """
+ if type(value) is not bool:
+ msg = f"{name} must be a bool, got {type(value).__name__}"
+ raise ManifestError(msg)
+ return value
+
+
+@dataclass(frozen=True)
+class DatasetRef:
+ """Named dataset with a SHA-256 digest and card path.
+
+ ``sha256`` is a 64-character hexadecimal digest of the dataset
+ payload bytes, not a hash of this manifest document.
+
+ Attributes
+ ----------
+ name : str
+ Dataset identifier.
+ version : str
+ Dataset version label.
+ sha256 : str
+ Hex SHA-256 of the payload.
+ card : str
+ Path or URI of the dataset card.
+ """
+
+ name: str
+ version: str
+ sha256: str
+ card: str
+
+ def __post_init__(self) -> None:
+ """Validate identity strings and the digest.
+
+ Raises
+ ------
+ ManifestError
+ If a field is empty or ``sha256`` is not 64 hex characters.
+ """
+ object.__setattr__(self, "name", _nonempty_str(self.name, name="dataset.name"))
+ object.__setattr__(
+ self, "version", _nonempty_str(self.version, name="dataset.version")
+ )
+ object.__setattr__(self, "card", _nonempty_str(self.card, name="dataset.card"))
+ digest = _nonempty_str(self.sha256, name="dataset.sha256")
+ if _SHA256_HEX.fullmatch(digest) is None:
+ msg = "dataset.sha256 must be a 64-character hex digest"
+ raise ManifestError(msg)
+ object.__setattr__(self, "sha256", digest.lower())
+
+
+@dataclass(frozen=True)
+class SplitSpec:
+ """Temporal split fractions and optional lookback.
+
+ Ratios are dimensionless and must sum to 1 within
+ :data:`RATIO_SUM_ATOL`. ``history_len`` is a snapshot count.
+
+ Attributes
+ ----------
+ train_ratio, val_ratio, test_ratio : float
+ Split fractions in ``(0, 1]``.
+ history_len : int or None
+ Optional lookback length. Default ``None``.
+ """
+
+ train_ratio: float
+ val_ratio: float
+ test_ratio: float
+ history_len: int | None = None
+
+ def __post_init__(self) -> None:
+ """Validate ratios and optional lookback.
+
+ Raises
+ ------
+ ManifestError
+ If ratios are invalid or ``history_len`` is not a positive int.
+ """
+ train = _ratio(self.train_ratio, name="split.train_ratio")
+ val = _ratio(self.val_ratio, name="split.val_ratio")
+ test = _ratio(self.test_ratio, name="split.test_ratio")
+ object.__setattr__(self, "train_ratio", train)
+ object.__setattr__(self, "val_ratio", val)
+ object.__setattr__(self, "test_ratio", test)
+ ratio_sum = train + val + test
+ if abs(ratio_sum - 1.0) > RATIO_SUM_ATOL:
+ msg = (
+ "split ratios must sum to 1 within "
+ f"atol={RATIO_SUM_ATOL}, got sum={ratio_sum}"
+ )
+ raise ManifestError(msg)
+ if self.history_len is not None and (
+ type(self.history_len) is not int or self.history_len < 1
+ ):
+ msg = (
+ "split.history_len must be an int >= 1 or None, "
+ f"got {self.history_len!r}"
+ )
+ raise ManifestError(msg)
+
+
+@dataclass(frozen=True)
+class PreprocessingSpec:
+ """Declared preprocessing (not an executable pipeline).
+
+ Attributes
+ ----------
+ zscore : bool
+ Whether inputs are z-scored on the train split. Default ``False``.
+ notes : tuple of str
+ Optional free-text notes. Default ``()``.
+ """
+
+ zscore: bool = False
+ notes: tuple[str, ...] = ()
+
+ def __post_init__(self) -> None:
+ """Validate the flag and notes tuple.
+
+ Raises
+ ------
+ ManifestError
+ If ``zscore`` is not a bool or notes are invalid.
+ """
+ object.__setattr__(
+ self, "zscore", _require_bool(self.zscore, name="preprocessing.zscore")
+ )
+ object.__setattr__(
+ self, "notes", _as_str_tuple(self.notes, name="preprocessing.notes")
+ )
+
+
+@dataclass(frozen=True)
+class MethodSpec:
+ """One method listed on a manifest.
+
+ ``role="teaching_gnn"`` requires a non-empty ``deviations`` tuple.
+ ``role="dedicated_library"`` requires a version pin. Leaderboard
+ adapters may have empty ``deviations``.
+
+ Attributes
+ ----------
+ name : str
+ Method identifier.
+ role : {"koopman", "teaching_gnn", "leaderboard", "control", "dedicated_library"}
+ Honesty class.
+ deviations : tuple of str
+ Protocol mismatches. Default ``()``.
+ version : str or None
+ Library version pin. Default ``None``.
+ """
+
+ name: str
+ role: MethodRole
+ deviations: tuple[str, ...] = ()
+ version: str | None = None
+
+ def __post_init__(self) -> None:
+ """Validate role, deviations, and version pin.
+
+ Raises
+ ------
+ EmptyMethodDeviationsError
+ If a teaching GNN method has empty ``deviations``.
+ ManifestError
+ If ``role`` is unknown or a dedicated library lacks ``version``.
+ """
+ object.__setattr__(self, "name", _nonempty_str(self.name, name="method.name"))
+ if self.role not in METHOD_ROLES:
+ allowed = ", ".join(sorted(METHOD_ROLES))
+ msg = f"method.role must be one of {allowed}, got {self.role!r}"
+ raise ManifestError(msg)
+ object.__setattr__(
+ self, "deviations", _as_str_tuple(self.deviations, name="method.deviations")
+ )
+ if self.role == "teaching_gnn" and len(self.deviations) == 0:
+ msg = (
+ "teaching_gnn methods require non-empty deviations; "
+ "teaching ports cannot claim zero deviation from a paper / "
+ "LibCity protocol"
+ )
+ raise EmptyMethodDeviationsError(msg)
+ if self.version is not None:
+ object.__setattr__(
+ self, "version", _nonempty_str(self.version, name="method.version")
+ )
+ elif self.role == "dedicated_library":
+ msg = "dedicated_library methods require a non-empty version pin"
+ raise ManifestError(msg)
+
+
+@dataclass(frozen=True)
+class OODShiftSpec:
+ """Named out-of-distribution shift (descriptive, not executed).
+
+ Attributes
+ ----------
+ name : str
+ Shift identifier.
+ kind : str
+ Shift family (for example ``"rewire"`` or ``"unseen_n"``).
+ description : str
+ Optional prose. Default ``""``.
+ """
+
+ name: str
+ kind: str
+ description: str = ""
+
+ def __post_init__(self) -> None:
+ """Validate name and kind strings.
+
+ Raises
+ ------
+ ManifestError
+ If ``name`` or ``kind`` is empty or ``description`` is not a str.
+ """
+ object.__setattr__(self, "name", _nonempty_str(self.name, name="ood.name"))
+ object.__setattr__(self, "kind", _nonempty_str(self.kind, name="ood.kind"))
+ if not isinstance(self.description, str):
+ msg = (
+ "ood.description must be a string, "
+ f"got {type(self.description).__name__}"
+ )
+ raise ManifestError(msg)
+
+
+@dataclass(frozen=True)
+class UQSpec:
+ """Optional uncertainty-quantification declaration.
+
+ ``coverage`` is a dimensionless target in ``(0, 1]`` when set.
+
+ Attributes
+ ----------
+ method : str
+ Named UQ procedure.
+ coverage : float or None
+ Optional coverage target. Default ``None``.
+ """
+
+ method: str
+ coverage: float | None = None
+
+ def __post_init__(self) -> None:
+ """Validate method and optional coverage.
+
+ Raises
+ ------
+ ManifestError
+ If ``method`` is empty or ``coverage`` is out of range.
+ """
+ object.__setattr__(self, "method", _nonempty_str(self.method, name="uq.method"))
+ if self.coverage is None:
+ return
+ if isinstance(self.coverage, bool) or not isinstance(
+ self.coverage, (int, float)
+ ):
+ msg = (
+ "uq.coverage must be a float or None, "
+ f"got {type(self.coverage).__name__}"
+ )
+ raise ManifestError(msg)
+ coverage = float(self.coverage)
+ if not _finite_float(coverage) or not (0.0 < coverage <= 1.0):
+ msg = f"uq.coverage must satisfy 0 < coverage <= 1, got {coverage}"
+ raise ManifestError(msg)
+ object.__setattr__(self, "coverage", coverage)
+
+
+@dataclass(frozen=True)
+class ComputeBudget:
+ """Declared compute envelope (not a measured wall-clock).
+
+ ``max_epochs`` is an epoch count, not a physical duration.
+
+ Attributes
+ ----------
+ max_epochs : int
+ Inclusive epoch budget.
+ device : str
+ Device label. Default ``"cpu"``.
+ notes : tuple of str
+ Optional notes. Default ``()``.
+ """
+
+ max_epochs: int
+ device: str = "cpu"
+ notes: tuple[str, ...] = ()
+
+ def __post_init__(self) -> None:
+ """Validate epoch count and device label.
+
+ Raises
+ ------
+ ManifestError
+ If ``max_epochs`` is not a positive int.
+ """
+ if type(self.max_epochs) is not int or self.max_epochs < 1:
+ msg = (
+ "compute_budget.max_epochs must be an int >= 1, "
+ f"got {self.max_epochs!r}"
+ )
+ raise ManifestError(msg)
+ object.__setattr__(
+ self, "device", _nonempty_str(self.device, name="compute_budget.device")
+ )
+ object.__setattr__(
+ self, "notes", _as_str_tuple(self.notes, name="compute_budget.notes")
+ )
+
+
+@dataclass(frozen=True)
+class ExperimentManifest:
+ """Protocol-locked experiment declaration (schema ``benchmark_manifest_v1``).
+
+ This record is not a runner and not a leaderboard host. Negative
+ factorization or transfer outcomes remain allowed when a later
+ runner executes the protocol.
+
+ Attributes
+ ----------
+ manifest_id : str
+ Stable identifier.
+ schema_version : {"benchmark_manifest_v1"}
+ Schema name.
+ track : {"telemetry", "multiphysics", "topology_transfer"}
+ Evidence track.
+ dataset : DatasetRef
+ Named payload with SHA-256.
+ split : SplitSpec
+ Temporal split.
+ preprocessing : PreprocessingSpec
+ Declared preprocessing.
+ methods : tuple of MethodSpec
+ Methods under comparison (non-empty).
+ seeds : tuple of int
+ At least :data:`MIN_MANIFEST_SEEDS` unique seeds.
+ horizons : tuple of int
+ Positive forecast horizons (snapshot counts).
+ metrics : tuple of str
+ Names from :data:`BENCHMARK_METRICS`.
+ ood_shifts : tuple of OODShiftSpec
+ Optional shifts. Default ``()``.
+ uq : UQSpec or None
+ Optional UQ block. Default ``None``.
+ compute_budget : ComputeBudget
+ Epoch / device envelope.
+ controls : tuple of str
+ Mandatory controls. ``telemetry`` / ``multiphysics`` require
+ ``pernode`` and/or ``joint_ls``. ``topology_transfer`` requires
+ ``hold_last``, ``pernode``, and ``joint_ls``.
+ """
+
+ manifest_id: str
+ schema_version: Literal["benchmark_manifest_v1"]
+ track: BenchmarkTrack
+ dataset: DatasetRef
+ split: SplitSpec
+ preprocessing: PreprocessingSpec
+ methods: tuple[MethodSpec, ...]
+ seeds: tuple[int, ...]
+ horizons: tuple[int, ...]
+ metrics: tuple[str, ...]
+ compute_budget: ComputeBudget
+ controls: tuple[str, ...]
+ ood_shifts: tuple[OODShiftSpec, ...] = ()
+ uq: UQSpec | None = None
+
+ def __post_init__(self) -> None:
+ """Validate identity, grid, methods, and track controls.
+
+ Raises
+ ------
+ ManifestError
+ If a required field, control set, or grid is invalid.
+ EmptyMethodDeviationsError
+ If a teaching GNN method has empty ``deviations``.
+ """
+ object.__setattr__(
+ self, "manifest_id", _nonempty_str(self.manifest_id, name="manifest_id")
+ )
+ if self.schema_version != SCHEMA_VERSION:
+ msg = (
+ f"schema_version must be {SCHEMA_VERSION!r}, "
+ f"got {self.schema_version!r}"
+ )
+ raise ManifestError(msg)
+ if self.track not in BENCHMARK_TRACKS:
+ allowed = ", ".join(sorted(BENCHMARK_TRACKS))
+ msg = f"track must be one of {allowed}, got {self.track!r}"
+ raise ManifestError(msg)
+ if not isinstance(self.dataset, DatasetRef):
+ msg = "dataset must be a DatasetRef"
+ raise ManifestError(msg)
+ if not isinstance(self.split, SplitSpec):
+ msg = "split must be a SplitSpec"
+ raise ManifestError(msg)
+ if not isinstance(self.preprocessing, PreprocessingSpec):
+ msg = "preprocessing must be a PreprocessingSpec"
+ raise ManifestError(msg)
+ if not isinstance(self.compute_budget, ComputeBudget):
+ msg = "compute_budget must be a ComputeBudget"
+ raise ManifestError(msg)
+ if self.uq is not None and not isinstance(self.uq, UQSpec):
+ msg = "uq must be a UQSpec or None"
+ raise ManifestError(msg)
+
+ methods = self.methods
+ if isinstance(methods, Sequence) and not isinstance(methods, (str, bytes)):
+ coerced_methods = tuple(methods)
+ else:
+ msg = "methods must be a non-empty sequence of MethodSpec"
+ raise ManifestError(msg)
+ if not coerced_methods:
+ msg = "methods must be a non-empty sequence of MethodSpec"
+ raise ManifestError(msg)
+ for method in coerced_methods:
+ if not isinstance(method, MethodSpec):
+ msg = f"methods entries must be MethodSpec, got {type(method).__name__}"
+ raise ManifestError(msg)
+ object.__setattr__(self, "methods", coerced_methods)
+
+ seeds = _as_int_tuple(self.seeds, name="seeds", minimum=0)
+ if len(seeds) < MIN_MANIFEST_SEEDS:
+ msg = f"seeds must contain at least {MIN_MANIFEST_SEEDS} unique ints"
+ raise ManifestError(msg)
+ _require_unique(seeds, name="seeds")
+ object.__setattr__(self, "seeds", seeds)
+
+ horizons = _as_int_tuple(self.horizons, name="horizons", minimum=1)
+ if not horizons:
+ msg = "horizons must be a non-empty sequence of positive ints"
+ raise ManifestError(msg)
+ _require_unique(horizons, name="horizons")
+ object.__setattr__(self, "horizons", horizons)
+
+ metrics = _as_str_tuple(self.metrics, name="metrics")
+ if not metrics:
+ msg = "metrics must be a non-empty sequence"
+ raise ManifestError(msg)
+ _require_unique(metrics, name="metrics")
+ unknown_metrics = sorted(set(metrics) - BENCHMARK_METRICS)
+ if unknown_metrics:
+ allowed = ", ".join(sorted(BENCHMARK_METRICS))
+ msg = f"metrics must be a subset of {allowed}, got {unknown_metrics}"
+ raise ManifestError(msg)
+ object.__setattr__(self, "metrics", metrics)
+
+ shifts = self.ood_shifts
+ if isinstance(shifts, Sequence) and not isinstance(shifts, (str, bytes)):
+ coerced_shifts = tuple(shifts)
+ else:
+ msg = "ood_shifts must be a sequence of OODShiftSpec"
+ raise ManifestError(msg)
+ for shift in coerced_shifts:
+ if not isinstance(shift, OODShiftSpec):
+ msg = (
+ "ood_shifts entries must be OODShiftSpec, "
+ f"got {type(shift).__name__}"
+ )
+ raise ManifestError(msg)
+ object.__setattr__(self, "ood_shifts", coerced_shifts)
+
+ controls = _as_str_tuple(self.controls, name="controls")
+ _require_unique(controls, name="controls")
+ unknown_controls = sorted(set(controls) - CONTROL_TOKENS)
+ if unknown_controls:
+ allowed = ", ".join(sorted(CONTROL_TOKENS))
+ msg = f"controls must be a subset of {allowed}, got {unknown_controls}"
+ raise ManifestError(msg)
+ control_set = set(controls)
+ if self.track == "topology_transfer":
+ missing = sorted(_TOPOLOGY_REQUIRED_CONTROLS - control_set)
+ if missing:
+ msg = (
+ "topology_transfer controls must include hold_last, "
+ f"pernode, and joint_ls; missing {missing}"
+ )
+ raise ManifestError(msg)
+ elif not (control_set & _FACTORIZATION_CONTROLS):
+ msg = (
+ f"{self.track} controls must include pernode and/or joint_ls, "
+ f"got {controls}"
+ )
+ raise ManifestError(msg)
+ object.__setattr__(self, "controls", controls)
+
+
+def _dataset_from_mapping(payload: Mapping[str, Any]) -> DatasetRef:
+ """Build :class:`DatasetRef` from a mapping.
+
+ Parameters
+ ----------
+ payload : mapping
+ Dataset fields.
+
+ Returns
+ -------
+ DatasetRef
+ Validated reference.
+ """
+ mapping = _require_mapping(payload, name="dataset")
+ _reject_unknown(mapping, DATASET_REF_KEYS, name="dataset")
+ missing = sorted(DATASET_REF_KEYS - set(mapping))
+ if missing:
+ msg = f"dataset missing required keys: {', '.join(missing)}"
+ raise ManifestError(msg)
+ return DatasetRef(
+ name=mapping["name"],
+ version=mapping["version"],
+ sha256=mapping["sha256"],
+ card=mapping["card"],
+ )
+
+
+def _split_from_mapping(payload: Mapping[str, Any]) -> SplitSpec:
+ """Build :class:`SplitSpec` from a mapping.
+
+ Parameters
+ ----------
+ payload : mapping
+ Split fields.
+
+ Returns
+ -------
+ SplitSpec
+ Validated split.
+ """
+ mapping = _require_mapping(payload, name="split")
+ _reject_unknown(mapping, _SPLIT_KEYS, name="split")
+ missing = sorted({"train_ratio", "val_ratio", "test_ratio"} - set(mapping))
+ if missing:
+ msg = f"split missing required keys: {', '.join(missing)}"
+ raise ManifestError(msg)
+ return SplitSpec(
+ train_ratio=mapping["train_ratio"],
+ val_ratio=mapping["val_ratio"],
+ test_ratio=mapping["test_ratio"],
+ history_len=mapping.get("history_len"),
+ )
+
+
+def _preprocess_from_mapping(payload: Mapping[str, Any]) -> PreprocessingSpec:
+ """Build :class:`PreprocessingSpec` from a mapping.
+
+ Parameters
+ ----------
+ payload : mapping
+ Preprocessing fields.
+
+ Returns
+ -------
+ PreprocessingSpec
+ Validated spec.
+ """
+ mapping = _require_mapping(payload, name="preprocessing")
+ _reject_unknown(mapping, _PREPROCESS_KEYS, name="preprocessing")
+ return PreprocessingSpec(
+ zscore=mapping.get("zscore", False),
+ notes=tuple(mapping["notes"]) if "notes" in mapping else (),
+ )
+
+
+def _method_from_mapping(payload: Mapping[str, Any]) -> MethodSpec:
+ """Build :class:`MethodSpec` from a mapping.
+
+ Parameters
+ ----------
+ payload : mapping
+ Method fields.
+
+ Returns
+ -------
+ MethodSpec
+ Validated spec.
+ """
+ mapping = _require_mapping(payload, name="method")
+ _reject_unknown(mapping, METHOD_SPEC_KEYS, name="method")
+ missing = sorted({"name", "role"} - set(mapping))
+ if missing:
+ msg = f"method missing required keys: {', '.join(missing)}"
+ raise ManifestError(msg)
+ return MethodSpec(
+ name=mapping["name"],
+ role=mapping["role"],
+ deviations=tuple(mapping["deviations"]) if "deviations" in mapping else (),
+ version=mapping.get("version"),
+ )
+
+
+def _ood_from_mapping(payload: Mapping[str, Any]) -> OODShiftSpec:
+ """Build :class:`OODShiftSpec` from a mapping.
+
+ Parameters
+ ----------
+ payload : mapping
+ Shift fields.
+
+ Returns
+ -------
+ OODShiftSpec
+ Validated spec.
+ """
+ mapping = _require_mapping(payload, name="ood_shift")
+ _reject_unknown(mapping, _OOD_KEYS, name="ood_shift")
+ missing = sorted({"name", "kind"} - set(mapping))
+ if missing:
+ msg = f"ood_shift missing required keys: {', '.join(missing)}"
+ raise ManifestError(msg)
+ return OODShiftSpec(
+ name=mapping["name"],
+ kind=mapping["kind"],
+ description=mapping.get("description", ""),
+ )
+
+
+def _uq_from_mapping(payload: Mapping[str, Any]) -> UQSpec:
+ """Build :class:`UQSpec` from a mapping.
+
+ Parameters
+ ----------
+ payload : mapping
+ UQ fields.
+
+ Returns
+ -------
+ UQSpec
+ Validated spec.
+ """
+ mapping = _require_mapping(payload, name="uq")
+ _reject_unknown(mapping, _UQ_KEYS, name="uq")
+ if "method" not in mapping:
+ msg = "uq missing required key: method"
+ raise ManifestError(msg)
+ return UQSpec(method=mapping["method"], coverage=mapping.get("coverage"))
+
+
+def _budget_from_mapping(payload: Mapping[str, Any]) -> ComputeBudget:
+ """Build :class:`ComputeBudget` from a mapping.
+
+ Parameters
+ ----------
+ payload : mapping
+ Budget fields.
+
+ Returns
+ -------
+ ComputeBudget
+ Validated budget.
+ """
+ mapping = _require_mapping(payload, name="compute_budget")
+ _reject_unknown(mapping, _BUDGET_KEYS, name="compute_budget")
+ if "max_epochs" not in mapping:
+ msg = "compute_budget missing required key: max_epochs"
+ raise ManifestError(msg)
+ return ComputeBudget(
+ max_epochs=mapping["max_epochs"],
+ device=mapping.get("device", "cpu"),
+ notes=tuple(mapping["notes"]) if "notes" in mapping else (),
+ )
+
+
+def manifest_from_mapping(payload: Mapping[str, Any]) -> ExperimentManifest:
+ """Build :class:`ExperimentManifest` from a JSON/YAML mapping.
+
+ Unknown keys are rejected. ``ood_shifts`` and ``uq`` may be omitted.
+
+ Parameters
+ ----------
+ payload : mapping
+ Manifest document.
+
+ Returns
+ -------
+ ExperimentManifest
+ Validated frozen record.
+
+ Raises
+ ------
+ ManifestError
+ If keys or nested objects are invalid.
+ EmptyMethodDeviationsError
+ If a teaching GNN method has empty ``deviations``.
+ """
+ mapping = _require_mapping(payload, name="manifest")
+ _reject_unknown(mapping, MANIFEST_KEYS, name="manifest")
+ required = MANIFEST_KEYS - {"ood_shifts", "uq", "preprocessing"}
+ missing = sorted(required - set(mapping))
+ if missing:
+ msg = f"manifest missing required keys: {', '.join(missing)}"
+ raise ManifestError(msg)
+ methods_raw = mapping["methods"]
+ if not isinstance(methods_raw, Sequence) or isinstance(methods_raw, (str, bytes)):
+ msg = "methods must be a sequence of mappings"
+ raise ManifestError(msg)
+ shifts_raw = mapping.get("ood_shifts", ())
+ if not isinstance(shifts_raw, Sequence) or isinstance(shifts_raw, (str, bytes)):
+ msg = "ood_shifts must be a sequence of mappings"
+ raise ManifestError(msg)
+ uq_raw = mapping.get("uq")
+ preprocess_raw = mapping.get("preprocessing")
+ return ExperimentManifest(
+ manifest_id=mapping["manifest_id"],
+ schema_version=mapping["schema_version"],
+ track=mapping["track"],
+ dataset=_dataset_from_mapping(mapping["dataset"]),
+ split=_split_from_mapping(mapping["split"]),
+ preprocessing=(
+ _preprocess_from_mapping(preprocess_raw)
+ if preprocess_raw is not None
+ else PreprocessingSpec()
+ ),
+ methods=tuple(_method_from_mapping(item) for item in methods_raw),
+ seeds=tuple(mapping["seeds"]),
+ horizons=tuple(mapping["horizons"]),
+ metrics=tuple(mapping["metrics"]),
+ ood_shifts=tuple(_ood_from_mapping(item) for item in shifts_raw),
+ uq=None if uq_raw is None else _uq_from_mapping(uq_raw),
+ compute_budget=_budget_from_mapping(mapping["compute_budget"]),
+ controls=tuple(mapping["controls"]),
+ )
+
+
+def manifest_to_mapping(manifest: ExperimentManifest) -> dict[str, Any]:
+ """Return a JSON-friendly nested mapping.
+
+ Tuples become lists. ``uq`` is ``None`` when unset.
+
+ Parameters
+ ----------
+ manifest : ExperimentManifest
+ Validated record.
+
+ Returns
+ -------
+ dict
+ Nested mapping suitable for JSON or YAML dump.
+ """
+ return asdict(manifest)
diff --git a/src/koopman_graph/cli/benchmark.py b/src/koopman_graph/cli/benchmark.py
new file mode 100644
index 0000000..3aca050
--- /dev/null
+++ b/src/koopman_graph/cli/benchmark.py
@@ -0,0 +1,60 @@
+"""``koopman-graph benchmark`` — identity-bound run / verify handlers.
+
+Handlers lazy-import :mod:`koopman_graph.benchmark` so that
+``import koopman_graph.cli`` does not load the benchmark package.
+They do **not** fit :class:`~koopman_graph.model.GraphKoopmanModel`.
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+
+
+def handle_benchmark_run(args: argparse.Namespace) -> int:
+ """Argparse handler for ``benchmark run``.
+
+ Parameters
+ ----------
+ args : argparse.Namespace
+ Parsed namespace with ``manifest``, ``data``, and ``out``.
+
+ Returns
+ -------
+ int
+ ``0`` on success; ``1`` on schema / I/O / import errors.
+ """
+ try:
+ from koopman_graph.benchmark.runner import run_manifest
+
+ destination = run_manifest(args.manifest, args.data, args.out)
+ except (ValueError, FileNotFoundError, OSError, ImportError, TypeError) as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 1
+ print(f"wrote summary: {destination}")
+ return 0
+
+
+def handle_benchmark_verify(args: argparse.Namespace) -> int:
+ """Argparse handler for ``benchmark verify``.
+
+ Parameters
+ ----------
+ args : argparse.Namespace
+ Parsed namespace with ``manifest`` and ``against``.
+
+ Returns
+ -------
+ int
+ ``0`` on success; ``1`` on tamper / schema / I/O errors.
+ """
+ try:
+ from koopman_graph.benchmark.runner import resolve_summary_path, verify_summary
+
+ verify_summary(args.manifest, args.against)
+ destination = resolve_summary_path(args.against)
+ except (ValueError, FileNotFoundError, OSError, ImportError, TypeError) as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 1
+ print(f"verified summary: {destination}")
+ return 0
diff --git a/src/koopman_graph/cli/main.py b/src/koopman_graph/cli/main.py
index 8946698..70fa0a8 100644
--- a/src/koopman_graph/cli/main.py
+++ b/src/koopman_graph/cli/main.py
@@ -12,16 +12,19 @@
def _build_parser() -> argparse.ArgumentParser:
- """Construct the root parser (subcommands added in later tasks).
+ """Construct the root parser.
Returns
-------
argparse.ArgumentParser
- Root parser with ``version``, ``train``, and ``predict`` subcommands.
+ Root parser with ``version``, ``train``, ``predict``, and
+ ``benchmark`` subcommands.
"""
parser = argparse.ArgumentParser(
prog=_PROG,
- description=("Config-driven train / predict entry points for KoopmanGraph."),
+ description=(
+ "Config-driven train / predict / benchmark entry points for KoopmanGraph."
+ ),
)
parser.add_argument(
"--version",
@@ -94,6 +97,59 @@ def _build_parser() -> argparse.ArgumentParser:
help="Output .pt path for the forecast payload.",
)
predict_parser.set_defaults(_handler=_handle_predict)
+
+ bench = subparsers.add_parser(
+ "benchmark",
+ help=(
+ "Write or verify identity-bound benchmark summaries "
+ "(does not train models)."
+ ),
+ description=(
+ "Verify a dataset SHA-256 against an ExperimentManifest, "
+ "write a canonical summary.json, or recompute that digest. "
+ "This command does not fit GraphKoopmanModel or GNN teaching "
+ "ports and does not invent forecast metrics."
+ ),
+ )
+ bench_sub = bench.add_subparsers(dest="benchmark_command", metavar="COMMAND")
+ bench.set_defaults(_handler=_handle_benchmark_root, _benchmark_parser=bench)
+
+ run_parser = bench_sub.add_parser(
+ "run",
+ help="Verify --data against the manifest and write summary.json.",
+ )
+ run_parser.add_argument(
+ "--manifest",
+ required=True,
+ help="Path to a benchmark manifest (.json / .yaml / .yml).",
+ )
+ run_parser.add_argument(
+ "--data",
+ required=True,
+ help="Dataset payload whose SHA-256 must match dataset.sha256.",
+ )
+ run_parser.add_argument(
+ "--out",
+ required=True,
+ help="Directory that will receive summary.json.",
+ )
+ run_parser.set_defaults(_handler=_handle_benchmark_run)
+
+ verify_parser = bench_sub.add_parser(
+ "verify",
+ help="Reject a tampered or unbound summary.json.",
+ )
+ verify_parser.add_argument(
+ "--manifest",
+ required=True,
+ help="Path to a benchmark manifest (.json / .yaml / .yml).",
+ )
+ verify_parser.add_argument(
+ "--against",
+ required=True,
+ help="summary.json file, or a directory containing summary.json.",
+ )
+ verify_parser.set_defaults(_handler=_handle_benchmark_verify)
return parser
@@ -151,6 +207,60 @@ def _handle_predict(args: argparse.Namespace) -> int:
return handle_predict(args)
+def _handle_benchmark_root(args: argparse.Namespace) -> int:
+ """Print ``benchmark`` help when no nested command is given.
+
+ Parameters
+ ----------
+ args : argparse.Namespace
+ Parsed ``benchmark`` arguments; uses ``_benchmark_parser``.
+
+ Returns
+ -------
+ int
+ Always ``0``.
+ """
+ parser = args._benchmark_parser
+ parser.print_help()
+ return 0
+
+
+def _handle_benchmark_run(args: argparse.Namespace) -> int:
+ """Delegate to :func:`koopman_graph.cli.benchmark.handle_benchmark_run`.
+
+ Parameters
+ ----------
+ args : argparse.Namespace
+ Parsed ``run`` arguments (``manifest``, ``data``, ``out``).
+
+ Returns
+ -------
+ int
+ Process exit code from the run handler.
+ """
+ from koopman_graph.cli.benchmark import handle_benchmark_run
+
+ return handle_benchmark_run(args)
+
+
+def _handle_benchmark_verify(args: argparse.Namespace) -> int:
+ """Delegate to :func:`koopman_graph.cli.benchmark.handle_benchmark_verify`.
+
+ Parameters
+ ----------
+ args : argparse.Namespace
+ Parsed ``verify`` arguments (``manifest``, ``against``).
+
+ Returns
+ -------
+ int
+ Process exit code from the verify handler.
+ """
+ from koopman_graph.cli.benchmark import handle_benchmark_verify
+
+ return handle_benchmark_verify(args)
+
+
def main(argv: Sequence[str] | None = None) -> int:
"""CLI entry point used by ``[project.scripts]`` and ``python -m``.
diff --git a/src/koopman_graph/data/__init__.py b/src/koopman_graph/data/__init__.py
index ed6429d..bb738ac 100644
--- a/src/koopman_graph/data/__init__.py
+++ b/src/koopman_graph/data/__init__.py
@@ -22,6 +22,31 @@
``MultiTrajectory``, ``as_multi_trajectory``, ``resolve_sequence``,
``resolve_hetero_sequence``, ``SnapshotSequence``, and
``resolve_pair_delta_t``.
+``batching``
+ Opt-in PyG ``Batch`` collate for independent homogeneous trajectories
+ (``BatchedGraphTrajectory``, ``collate_graph_trajectories``). Package
+ export; not on root ``__all__``. Must not import ``nn``.
+``graph_state``
+ Frozen ``GraphDynamicsConfig`` and ``GraphStateSnapshot`` supervision
+ records for opt-in graph-state closure. Package export; not on root
+ ``__all__``. Must not import ``nn``.
+``conditioning``
+ Frozen ``ConditioningContext`` and :func:`conditioning_at` for
+ per-snapshot :math:`(\\mu, t, u)` records. Homogeneous
+ ``parameter_trajectory`` only. Package export; not on root
+ ``__all__``. Must not import ``nn``. Distinct from switched /
+ mixture operators.
+``calendar``
+ :func:`~koopman_graph.data.diurnal_control_features` and
+ :func:`~koopman_graph.data.diurnal_phase_index` time-of-day
+ recipes for existing control / switched ``phase_index``. Package
+ export; not on root ``__all__``. Not a calendar serializer. Must
+ not import ``nn``.
+``remap``
+ :class:`~koopman_graph.data.EntityRemap` and
+ :func:`~koopman_graph.data.remap_node_features` for injective
+ placement into a finite :math:`N_{\\max}`. Not entity resolution;
+ unbounded growth is refused. Package export; not on root ``__all__``.
``delay_windows``
Hankel-style stack / flatten / observation-mask helpers shared by
containers, ``nn.delay``, and model encode paths. Power-user submodule;
@@ -41,10 +66,26 @@
Shared rollout-origin resolution for training and forecast evaluation.
"""
+from koopman_graph.data.batching import (
+ BatchedGraphTrajectory,
+ collate_graph_trajectories,
+)
+from koopman_graph.data.calendar import (
+ diurnal_control_features,
+ diurnal_phase_index,
+)
+from koopman_graph.data.conditioning import ConditioningContext, conditioning_at
from koopman_graph.data.containers import (
GraphSnapshotSequence,
HeteroGraphSnapshotSequence,
)
+from koopman_graph.data.graph_state import (
+ DEFAULT_CANDIDATE_K,
+ GraphDynamicsConfig,
+ GraphStateSnapshot,
+ graph_dynamics_from_mapping,
+ graph_state_at,
+)
from koopman_graph.data.hetero_layout import (
global_relation_edge_indices,
latent_type_slices,
@@ -64,7 +105,7 @@
induced_cluster_subgraph,
iter_cluster_subgraphs,
)
-from koopman_graph.data.remap import remap_node_features
+from koopman_graph.data.remap import EntityRemap, remap_node_features
from koopman_graph.data.rollout import (
RolloutStartIndices,
resolve_rollout_start_indices,
@@ -88,7 +129,13 @@
)
__all__ = [
+ "BatchedGraphTrajectory",
+ "ConditioningContext",
+ "DEFAULT_CANDIDATE_K",
+ "EntityRemap",
+ "GraphDynamicsConfig",
"GraphSnapshotSequence",
+ "GraphStateSnapshot",
"HeteroGraphSnapshotSequence",
"MultiTrajectory",
"NeighborWindowSampler",
@@ -100,11 +147,17 @@
"WindowSampler",
"as_multi_trajectory",
"build_window_index_list",
+ "collate_graph_trajectories",
"cluster_node_partition",
+ "conditioning_at",
+ "diurnal_control_features",
+ "diurnal_phase_index",
"induced_cluster_subgraph",
"iter_cluster_subgraphs",
"remap_node_features",
"global_relation_edge_indices",
+ "graph_dynamics_from_mapping",
+ "graph_state_at",
"latent_type_slices",
"latent_type_slices_from_dims",
"mask_hetero_snapshot_features",
diff --git a/src/koopman_graph/data/batching.py b/src/koopman_graph/data/batching.py
new file mode 100644
index 0000000..a0083f0
--- /dev/null
+++ b/src/koopman_graph/data/batching.py
@@ -0,0 +1,233 @@
+"""Collate independent homogeneous snapshot sequences into one PyG ``Batch``.
+
+Power-user helper for opt-in multi-graph training. Shared
+:math:`K_{\\mathrm{self}}` / :math:`K_k` apply to the disconnected union;
+per-graph shifts stay block-diagonal because ``Batch.from_data_list`` offsets
+``edge_index``. This module lives in :mod:`koopman_graph.data` and must not
+import :mod:`koopman_graph.nn`.
+
+Component order is **time-major then trajectory**: snapshot ``(t, g)`` is
+component ``t * num_trajectories + g``. :attr:`time_index` and
+:attr:`graph_index` have length ``num_trajectories * num_timesteps``.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+from torch import Tensor
+from torch_geometric.data import Batch, Data
+
+from koopman_graph.data.containers import GraphSnapshotSequence
+
+__all__ = [
+ "BatchedGraphTrajectory",
+ "collate_graph_trajectories",
+]
+
+
+@dataclass(frozen=True)
+class BatchedGraphTrajectory:
+ """Disconnected union of ``(trajectory, time)`` homogeneous snapshots.
+
+ Attributes
+ ----------
+ batch : Batch
+ PyG batch whose graphs are the flattened ``(t, g)`` snapshots.
+ time_index : Tensor
+ Integer time of each component, shape ``(num_components,)``.
+ graph_index : Tensor
+ Integer trajectory index of each component, shape
+ ``(num_components,)``.
+ """
+
+ batch: Batch
+ time_index: Tensor
+ graph_index: Tensor
+
+ @property
+ def num_trajectories(self) -> int:
+ """Return the number of collated trajectories.
+
+ Returns
+ -------
+ int
+ ``1 + max(graph_index)`` when any components exist, else ``0``.
+ """
+ if self.graph_index.numel() == 0:
+ return 0
+ return int(self.graph_index.max().item()) + 1
+
+ @property
+ def num_timesteps(self) -> int:
+ """Return the shared number of snapshots per trajectory.
+
+ Returns
+ -------
+ int
+ ``1 + max(time_index)`` when any components exist, else ``0``.
+ """
+ if self.time_index.numel() == 0:
+ return 0
+ return int(self.time_index.max().item()) + 1
+
+ @property
+ def num_components(self) -> int:
+ """Return the number of disjoint ``(t, g)`` graphs in :attr:`batch`.
+
+ Returns
+ -------
+ int
+ Length of :attr:`time_index`.
+ """
+ return int(self.time_index.numel())
+
+ def graphs_at_time(self, timestep: int) -> Batch:
+ """Return the disconnected union of all trajectories at ``timestep``.
+
+ The returned ``Batch.batch`` vector is ``0 … G-1`` in trajectory
+ order. Edges do not cross graphs.
+
+ Parameters
+ ----------
+ timestep : int
+ Time index in ``[0, num_timesteps)``.
+
+ Returns
+ -------
+ Batch
+ One component per trajectory at ``timestep``.
+
+ Raises
+ ------
+ ValueError
+ If ``timestep`` is out of range.
+ """
+ n_times = self.num_timesteps
+ if timestep < 0 or timestep >= n_times:
+ msg = f"timestep must be in [0, {n_times}), got {timestep}"
+ raise ValueError(msg)
+ n_traj = self.num_trajectories
+ start = timestep * n_traj
+ data_list = self.batch.to_data_list()
+ return Batch.from_data_list(data_list[start : start + n_traj])
+
+
+def _validate_collate_sequences(
+ sequences: Sequence[GraphSnapshotSequence],
+) -> tuple[int, int]:
+ """Return ``(num_trajectories, num_timesteps)`` after layout checks.
+
+ Parameters
+ ----------
+ sequences : sequence of GraphSnapshotSequence
+ Candidate trajectories.
+
+ Returns
+ -------
+ tuple of int
+ Trajectory count and shared snapshot count.
+
+ Raises
+ ------
+ TypeError
+ If any element is not a :class:`GraphSnapshotSequence`.
+ ValueError
+ If the input is empty, lengths or feature widths differ, or a
+ sequence carries controls or hyperedges.
+ """
+ if not sequences:
+ msg = "collate_graph_trajectories requires at least one sequence"
+ raise ValueError(msg)
+ for index, sequence in enumerate(sequences):
+ if not isinstance(sequence, GraphSnapshotSequence):
+ msg = (
+ "collate_graph_trajectories requires GraphSnapshotSequence "
+ f"instances; index {index} has type {type(sequence).__name__}"
+ )
+ raise TypeError(msg)
+ if sequence.has_controls:
+ msg = (
+ "collate_graph_trajectories does not support control_inputs; "
+ f"sequence {index} has controls"
+ )
+ raise ValueError(msg)
+ if sequence.has_hyperedges:
+ msg = (
+ "collate_graph_trajectories does not support hyperedge "
+ f"incidence; sequence {index} has hyperedges"
+ )
+ raise ValueError(msg)
+ if sequence.has_timestamps:
+ msg = (
+ "collate_graph_trajectories does not support timestamps; "
+ f"sequence {index} has timestamps"
+ )
+ raise ValueError(msg)
+ if sequence.has_parameter_trajectory:
+ msg = (
+ "collate_graph_trajectories does not support "
+ "parameter_trajectory; "
+ f"sequence {index} has parameter_trajectory"
+ )
+ raise ValueError(msg)
+ lengths = [sequence.num_timesteps for sequence in sequences]
+ if len(set(lengths)) != 1:
+ msg = f"collate_graph_trajectories requires equal num_timesteps; got {lengths}"
+ raise ValueError(msg)
+ channels = [sequence.in_channels for sequence in sequences]
+ if len(set(channels)) != 1:
+ msg = f"collate_graph_trajectories requires equal in_channels; got {channels}"
+ raise ValueError(msg)
+ return len(sequences), lengths[0]
+
+
+def collate_graph_trajectories(
+ sequences: Sequence[GraphSnapshotSequence],
+) -> BatchedGraphTrajectory:
+ """Collate homogeneous trajectories into one disconnected PyG ``Batch``.
+
+ Snapshots are cloned so the source sequences are not mutated with a
+ ``batch`` vector. Component ``t * G + g`` is trajectory ``g`` at time
+ ``t``.
+
+ Parameters
+ ----------
+ sequences : sequence of GraphSnapshotSequence
+ Non-empty homogeneous trajectories sharing ``num_timesteps`` and
+ ``in_channels``. Node counts may differ across trajectories.
+
+ Returns
+ -------
+ BatchedGraphTrajectory
+ Union batch plus per-component time and graph index tensors.
+
+ Raises
+ ------
+ TypeError
+ If any element is not a :class:`GraphSnapshotSequence`.
+ ValueError
+ If sequences are empty, lengths differ, feature widths differ, or
+ a sequence carries controls, timestamps, or hyperedges.
+ """
+ num_trajectories, num_timesteps = _validate_collate_sequences(sequences)
+ data_list: list[Data] = []
+ time_ids: list[int] = []
+ graph_ids: list[int] = []
+ for timestep in range(num_timesteps):
+ for graph_id, sequence in enumerate(sequences):
+ snapshot = sequence[timestep]
+ data_list.append(snapshot.clone())
+ time_ids.append(timestep)
+ graph_ids.append(graph_id)
+ batch = Batch.from_data_list(data_list)
+ device = batch.x.device if batch.x is not None else torch.device("cpu")
+ time_index = torch.tensor(time_ids, dtype=torch.long, device=device)
+ graph_index = torch.tensor(graph_ids, dtype=torch.long, device=device)
+ return BatchedGraphTrajectory(
+ batch=batch,
+ time_index=time_index,
+ graph_index=graph_index,
+ )
diff --git a/src/koopman_graph/data/calendar.py b/src/koopman_graph/data/calendar.py
new file mode 100644
index 0000000..7dc29b8
--- /dev/null
+++ b/src/koopman_graph/data/calendar.py
@@ -0,0 +1,183 @@
+"""Time-of-day control and phase-index recipes.
+
+These helpers turn caller timestamps into additive / bilinear control
+features or integer phase bins. They are not a calendar serializer and
+do not change discrete uniform-:math:`\\Delta t` validation. This module
+must not import :mod:`koopman_graph.nn` or :mod:`koopman_graph.model`.
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Sequence
+
+import torch
+from torch import Tensor
+
+DEFAULT_HARMONICS = 1
+MAX_HARMONICS = 8
+
+__all__ = [
+ "DEFAULT_HARMONICS",
+ "MAX_HARMONICS",
+ "diurnal_control_features",
+ "diurnal_phase_index",
+]
+
+
+def _as_1d_timestamps(timestamps: Sequence[float] | Tensor) -> Tensor:
+ """Coerce timestamps to a finite 1-D float tensor.
+
+ Parameters
+ ----------
+ timestamps : sequence of float or Tensor
+ Caller times in one shared unit.
+
+ Returns
+ -------
+ Tensor
+ Shape ``(T,)`` with ``T >= 1``.
+
+ Raises
+ ------
+ ValueError
+ If the input is empty, not 1-D after a scalar wrap, or non-finite.
+ """
+ tensor = torch.as_tensor(timestamps)
+ if tensor.ndim == 0:
+ tensor = tensor.reshape(1)
+ if tensor.ndim != 1:
+ msg = (
+ "timestamps must be a scalar or 1-D sequence, "
+ f"got shape {tuple(tensor.shape)}"
+ )
+ raise ValueError(msg)
+ if tensor.numel() < 1:
+ msg = "timestamps must contain at least one value"
+ raise ValueError(msg)
+ values = tensor.to(dtype=torch.float64)
+ if not bool(torch.all(torch.isfinite(values))):
+ msg = "timestamps must be finite"
+ raise ValueError(msg)
+ return values
+
+
+def _require_positive_period(period: float) -> float:
+ """Validate the shared period.
+
+ Parameters
+ ----------
+ period : float
+ Cycle length in the same unit as the timestamps.
+
+ Returns
+ -------
+ float
+ The accepted period.
+
+ Raises
+ ------
+ ValueError
+ If ``period`` is not a positive finite float.
+ """
+ scale = float(period)
+ if not math.isfinite(scale) or scale <= 0.0:
+ msg = f"period must be a positive finite float, got {period!r}"
+ raise ValueError(msg)
+ return scale
+
+
+def diurnal_control_features(
+ timestamps: Sequence[float] | Tensor,
+ *,
+ period: float,
+ harmonics: int = DEFAULT_HARMONICS,
+) -> Tensor:
+ """Return Fourier time-of-day features for additive / bilinear control.
+
+ For harmonic :math:`k=1,\\ldots,H` the columns are
+ :math:`\\sin(2\\pi k t / T)` and :math:`\\cos(2\\pi k t / T)` in the
+ same time unit as ``timestamps`` and ``period``. Pass the result as
+ ``control_inputs`` on a homogeneous sequence. This is a documented
+ recipe, not a native calendar field, and it does not relax discrete
+ uniform-:math:`\\Delta t` validation.
+
+ Parameters
+ ----------
+ timestamps : sequence of float or Tensor
+ Times with shape ``(T,)`` (or a scalar) in the caller unit.
+ period : float
+ Cycle length :math:`T` in the same unit (for example 24 hours
+ when timestamps are hours).
+ harmonics : int, optional
+ Number of Fourier pairs. Default is 1 (one sine and one cosine).
+ Maximum is 8.
+
+ Returns
+ -------
+ Tensor
+ Features with shape ``(T, 2 H)`` and dtype ``float32``.
+
+ Raises
+ ------
+ ValueError
+ If ``timestamps`` or ``period`` are invalid, or ``harmonics`` is
+ outside ``[1, 8]``.
+ """
+ times = _as_1d_timestamps(timestamps)
+ cycle = _require_positive_period(period)
+ if type(harmonics) is not int or not 1 <= harmonics <= MAX_HARMONICS:
+ msg = f"harmonics must be an int in [1, {MAX_HARMONICS}], got {harmonics!r}"
+ raise ValueError(msg)
+ angle = (2.0 * math.pi) * times / cycle
+ columns: list[Tensor] = []
+ for order in range(1, harmonics + 1):
+ scaled = float(order) * angle
+ columns.append(torch.sin(scaled))
+ columns.append(torch.cos(scaled))
+ return torch.stack(columns, dim=-1).to(dtype=torch.float32)
+
+
+def diurnal_phase_index(
+ timestamps: Sequence[float] | Tensor,
+ *,
+ period: float,
+ num_phases: int,
+) -> Tensor:
+ """Return integer phase bins for switched-by-clock maps.
+
+ Each time is reduced modulo ``period`` and assigned
+ :math:`\\lfloor n \\, \\mathrm{frac}(t / T) \\rfloor` in
+ ``[0, num_phases)``. Use the bins as ``phase_index`` on
+ :class:`~koopman_graph.operators.SwitchedKoopmanOperator`. Discrete
+ uniform-:math:`\\Delta t` validation is unchanged.
+
+ Parameters
+ ----------
+ timestamps : sequence of float or Tensor
+ Times with shape ``(T,)`` (or a scalar) in the caller unit.
+ period : float
+ Cycle length :math:`T` in the same unit.
+ num_phases : int
+ Number of bins (typically the switched ``num_modes``).
+
+ Returns
+ -------
+ Tensor
+ Integer indices with shape ``(T,)`` and dtype ``int64``.
+
+ Raises
+ ------
+ ValueError
+ If ``timestamps`` or ``period`` are invalid, or ``num_phases``
+ is not a positive int.
+ """
+ times = _as_1d_timestamps(timestamps)
+ cycle = _require_positive_period(period)
+ if type(num_phases) is not int or num_phases < 1:
+ msg = f"num_phases must be a positive int, got {num_phases!r}"
+ raise ValueError(msg)
+ fraction = torch.remainder(times, cycle) / cycle
+ raw = torch.floor(float(num_phases) * fraction)
+ clipped = torch.clamp(raw, min=0, max=num_phases - 1)
+ return clipped.to(dtype=torch.int64)
diff --git a/src/koopman_graph/data/conditioning.py b/src/koopman_graph/data/conditioning.py
new file mode 100644
index 0000000..acf0c18
--- /dev/null
+++ b/src/koopman_graph/data/conditioning.py
@@ -0,0 +1,193 @@
+"""Per-snapshot time, parameter, and control records.
+
+A :class:`ConditioningContext` is a data record for one timestep. It does
+not select a factory operator. ``koopman="switched"`` and
+``koopman="mixture"`` remain latent-gated or piecewise LTI maps, not a
+parameter interpolant :math:`K(\\mu)` (``Macesic2018Nonautonomous``).
+
+This module must not import :mod:`koopman_graph.nn`.
+``koopman="parametric"`` evaluates :math:`K(\\mu)` from the stored
+trajectory; this record does not select that factory kind.
+"""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass
+
+import torch
+from torch import Tensor
+
+from koopman_graph.data.calendar import diurnal_phase_index
+from koopman_graph.data.containers import GraphSnapshotSequence
+
+__all__ = [
+ "ConditioningContext",
+ "conditioning_at",
+]
+
+
+@dataclass(frozen=True, eq=False)
+class ConditioningContext:
+ """Exogenous conditioning at one homogeneous snapshot.
+
+ Attributes
+ ----------
+ time : float or None
+ Timestamp at this index in the caller's time unit, or ``None``
+ when the sequence has no timestamps.
+ parameters : Tensor or None
+ Regime coordinates :math:`\\mu` with shape ``(d_mu,)``. Units are
+ caller-defined (dimensionless if unspecified). ``None`` when
+ the sequence has no
+ :attr:`~koopman_graph.data.GraphSnapshotSequence.parameter_trajectory`.
+ control : Tensor or None
+ Control driving the transition from this snapshot, same layout as
+ :meth:`~koopman_graph.data.GraphSnapshotSequence.control_at`
+ (global ``(C,)`` or per-node ``(N, C)``). ``None`` when controls
+ are absent.
+ phase_index : int or None
+ Optional diurnal or switched-by-clock index. Default
+ :func:`conditioning_at` leaves this ``None``. Pass ``period``
+ and ``num_phases`` to fill it from timestamps via
+ :func:`~koopman_graph.data.diurnal_phase_index`.
+
+ Notes
+ -----
+ Equality is disabled because tensor fields do not define value
+ equality. This record does not itself evaluate :math:`K(\\mu)`;
+ ``koopman="parametric"`` does. Discrete uniform-:math:`\\Delta t`
+ validation is unchanged.
+
+ References
+ ----------
+ Maćešić, S., Črnjarić-Žic, N. and Mezić, I. (2018). Koopman operator
+ family spectrum for nonautonomous systems. *SIAM Journal on Applied
+ Dynamical Systems* 17:2478–2515. doi:10.1137/17M1133610
+ (``Macesic2018Nonautonomous``).
+ """
+
+ time: float | None
+ parameters: Tensor | None
+ control: Tensor | None
+ phase_index: int | None = None
+
+ def __post_init__(self) -> None:
+ """Validate optional tensor ranks and scalar fields.
+
+ Raises
+ ------
+ ValueError
+ If a present field has an invalid rank, width, or value.
+ """
+ if self.time is not None and not math.isfinite(self.time):
+ msg = f"time must be a finite float, got {self.time!r}"
+ raise ValueError(msg)
+ if self.parameters is not None:
+ if self.parameters.ndim != 1 or int(self.parameters.shape[0]) < 1:
+ msg = (
+ "parameters must have shape (d_mu,) with d_mu >= 1, "
+ f"got {tuple(self.parameters.shape)}"
+ )
+ raise ValueError(msg)
+ if self.parameters.is_floating_point() and not torch.all(
+ torch.isfinite(self.parameters)
+ ):
+ msg = "parameters must be finite"
+ raise ValueError(msg)
+ if self.control is not None and self.control.ndim not in (1, 2):
+ msg = (
+ "control must have shape (C,) or (N, C), "
+ f"got {tuple(self.control.shape)}"
+ )
+ raise ValueError(msg)
+ if self.phase_index is not None and (
+ type(self.phase_index) is not int or self.phase_index < 0
+ ):
+ msg = (
+ "phase_index must be a non-negative int or None, "
+ f"got {self.phase_index!r}"
+ )
+ raise ValueError(msg)
+
+
+def conditioning_at(
+ sequence: GraphSnapshotSequence,
+ index: int,
+ *,
+ period: float | None = None,
+ num_phases: int | None = None,
+) -> ConditioningContext:
+ """Return the exogenous record at ``index``.
+
+ Parameters
+ ----------
+ sequence : GraphSnapshotSequence
+ Homogeneous snapshot sequence.
+ index : int
+ Timestep in ``[0, num_timesteps)``.
+ period : float or None, optional
+ Cycle length in the timestamp unit. Required with ``num_phases``
+ to fill :attr:`ConditioningContext.phase_index`.
+ num_phases : int or None, optional
+ Number of phase bins. Required with ``period``.
+
+ Returns
+ -------
+ ConditioningContext
+ Time, :math:`\\mu`, and control at ``index``. ``phase_index`` is
+ ``None`` unless ``period`` and ``num_phases`` are set. Missing
+ sequence fields yield ``None`` payload slots; an all-``None``
+ record is valid.
+
+ Raises
+ ------
+ TypeError
+ If ``sequence`` is not a homogeneous
+ :class:`~koopman_graph.data.GraphSnapshotSequence`.
+ ValueError
+ If ``index`` is out of range, only one of ``period`` /
+ ``num_phases`` is set, or phase bins are requested without
+ timestamps.
+ """
+ if type(sequence) is not GraphSnapshotSequence:
+ msg = (
+ "conditioning_at requires GraphSnapshotSequence, "
+ f"got {type(sequence).__name__}"
+ )
+ raise TypeError(msg)
+ num_timesteps = sequence.num_timesteps
+ if index < 0 or index >= num_timesteps:
+ msg = (
+ f"conditioning index {index} is out of range for {num_timesteps} timesteps"
+ )
+ raise ValueError(msg)
+ time = None
+ if sequence.timestamps is not None:
+ time = float(sequence.timestamps[index].item())
+ parameters = None
+ if sequence.parameter_trajectory is not None:
+ parameters = sequence.parameter_trajectory[index]
+ control = None
+ if sequence.has_controls:
+ control = sequence.control_at(index)
+ phase_index = None
+ if (period is None) != (num_phases is None):
+ msg = "period and num_phases must be supplied together"
+ raise ValueError(msg)
+ if period is not None and num_phases is not None:
+ if sequence.timestamps is None:
+ msg = "phase_index requires sequence timestamps"
+ raise ValueError(msg)
+ bins = diurnal_phase_index(
+ sequence.timestamps[index : index + 1],
+ period=period,
+ num_phases=num_phases,
+ )
+ phase_index = int(bins[0].item())
+ return ConditioningContext(
+ time=time,
+ parameters=parameters,
+ control=control,
+ phase_index=phase_index,
+ )
diff --git a/src/koopman_graph/data/construction.py b/src/koopman_graph/data/construction.py
index 9f858f2..e3e5516 100644
--- a/src/koopman_graph/data/construction.py
+++ b/src/koopman_graph/data/construction.py
@@ -36,6 +36,8 @@ class ConstructedSnapshots:
Coerced control tensor, or ``None``.
timestamps : Tensor or None
Coerced timestamp tensor, or ``None``.
+ parameter_trajectory : Tensor or None
+ Coerced regime-coordinate tensor, or ``None``.
observation_masks : Tensor or None
Coerced boolean observation mask, or ``None``.
presence_masks : Tensor or None
@@ -47,6 +49,7 @@ class ConstructedSnapshots:
snapshots: list[Data]
control_inputs: Tensor | None = None
timestamps: Tensor | None = None
+ parameter_trajectory: Tensor | None = None
observation_masks: Tensor | None = None
presence_masks: Tensor | None = None
allow_dynamic_topology: bool = False
@@ -61,6 +64,7 @@ def build_snapshots_from_arrays(
hyperedge_weight: ArrayLike | None = None,
control_inputs: ArrayLike | None = None,
timestamps: ArrayLike | None = None,
+ parameter_trajectory: ArrayLike | None = None,
observation_masks: ArrayLike | None = None,
presence_masks: ArrayLike | None = None,
dtype: torch.dtype = torch.float32,
@@ -88,6 +92,9 @@ def build_snapshots_from_arrays(
timestamps : array-like, optional
Strictly increasing physical timestamps with shape
``(num_timesteps,)``.
+ parameter_trajectory : array-like, optional
+ Per-snapshot regime coordinates with shape
+ ``(num_timesteps, d_mu)``.
observation_masks : array-like, optional
Per-timestep node observation mask with shape
``(num_timesteps, num_nodes)``.
@@ -117,6 +124,11 @@ def build_snapshots_from_arrays(
None if control_inputs is None else as_tensor(control_inputs, dtype=dtype)
)
times = None if timestamps is None else as_tensor(timestamps, dtype=dtype)
+ parameters = (
+ None
+ if parameter_trajectory is None
+ else as_tensor(parameter_trajectory, dtype=dtype)
+ )
masks = (
None
if observation_masks is None
@@ -181,6 +193,7 @@ def build_snapshots_from_arrays(
snapshots=snapshots,
control_inputs=controls,
timestamps=times,
+ parameter_trajectory=parameters,
observation_masks=masks,
presence_masks=presence,
allow_dynamic_topology=False,
@@ -194,6 +207,7 @@ def build_snapshots_from_dynamic_arrays(
edge_weights: Sequence[ArrayLike | None] | None = None,
control_inputs: ArrayLike | None = None,
timestamps: ArrayLike | None = None,
+ parameter_trajectory: ArrayLike | None = None,
observation_masks: ArrayLike | None = None,
presence_masks: ArrayLike | None = None,
dtype: torch.dtype = torch.float32,
@@ -216,6 +230,9 @@ def build_snapshots_from_dynamic_arrays(
timestamps : array-like, optional
Strictly increasing physical timestamps with shape
``(num_timesteps,)``.
+ parameter_trajectory : array-like, optional
+ Per-snapshot regime coordinates with shape
+ ``(num_timesteps, d_mu)``.
observation_masks : array-like, optional
Per-timestep node observation mask with shape
``(num_timesteps, num_nodes)``.
@@ -301,6 +318,11 @@ def build_snapshots_from_dynamic_arrays(
None if control_inputs is None else as_tensor(control_inputs, dtype=dtype)
)
times = None if timestamps is None else as_tensor(timestamps, dtype=dtype)
+ parameters = (
+ None
+ if parameter_trajectory is None
+ else as_tensor(parameter_trajectory, dtype=dtype)
+ )
masks = (
None
if observation_masks is None
@@ -313,6 +335,7 @@ def build_snapshots_from_dynamic_arrays(
snapshots=snapshots,
control_inputs=controls,
timestamps=times,
+ parameter_trajectory=parameters,
observation_masks=masks,
presence_masks=presence,
allow_dynamic_topology=True,
@@ -401,6 +424,10 @@ def build_windowed_snapshots(
if sequence.timestamps is not None:
timestamps = sequence.timestamps[ends]
+ parameter_trajectory = getattr(sequence, "parameter_trajectory", None)
+ if parameter_trajectory is not None:
+ parameter_trajectory = parameter_trajectory[ends]
+
observation_masks = None
if sequence.observation_masks is not None:
observation_masks = sequence.observation_masks[ends]
@@ -413,6 +440,7 @@ def build_windowed_snapshots(
snapshots=stacked_snapshots,
control_inputs=control_inputs,
timestamps=timestamps,
+ parameter_trajectory=parameter_trajectory,
observation_masks=observation_masks,
presence_masks=presence_masks,
allow_dynamic_topology=sequence.allow_dynamic_topology,
diff --git a/src/koopman_graph/data/containers.py b/src/koopman_graph/data/containers.py
index 8925955..27648e0 100644
--- a/src/koopman_graph/data/containers.py
+++ b/src/koopman_graph/data/containers.py
@@ -26,6 +26,7 @@
validate_hetero_snapshot_metadata,
validate_node_churn_policy,
validate_observation_masks,
+ validate_parameter_trajectory,
validate_presence_masks,
validate_shared_hetero_topology,
validate_shared_topology,
@@ -57,6 +58,11 @@ class GraphSnapshotSequence:
:class:`~koopman_graph.env.GraphKoopmanEnv` and
:class:`~koopman_graph.baselines.DMDcBaseline` are global-only (see
architecture control layout capability matrix). Optional
+ :attr:`parameter_trajectory` stores caller-defined regime coordinates
+ :math:`\\mu_t` with shape ``(T, d_mu)``. That tensor is a data record
+ for :class:`~koopman_graph.data.ConditioningContext`; it does not
+ select ``koopman="switched"`` / ``"mixture"`` or evaluate
+ :math:`K(\\mu)`. Optional
:attr:`observation_masks` mark which nodes are **measured** at each
timestep (``True`` = observed). Optional :attr:`presence_masks` mark
which entities **exist** in a fixed union universe of size ``N_max`` at
@@ -90,6 +96,7 @@ class GraphSnapshotSequence:
:attr:`hyperedge_weight`, :attr:`has_hyperedges`,
:attr:`is_dynamic_topology`, :attr:`control_inputs`, :attr:`has_controls`,
:attr:`control_dim`, :attr:`timestamps`, :attr:`has_timestamps`,
+ :attr:`parameter_trajectory`, :attr:`has_parameter_trajectory`,
:attr:`observation_masks`, :attr:`has_observation_masks`,
:attr:`presence_masks`, :attr:`has_presence_masks`,
:attr:`entity_ids`, :attr:`has_entity_ids`, :attr:`allow_node_churn`,
@@ -106,6 +113,7 @@ def __init__(
allow_dynamic_topology: bool = False,
control_inputs: Tensor | None = None,
timestamps: Tensor | None = None,
+ parameter_trajectory: Tensor | None = None,
observation_masks: Tensor | None = None,
presence_masks: Tensor | None = None,
entity_ids: Sequence[str | int] | None = None,
@@ -131,6 +139,11 @@ def __init__(
Strictly increasing physical timestamps with shape
``(num_timesteps,)``. When present, training uses per-pair
``Δt = timestamps[t+1] - timestamps[t]``.
+ parameter_trajectory : Tensor or None, optional
+ Per-snapshot regime coordinates with shape
+ ``(num_timesteps, d_mu)`` and ``d_mu >= 1``. Units are
+ caller-defined (dimensionless if unspecified). Carrying this
+ tensor does not change the default operator.
observation_masks : Tensor or None, optional
Per-timestep node observation mask with shape
``(num_timesteps, num_nodes)``. ``True`` (or ``1``) marks an
@@ -169,6 +182,11 @@ def __init__(
timestamps,
num_timesteps=num_timesteps,
)
+ if parameter_trajectory is not None:
+ validate_parameter_trajectory(
+ parameter_trajectory,
+ num_timesteps=num_timesteps,
+ )
validated_masks = None
if observation_masks is not None:
validated_masks = validate_observation_masks(
@@ -194,6 +212,7 @@ def __init__(
self._snapshots = tuple(snapshot_list)
self._control_inputs = control_inputs
self._timestamps = timestamps
+ self._parameter_trajectory = parameter_trajectory
self._observation_masks = validated_masks
self._presence_masks = validated_presence
self._entity_ids = validated_ids
@@ -214,6 +233,7 @@ def from_arrays(
hyperedge_weight: ArrayLike | None = None,
control_inputs: ArrayLike | None = None,
timestamps: ArrayLike | None = None,
+ parameter_trajectory: ArrayLike | None = None,
observation_masks: ArrayLike | None = None,
presence_masks: ArrayLike | None = None,
entity_ids: Sequence[str | int] | None = None,
@@ -243,6 +263,9 @@ def from_arrays(
timestamps : array-like, optional
Strictly increasing physical timestamps with shape
``(num_timesteps,)``.
+ parameter_trajectory : array-like, optional
+ Per-snapshot regime coordinates with shape
+ ``(num_timesteps, d_mu)``.
observation_masks : array-like, optional
Per-timestep node observation mask with shape
``(num_timesteps, num_nodes)``.
@@ -276,6 +299,7 @@ def from_arrays(
hyperedge_weight=hyperedge_weight,
control_inputs=control_inputs,
timestamps=timestamps,
+ parameter_trajectory=parameter_trajectory,
observation_masks=observation_masks,
presence_masks=presence_masks,
dtype=dtype,
@@ -284,6 +308,7 @@ def from_arrays(
built.snapshots,
control_inputs=built.control_inputs,
timestamps=built.timestamps,
+ parameter_trajectory=built.parameter_trajectory,
observation_masks=built.observation_masks,
presence_masks=built.presence_masks,
entity_ids=entity_ids,
@@ -299,6 +324,7 @@ def from_dynamic_arrays(
edge_weights: Sequence[ArrayLike | None] | None = None,
control_inputs: ArrayLike | None = None,
timestamps: ArrayLike | None = None,
+ parameter_trajectory: ArrayLike | None = None,
observation_masks: ArrayLike | None = None,
presence_masks: ArrayLike | None = None,
entity_ids: Sequence[str | int] | None = None,
@@ -323,6 +349,9 @@ def from_dynamic_arrays(
timestamps : array-like, optional
Strictly increasing physical timestamps with shape
``(num_timesteps,)``.
+ parameter_trajectory : array-like, optional
+ Per-snapshot regime coordinates with shape
+ ``(num_timesteps, d_mu)``.
observation_masks : array-like, optional
Per-timestep node observation mask with shape
``(num_timesteps, num_nodes)``.
@@ -354,6 +383,7 @@ def from_dynamic_arrays(
edge_weights=edge_weights,
control_inputs=control_inputs,
timestamps=timestamps,
+ parameter_trajectory=parameter_trajectory,
observation_masks=observation_masks,
presence_masks=presence_masks,
dtype=dtype,
@@ -363,6 +393,7 @@ def from_dynamic_arrays(
allow_dynamic_topology=built.allow_dynamic_topology,
control_inputs=built.control_inputs,
timestamps=built.timestamps,
+ parameter_trajectory=built.parameter_trajectory,
observation_masks=built.observation_masks,
presence_masks=built.presence_masks,
entity_ids=entity_ids,
@@ -453,6 +484,28 @@ def has_timestamps(self) -> bool:
"""
return self._timestamps is not None
+ @property
+ def parameter_trajectory(self) -> Tensor | None:
+ """Return per-snapshot regime coordinates when present.
+
+ Returns
+ -------
+ Tensor or None
+ Coordinates with shape ``(num_timesteps, d_mu)``.
+ """
+ return self._parameter_trajectory
+
+ @property
+ def has_parameter_trajectory(self) -> bool:
+ """Return whether the sequence carries regime coordinates.
+
+ Returns
+ -------
+ bool
+ ``True`` when :attr:`parameter_trajectory` is not ``None``.
+ """
+ return self._parameter_trajectory is not None
+
def delta_t_at(self, index: int) -> Tensor:
"""Return ``timestamps[index + 1] - timestamps[index]``.
@@ -1002,6 +1055,11 @@ def slice(self, start: int, stop: int) -> GraphSnapshotSequence:
timestamps=(
None if self.timestamps is None else self.timestamps[start:stop]
),
+ parameter_trajectory=(
+ None
+ if self.parameter_trajectory is None
+ else self.parameter_trajectory[start:stop]
+ ),
observation_masks=(
None
if self.observation_masks is None
@@ -1069,6 +1127,7 @@ def windowed(
allow_dynamic_topology=built.allow_dynamic_topology,
control_inputs=built.control_inputs,
timestamps=built.timestamps,
+ parameter_trajectory=built.parameter_trajectory,
observation_masks=built.observation_masks,
presence_masks=built.presence_masks,
entity_ids=self.entity_ids,
diff --git a/src/koopman_graph/data/graph_state.py b/src/koopman_graph/data/graph_state.py
new file mode 100644
index 0000000..61a1b2b
--- /dev/null
+++ b/src/koopman_graph/data/graph_state.py
@@ -0,0 +1,296 @@
+"""Graph-state snapshots and frozen dynamics config.
+
+This module must not import :mod:`koopman_graph.nn`. Topology heads live
+in :mod:`koopman_graph.nn.predicted_topology`; this file only records
+targets and the config that selects a head kind.
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Mapping
+from dataclasses import dataclass
+from typing import Any, Literal
+
+import torch
+from torch import Tensor
+
+from koopman_graph.data.containers import GraphSnapshotSequence
+from koopman_graph.graph_utils import snapshot_edge_weight
+
+TopologyHeadKind = Literal["none", "sparse_candidate", "dense_mlp"]
+
+TOPOLOGY_HEAD_KINDS: frozenset[str] = frozenset(
+ {"none", "sparse_candidate", "dense_mlp"}
+)
+DEFAULT_CANDIDATE_K = 8
+_CONFIG_KEYS: frozenset[str] = frozenset(
+ {
+ "topology_head",
+ "recursive_training",
+ "topology_loss_weight",
+ "presence_loss_weight",
+ "candidate_k",
+ }
+)
+
+__all__ = [
+ "DEFAULT_CANDIDATE_K",
+ "TOPOLOGY_HEAD_KINDS",
+ "GraphDynamicsConfig",
+ "GraphStateSnapshot",
+ "TopologyHeadKind",
+ "graph_dynamics_from_mapping",
+ "graph_state_at",
+]
+
+
+@dataclass(frozen=True, eq=False)
+class GraphStateSnapshot:
+ """One homogeneous graph-state target at a single timestep.
+
+ Attributes
+ ----------
+ x : Tensor
+ Node features ``(N, F)``.
+ edge_index : Tensor
+ COO edges ``(2, E)``.
+ edge_weight : Tensor or None
+ Optional edge weights ``(E,)``.
+ presence : Tensor
+ Boolean presence mask ``(N,)``. All-true when the sequence has
+ no presence masks.
+ entity_ids : tuple of str or None
+ Optional fixed-union row names.
+
+ Notes
+ -----
+ This is a supervision record, not a replacement for
+ :class:`~koopman_graph.data.GraphSnapshotSequence`. Equality is
+ disabled because the payload holds tensors.
+ """
+
+ x: Tensor
+ edge_index: Tensor
+ edge_weight: Tensor | None
+ presence: Tensor
+ entity_ids: tuple[str, ...] | None = None
+
+ def __post_init__(self) -> None:
+ """Validate shapes of the stored tensors.
+
+ Raises
+ ------
+ ValueError
+ If a tensor rank or length is inconsistent.
+ """
+ if self.x.ndim != 2:
+ msg = f"x must have shape (N, F), got {tuple(self.x.shape)}"
+ raise ValueError(msg)
+ num_nodes = int(self.x.shape[0])
+ if self.edge_index.ndim != 2 or int(self.edge_index.shape[0]) != 2:
+ msg = (
+ f"edge_index must have shape (2, E), got {tuple(self.edge_index.shape)}"
+ )
+ raise ValueError(msg)
+ num_edges = int(self.edge_index.shape[1])
+ if self.edge_weight is not None and (
+ self.edge_weight.ndim != 1 or int(self.edge_weight.shape[0]) != num_edges
+ ):
+ msg = (
+ f"edge_weight must have shape (E,), got {tuple(self.edge_weight.shape)}"
+ )
+ raise ValueError(msg)
+ if self.presence.ndim != 1 or int(self.presence.shape[0]) != num_nodes:
+ msg = f"presence must have shape (N,), got {tuple(self.presence.shape)}"
+ raise ValueError(msg)
+ if self.entity_ids is not None and len(self.entity_ids) != num_nodes:
+ msg = (
+ "entity_ids length must equal N, "
+ f"got {len(self.entity_ids)} != {num_nodes}"
+ )
+ raise ValueError(msg)
+
+
+@dataclass(frozen=True)
+class GraphDynamicsConfig:
+ """Opt-in graph-state closure options.
+
+ Default ``topology_head="sparse_candidate"`` scores at most
+ ``candidate_k`` destinations per node. ``dense_mlp`` keeps
+ :class:`~koopman_graph.nn.PredictedTopologyHead` as a power-user
+ dense :math:`N\\times N` path with an :math:`N` ceiling.
+ ``learn_topology="self_adaptive"`` / :class:`~koopman_graph.nn.AdaptiveAdjacency`
+ is a separate static Graph WaveNet formula and is unchanged.
+
+ Attributes
+ ----------
+ topology_head : {"none", "sparse_candidate", "dense_mlp"}
+ Head kind. Default ``"sparse_candidate"``.
+ recursive_training : bool
+ When ``True``, predicted :math:`\\hat A_{t+1}` feeds the next
+ operator step when ``future_topologies`` is omitted and
+ ``topology_policy="auto"``.
+ topology_loss_weight : float
+ Non-negative finite weight for structural BCE on candidate
+ pairs. Dimensionless. Default ``1.0``.
+ presence_loss_weight : float
+ Non-negative finite weight for presence BCE. Dimensionless.
+ Default ``1.0``.
+ candidate_k : int
+ Maximum destinations scored per source under
+ ``sparse_candidate``. Default ``8``.
+ """
+
+ topology_head: TopologyHeadKind = "sparse_candidate"
+ recursive_training: bool = True
+ topology_loss_weight: float = 1.0
+ presence_loss_weight: float = 1.0
+ candidate_k: int = DEFAULT_CANDIDATE_K
+
+ def __post_init__(self) -> None:
+ """Validate head kind, flags, weights, and ``candidate_k``.
+
+ Raises
+ ------
+ ValueError
+ If a field is outside the allowed set or range.
+ """
+ if self.topology_head not in TOPOLOGY_HEAD_KINDS:
+ allowed = ", ".join(sorted(TOPOLOGY_HEAD_KINDS))
+ msg = (
+ "topology_head must be one of "
+ f"{{{allowed}}}; got {self.topology_head!r}"
+ )
+ raise ValueError(msg)
+ if type(self.recursive_training) is not bool:
+ msg = (
+ "recursive_training must be a bool, got "
+ f"{type(self.recursive_training).__name__}"
+ )
+ raise ValueError(msg)
+ for name in ("topology_loss_weight", "presence_loss_weight"):
+ value = getattr(self, name)
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
+ msg = f"{name} must be a real float, got {type(value).__name__}"
+ raise ValueError(msg)
+ if not math.isfinite(float(value)) or float(value) < 0.0:
+ msg = f"{name} must be a finite non-negative float, got {value!r}"
+ raise ValueError(msg)
+ if type(self.candidate_k) is not int or self.candidate_k < 1:
+ msg = f"candidate_k must be a positive int, got {self.candidate_k!r}"
+ raise ValueError(msg)
+
+ def to_mapping(self) -> dict[str, Any]:
+ """Return a JSON-friendly mapping for format-1 checkpoints.
+
+ Returns
+ -------
+ dict
+ Keys ``topology_head``, ``recursive_training``,
+ ``topology_loss_weight``, ``presence_loss_weight``,
+ ``candidate_k``.
+ """
+ return {
+ "topology_head": self.topology_head,
+ "recursive_training": self.recursive_training,
+ "topology_loss_weight": float(self.topology_loss_weight),
+ "presence_loss_weight": float(self.presence_loss_weight),
+ "candidate_k": int(self.candidate_k),
+ }
+
+
+def graph_dynamics_from_mapping(
+ payload: Mapping[str, Any] | None,
+) -> GraphDynamicsConfig | None:
+ """Build a config from a checkpoint mapping.
+
+ Parameters
+ ----------
+ payload : mapping or None
+ Stored ``graph_dynamics`` block. ``None`` means the 0.14 default
+ (no graph-state head).
+
+ Returns
+ -------
+ GraphDynamicsConfig or None
+ Parsed config, or ``None`` when ``payload`` is ``None``.
+
+ Raises
+ ------
+ TypeError
+ If ``payload`` is neither ``None`` nor a mapping.
+ ValueError
+ If unknown keys are present or field validation fails.
+ """
+ if payload is None:
+ return None
+ if not isinstance(payload, Mapping):
+ msg = f"graph_dynamics must be a mapping or None, got {type(payload).__name__}"
+ raise TypeError(msg)
+ unknown = set(payload) - _CONFIG_KEYS
+ if unknown:
+ extra = ", ".join(sorted(str(key) for key in unknown))
+ msg = f"unknown graph_dynamics keys: {extra}"
+ raise ValueError(msg)
+ return GraphDynamicsConfig(
+ topology_head=payload.get("topology_head", "sparse_candidate"),
+ recursive_training=payload.get("recursive_training", True),
+ topology_loss_weight=payload.get("topology_loss_weight", 1.0),
+ presence_loss_weight=payload.get("presence_loss_weight", 1.0),
+ candidate_k=payload.get("candidate_k", DEFAULT_CANDIDATE_K),
+ )
+
+
+def graph_state_at(sequence: GraphSnapshotSequence, index: int) -> GraphStateSnapshot:
+ """Return the graph-state target at ``index``.
+
+ Parameters
+ ----------
+ sequence : GraphSnapshotSequence
+ Homogeneous snapshot sequence.
+ index : int
+ Timestep. Negative indices follow Python sequence rules.
+
+ Returns
+ -------
+ GraphStateSnapshot
+ Features, edges, presence, and optional entity ids.
+
+ Raises
+ ------
+ IndexError
+ If ``index`` is out of range.
+ TypeError
+ If ``sequence`` is not a :class:`GraphSnapshotSequence`.
+ """
+ if not isinstance(sequence, GraphSnapshotSequence):
+ msg = (
+ "graph_state_at expects a GraphSnapshotSequence, got "
+ f"{type(sequence).__name__}"
+ )
+ raise TypeError(msg)
+ n_steps = len(sequence)
+ resolved = int(index) if int(index) >= 0 else n_steps + int(index)
+ if resolved < 0 or resolved >= n_steps:
+ msg = f"graph-state index {index} is out of range for {n_steps} timesteps"
+ raise IndexError(msg)
+ snapshot = sequence[resolved]
+ features = snapshot.x
+ num_nodes = int(features.shape[0])
+ if sequence.has_presence_masks:
+ presence = sequence.presence_mask_at(resolved)
+ else:
+ presence = torch.ones(num_nodes, dtype=torch.bool, device=features.device)
+ entity_ids: tuple[str, ...] | None = None
+ if sequence.has_entity_ids:
+ raw = sequence.entity_ids
+ assert raw is not None
+ entity_ids = tuple(str(item) for item in raw)
+ return GraphStateSnapshot(
+ x=features,
+ edge_index=snapshot.edge_index,
+ edge_weight=snapshot_edge_weight(snapshot),
+ presence=presence,
+ entity_ids=entity_ids,
+ )
diff --git a/src/koopman_graph/data/remap.py b/src/koopman_graph/data/remap.py
index a631c59..e5fa1ed 100644
--- a/src/koopman_graph/data/remap.py
+++ b/src/koopman_graph/data/remap.py
@@ -1,12 +1,25 @@
"""Growable node-universe remapping (open-world MVP).
-Callers supply the index map. Silent merge of unrelated universes is refused.
+Callers supply the index map into a finite :math:`N_{\\max}`. Silent merge
+of unrelated universes is refused. This is **not** automatic entity
+resolution and does **not** allow unbounded node growth.
"""
from __future__ import annotations
+from collections.abc import Sequence
+from dataclasses import dataclass
+
import torch
from torch import Tensor
+from torch_geometric.data import Data
+
+from koopman_graph.data.validation import validate_entity_ids
+
+__all__ = [
+ "EntityRemap",
+ "remap_node_features",
+]
def remap_node_features(
@@ -24,12 +37,12 @@ def remap_node_features(
old_index : Tensor
Integer destinations in ``[0, new_capacity)`` with length ``N_old``.
new_capacity : int
- Target union size \(N_{\max}\).
+ Target union size :math:`N_{\max}`.
Returns
-------
Tensor
- Features of shape ``(new_capacity, F)``.
+ Features of shape ``(new_capacity, F)``. Unused rows are zeros.
Raises
------
@@ -57,3 +70,221 @@ def remap_node_features(
)
out[old_index] = features
return out
+
+
+def _remap_edge_index(edge_index: Tensor, index: Tensor) -> Tensor:
+ """Map COO endpoints through an injective node remap.
+
+ Parameters
+ ----------
+ edge_index : Tensor
+ Source COO with shape ``(2, E)``.
+ index : Tensor
+ Destinations with length ``N_old``.
+
+ Returns
+ -------
+ Tensor
+ Remapped COO with the same ``E``.
+
+ Raises
+ ------
+ ValueError
+ If an endpoint is outside ``[0, N_old)``.
+ """
+ if edge_index.ndim != 2 or int(edge_index.shape[0]) != 2:
+ msg = f"edge_index must have shape (2, E), got {tuple(edge_index.shape)}"
+ raise ValueError(msg)
+ n_old = int(index.shape[0])
+ if edge_index.numel() == 0:
+ return edge_index.to(dtype=torch.long, device=index.device).reshape(2, 0)
+ if int(edge_index.min()) < 0 or int(edge_index.max()) >= n_old:
+ msg = (
+ "edge_index endpoints must lie in "
+ f"[0, {n_old}), got min={int(edge_index.min())}, "
+ f"max={int(edge_index.max())}"
+ )
+ raise ValueError(msg)
+ mapped = index.to(device=edge_index.device)[edge_index.long()]
+ return mapped
+
+
+@dataclass(frozen=True, eq=False)
+class EntityRemap:
+ """Injective placement of source nodes into a fixed union.
+
+ ``entity_ids`` names the **target** rows (length :math:`N_{\\max}`).
+ ``index`` places each source row into that union. Unused union rows
+ stay zero / absent. This is not entity resolution across unrelated
+ graphs.
+
+ Attributes
+ ----------
+ entity_ids : tuple of str or int
+ Unique keys for the fixed union, length :math:`N_{\\max}`.
+ index : Tensor
+ Long destinations ``(N_old,)`` injective into
+ ``[0, N_max)``.
+ """
+
+ entity_ids: tuple[str | int, ...]
+ index: Tensor
+
+ def __post_init__(self) -> None:
+ """Validate union names and the injective index.
+
+ Raises
+ ------
+ ValueError
+ If ``entity_ids`` is empty, ``index`` is not a 1-D injective
+ long tensor into ``[0, N_max)``, or names collide.
+ """
+ ids = validate_entity_ids(self.entity_ids, num_nodes=len(self.entity_ids))
+ if len(ids) < 1:
+ msg = "EntityRemap.entity_ids must be non-empty (finite N_max)"
+ raise ValueError(msg)
+ object.__setattr__(self, "entity_ids", ids)
+ index = self.index
+ if not isinstance(index, Tensor) or index.ndim != 1:
+ msg = "EntityRemap.index must be a 1-D tensor"
+ raise ValueError(msg)
+ if int(index.numel()) < 1:
+ msg = "EntityRemap.index must contain at least one source row"
+ raise ValueError(msg)
+ stored = index.detach().to(dtype=torch.long).clone()
+ object.__setattr__(self, "index", stored)
+ remap_node_features(
+ torch.zeros(
+ int(stored.shape[0]),
+ 1,
+ dtype=torch.float32,
+ device=stored.device,
+ ),
+ old_index=stored,
+ new_capacity=len(ids),
+ )
+
+ @property
+ def n_max(self) -> int:
+ """Return the fixed union size :math:`N_{\\max}`.
+
+ Returns
+ -------
+ int
+ ``len(entity_ids)``.
+ """
+ return len(self.entity_ids)
+
+ @property
+ def presence_mask(self) -> Tensor:
+ """Return a boolean mask that is true on mapped union rows.
+
+ Returns
+ -------
+ Tensor
+ Shape ``(N_max,)`` on ``index.device``.
+ """
+ mask = torch.zeros(
+ self.n_max,
+ dtype=torch.bool,
+ device=self.index.device,
+ )
+ mask[self.index] = True
+ return mask
+
+ def apply_features(self, features: Tensor) -> Tensor:
+ """Scatter source features into the union.
+
+ Parameters
+ ----------
+ features : Tensor
+ Source features ``(N_old, F)``.
+
+ Returns
+ -------
+ Tensor
+ Union features ``(N_max, F)``.
+ """
+ return remap_node_features(
+ features,
+ old_index=self.index,
+ new_capacity=self.n_max,
+ )
+
+ def apply_snapshot(self, snapshot: Data) -> Data:
+ """Scatter one homogeneous snapshot into the union.
+
+ Remaps ``x`` and ``edge_index`` (and ``edge_weight`` when present).
+ Padded rows are zeros with no incident edges.
+
+ Parameters
+ ----------
+ snapshot : Data
+ Homogeneous snapshot with ``N_old`` nodes.
+
+ Returns
+ -------
+ Data
+ Snapshot with ``N_max`` nodes.
+
+ Raises
+ ------
+ TypeError
+ If ``snapshot`` is not a homogeneous ``Data``.
+ ValueError
+ If node count or edges do not match ``index``.
+ """
+ if type(snapshot) is not Data:
+ msg = (
+ "EntityRemap.apply_snapshot supports homogeneous Data only, "
+ f"got {type(snapshot).__name__}"
+ )
+ raise TypeError(msg)
+ if snapshot.x is None:
+ msg = "EntityRemap.apply_snapshot requires Data.x"
+ raise ValueError(msg)
+ remapped = Data(x=self.apply_features(snapshot.x))
+ edge_index = snapshot.edge_index
+ if edge_index is None:
+ remapped.edge_index = torch.empty(
+ (2, 0),
+ dtype=torch.long,
+ device=self.index.device,
+ )
+ return remapped
+ remapped.edge_index = _remap_edge_index(edge_index, self.index)
+ weight = getattr(snapshot, "edge_weight", None)
+ if weight is not None:
+ remapped.edge_weight = weight
+ return remapped
+
+ def apply_snapshots(
+ self,
+ snapshots: Sequence[Data],
+ ) -> tuple[list[Data], Tensor]:
+ """Remap a homogeneous trajectory into the shared union.
+
+ Parameters
+ ----------
+ snapshots : sequence of Data
+ Snapshots that share ``N_old`` with ``index``.
+
+ Returns
+ -------
+ remapped : list of Data
+ Union-capacity snapshots.
+ presence : Tensor
+ Boolean mask ``(T, N_max)`` true on mapped rows (constant
+ across time).
+
+ Raises
+ ------
+ ValueError
+ If ``snapshots`` is empty.
+ """
+ if not snapshots:
+ msg = "apply_snapshots requires at least one snapshot"
+ raise ValueError(msg)
+ remapped = [self.apply_snapshot(snapshot) for snapshot in snapshots]
+ presence = self.presence_mask.unsqueeze(0).expand(len(snapshots), -1).clone()
+ return remapped, presence
diff --git a/src/koopman_graph/data/sampling.py b/src/koopman_graph/data/sampling.py
index 1300c21..57db7da 100644
--- a/src/koopman_graph/data/sampling.py
+++ b/src/koopman_graph/data/sampling.py
@@ -360,6 +360,7 @@ def induce_neighbor_subgraph_sequence(
snapshots,
control_inputs=control_inputs,
timestamps=sequence.timestamps,
+ parameter_trajectory=sequence.parameter_trajectory,
observation_masks=observation_masks,
presence_masks=presence_masks,
entity_ids=entity_ids,
diff --git a/src/koopman_graph/data/sequence_types.py b/src/koopman_graph/data/sequence_types.py
index 2840aa0..f7d8ea8 100644
--- a/src/koopman_graph/data/sequence_types.py
+++ b/src/koopman_graph/data/sequence_types.py
@@ -79,6 +79,17 @@ def timestamps(self) -> Tensor | None:
"""
...
+ @property
+ def parameter_trajectory(self) -> Tensor | None:
+ """Optional regime coordinates aligned with timesteps.
+
+ Returns
+ -------
+ Tensor or None
+ Coordinates with shape ``(T, d_mu)``, or ``None``.
+ """
+ ...
+
@property
def observation_masks(self) -> Tensor | None:
"""Optional boolean observation masks ``(T, N)``.
diff --git a/src/koopman_graph/data/splits.py b/src/koopman_graph/data/splits.py
index 1d295e8..fce14e8 100644
--- a/src/koopman_graph/data/splits.py
+++ b/src/koopman_graph/data/splits.py
@@ -131,6 +131,11 @@ def temporal_split(
timestamps=(
None if sequence.timestamps is None else sequence.timestamps[:train_end]
),
+ parameter_trajectory=(
+ None
+ if sequence.parameter_trajectory is None
+ else sequence.parameter_trajectory[:train_end]
+ ),
observation_masks=(
None
if sequence.observation_masks is None
@@ -157,6 +162,11 @@ def temporal_split(
if sequence.timestamps is None
else sequence.timestamps[train_end:val_end]
),
+ parameter_trajectory=(
+ None
+ if sequence.parameter_trajectory is None
+ else sequence.parameter_trajectory[train_end:val_end]
+ ),
observation_masks=(
None
if sequence.observation_masks is None
@@ -181,6 +191,11 @@ def temporal_split(
timestamps=(
None if sequence.timestamps is None else sequence.timestamps[val_end:]
),
+ parameter_trajectory=(
+ None
+ if sequence.parameter_trajectory is None
+ else sequence.parameter_trajectory[val_end:]
+ ),
observation_masks=(
None
if sequence.observation_masks is None
diff --git a/src/koopman_graph/data/validation.py b/src/koopman_graph/data/validation.py
index 8896a1c..a5f7101 100644
--- a/src/koopman_graph/data/validation.py
+++ b/src/koopman_graph/data/validation.py
@@ -735,6 +735,58 @@ def as_tensor(value: ArrayLike, *, dtype: torch.dtype | None = None) -> Tensor:
return torch.as_tensor(value, dtype=dtype)
+def validate_parameter_trajectory(
+ parameter_trajectory: Tensor,
+ *,
+ num_timesteps: int,
+) -> None:
+ """Validate optional per-snapshot regime coordinates.
+
+ Parameters
+ ----------
+ parameter_trajectory : Tensor
+ Regime coordinates :math:`\\mu_t` with shape
+ ``(num_timesteps, d_mu)`` and ``d_mu >= 1``. Entries must be
+ finite. Units are caller-defined (dimensionless if unspecified).
+ num_timesteps : int
+ Expected number of snapshots.
+
+ Raises
+ ------
+ ValueError
+ If rank, length, width, dtype, or finiteness is invalid.
+ """
+ if parameter_trajectory.ndim != 2:
+ msg = (
+ "parameter_trajectory must have shape (num_timesteps, d_mu), "
+ f"got {tuple(parameter_trajectory.shape)}"
+ )
+ raise ValueError(msg)
+ if int(parameter_trajectory.shape[0]) != num_timesteps:
+ msg = (
+ f"parameter_trajectory has {parameter_trajectory.shape[0]} "
+ f"timesteps, expected {num_timesteps}"
+ )
+ raise ValueError(msg)
+ if int(parameter_trajectory.shape[1]) < 1:
+ msg = (
+ "parameter_trajectory must have shape (num_timesteps, d_mu) "
+ f"with d_mu >= 1, got {tuple(parameter_trajectory.shape)}"
+ )
+ raise ValueError(msg)
+ if parameter_trajectory.dtype == torch.bool:
+ msg = (
+ "parameter_trajectory must be a real numeric tensor, "
+ f"got dtype {parameter_trajectory.dtype}"
+ )
+ raise ValueError(msg)
+ if parameter_trajectory.is_floating_point() and not torch.all(
+ torch.isfinite(parameter_trajectory)
+ ):
+ msg = "parameter_trajectory must be finite"
+ raise ValueError(msg)
+
+
def validate_timestamps(
timestamps: Tensor,
*,
@@ -1080,7 +1132,10 @@ def validate_snapshot_metadata(snapshots: Sequence[Data]) -> None:
------
ValueError
If the sequence is empty or any snapshot differs in node count or
- feature dimension from the first snapshot.
+ feature dimension from the first snapshot. A node-count mismatch
+ is unbounded growth without
+ :class:`~koopman_graph.data.EntityRemap`; pad into a shared
+ :math:`N_{\\max}` first.
"""
if not snapshots:
msg = "GraphSnapshotSequence requires at least one snapshot"
@@ -1094,7 +1149,9 @@ def validate_snapshot_metadata(snapshots: Sequence[Data]) -> None:
if snapshot.num_nodes != ref_num_nodes:
msg = (
f"Snapshot {idx} has {snapshot.num_nodes} nodes, "
- f"expected {ref_num_nodes}"
+ f"expected {ref_num_nodes} (fixed N_max). "
+ "Use EntityRemap / remap_node_features into a shared union; "
+ "unbounded node growth without remap is refused."
)
raise ValueError(msg)
if snapshot.x.shape[1] != ref_in_channels:
diff --git a/src/koopman_graph/datasets/ieee118.py b/src/koopman_graph/datasets/ieee118.py
index e78daaf..4d52c2f 100644
--- a/src/koopman_graph/datasets/ieee118.py
+++ b/src/koopman_graph/datasets/ieee118.py
@@ -11,7 +11,10 @@
``case118`` artifact. Feature trajectories from ``generate`` /
``generate_typed`` are **simulated** (Laplacian diffusion on voltages/angles
plus a sinusoidal load ramp) — they are **not** real grid telemetry or OPF
-solutions. Do not cite this benchmark as measured power-system dynamics.
+solutions, and they are **not** AC power-flow snapshots. Constraint-preserving
+decoder heads act after decode and do not create AC power-flow consistency
+on these trajectories. Do not cite this benchmark as measured power-system
+dynamics.
"""
from __future__ import annotations
@@ -673,8 +676,9 @@ class IEEE118DynamicBenchmark:
Voltages and angles evolve via graph Laplacian diffusion on the real IEEE
118 transmission topology; loads follow a slow sinusoidal ramp to emulate
changing grid conditions over time. These trajectories are **simulated**
- teaching dynamics, not measured grid telemetry
- (:data:`SIMULATED_DYNAMICS_DISCLAIMER`).
+ teaching dynamics, not measured grid telemetry, OPF solutions, or AC
+ power flow (:data:`SIMULATED_DYNAMICS_DISCLAIMER`). Constraint-preserving
+ decoder heads do not create power-flow consistency.
Public entry points are the classmethods ``load_topology``, ``generate``,
``load_typed_topology``, and ``generate_typed``. Prefer those over the
@@ -801,6 +805,12 @@ def generate(
------
ValueError
If any generation parameter is invalid.
+
+ Notes
+ -----
+ Trajectories are Laplacian diffusion plus a load ramp — not
+ SCADA, OPF, or AC power flow. Constraint decoder heads do not
+ change that.
"""
if num_timesteps < 1:
msg = f"num_timesteps must be >= 1, got {num_timesteps}"
@@ -892,6 +902,7 @@ def generate_typed(
Dynamics match :meth:`generate` (simulated Laplacian diffusion + load
ramp on the MATPOWER topology) and are then partitioned into typed
``HeteroData`` snapshots. See :data:`SIMULATED_DYNAMICS_DISCLAIMER`.
+ Constraint decoder heads do not create AC power-flow consistency.
Parameters
----------
diff --git a/src/koopman_graph/export.py b/src/koopman_graph/export.py
index 09effe1..9262278 100644
--- a/src/koopman_graph/export.py
+++ b/src/koopman_graph/export.py
@@ -83,6 +83,10 @@ def _reject_unsupported_export(model: GraphKoopmanModel) -> None:
kind = type(model.koopman).__name__
if kind in {"SwitchedKoopmanOperator", "MixtureKoopmanOperator"}:
raise ValueError("export_inference_module refuses switched/mixture operators")
+ if kind == "ParametricKoopmanOperator":
+ raise ValueError(
+ "export_inference_module refuses parametric interpolants K(mu)"
+ )
def export_inference_module(
diff --git a/src/koopman_graph/graph_utils/__init__.py b/src/koopman_graph/graph_utils/__init__.py
index b1446e0..12addb3 100644
--- a/src/koopman_graph/graph_utils/__init__.py
+++ b/src/koopman_graph/graph_utils/__init__.py
@@ -37,6 +37,7 @@
DecoderFn,
DeltaTAtFn,
KoopmanPropagator,
+ ParametersAtFn,
PresenceAtFn,
RelationDecoderFn,
RelationTopologyAtFn,
@@ -44,6 +45,7 @@
advance_and_decode,
autoregressive_hetero_latent_rollout,
autoregressive_latent_rollout,
+ call_topology_at,
hold_last_relation_topology_at,
hold_last_topology_at,
inverse_propagate_latent,
@@ -120,6 +122,7 @@
"MAX_ISOTYPIC_NODES",
"OrbitMethod",
"OrbitPartition",
+ "ParametersAtFn",
"PresenceAtFn",
"RELATION_NORMALIZATION_MODES",
"RandomWalkDirection",
@@ -132,6 +135,7 @@
"assemble_orbit_self_blocks",
"autoregressive_hetero_latent_rollout",
"autoregressive_latent_rollout",
+ "call_topology_at",
"clear_hyperedge_cache",
"compute_isotypic_decomposition",
"degree_support_mask",
diff --git a/src/koopman_graph/graph_utils/propagation.py b/src/koopman_graph/graph_utils/propagation.py
index eafafd4..2c7bf63 100644
--- a/src/koopman_graph/graph_utils/propagation.py
+++ b/src/koopman_graph/graph_utils/propagation.py
@@ -33,6 +33,10 @@
* **Hold-last (inference):** :func:`hold_last_topology_at` — start from the
initial graph topology; when ``future_topologies[step]`` is provided, update
and retain that topology for later steps.
+* **Predicted graph-state (opt-in):** a ``topology_at(step, latent)``
+ callable that scores :math:`\\hat A_{t+1}=g_\\phi(z_t)` on a candidate
+ set and returns sigmoid edge weights. Unary ``topology_at(step)``
+ callables remain valid.
* **Per-step / teacher targets (training):** :func:`snapshot_topology_at` —
decode step ``i`` uses the topology of snapshot ``i`` (typically the
observed target at ``start + i + 1``).
@@ -72,13 +76,53 @@
[Tensor, Sequence[Tensor], Sequence[Tensor | None] | None],
Tensor | dict[str, Tensor],
]
-TopologyAtFn = Callable[[int], tuple[Tensor, Tensor | None]]
+TopologyAtFn = Callable[..., tuple[Tensor, Tensor | None]]
RelationTopologyAtFn = Callable[[int], tuple[list[Tensor], list[Tensor | None]]]
ControlAtFn = Callable[[int], Tensor | None]
+ParametersAtFn = Callable[[int], Tensor | None]
DeltaTAtFn = Callable[[int], float | Tensor | None]
PresenceAtFn = Callable[[int], Tensor | None]
+def call_topology_at(
+ topology_at: TopologyAtFn,
+ step: int,
+ latent: Tensor,
+) -> tuple[Tensor, Tensor | None]:
+ """Dispatch unary or latent-conditioned ``topology_at``.
+
+ Parameters
+ ----------
+ topology_at : callable
+ ``topology_at(step)`` or ``topology_at(step, latent)``.
+ step : int
+ Zero-based rollout step.
+ latent : Tensor
+ Current node latents (ignored by unary callables).
+
+ Returns
+ -------
+ tuple[Tensor, Tensor or None]
+ Decode ``edge_index`` and optional ``edge_weight``.
+ """
+ try:
+ signature = inspect.signature(topology_at)
+ except (TypeError, ValueError):
+ return topology_at(step)
+ positional = [
+ parameter
+ for parameter in signature.parameters.values()
+ if parameter.kind
+ in (
+ inspect.Parameter.POSITIONAL_ONLY,
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
+ )
+ ]
+ if len(positional) >= 2:
+ return topology_at(step, latent)
+ return topology_at(step)
+
+
def resolve_delta_t(
delta_t: float | Tensor | None,
*,
@@ -118,9 +162,16 @@ def _topology_kwargs_for(
edge_indices: Sequence[Tensor] | None = None,
edge_weights: Sequence[Tensor | None] | None = None,
num_nodes_dict: Mapping[str, int] | None = None,
+ parameters: Tensor | None = None,
+ phase_index: int | None = None,
) -> dict[
str,
- Tensor | None | Sequence[Tensor] | Sequence[Tensor | None] | Mapping[str, int],
+ Tensor
+ | None
+ | Sequence[Tensor]
+ | Sequence[Tensor | None]
+ | Mapping[str, int]
+ | int,
]:
"""Return topology / window kwargs accepted by ``method``.
@@ -153,6 +204,11 @@ def _topology_kwargs_for(
Optional per-relation edge weights.
num_nodes_dict : mapping of str to int or None, optional
Per-type node counts for typed hetero operators.
+ parameters : Tensor or None, optional
+ Regime coordinate forwarded when ``method`` declares ``parameters``.
+ phase_index : int or None, optional
+ Switched-by-clock mode forwarded when ``method`` declares
+ ``phase_index``.
Returns
-------
@@ -165,7 +221,12 @@ def _topology_kwargs_for(
)
kwargs: dict[
str,
- Tensor | None | Sequence[Tensor] | Sequence[Tensor | None] | Mapping[str, int],
+ Tensor
+ | None
+ | Sequence[Tensor]
+ | Sequence[Tensor | None]
+ | Mapping[str, int]
+ | int,
] = {}
if accepts_var_kw or "edge_index" in params:
kwargs["edge_index"] = edge_index
@@ -187,6 +248,10 @@ def _topology_kwargs_for(
kwargs["latent_window"] = latent_window
if "num_nodes_dict" in params:
kwargs["num_nodes_dict"] = num_nodes_dict
+ if accepts_var_kw or "parameters" in params:
+ kwargs["parameters"] = parameters
+ if accepts_var_kw or "phase_index" in params:
+ kwargs["phase_index"] = phase_index
return kwargs
@@ -207,6 +272,8 @@ def propagate_latent(
edge_indices: Sequence[Tensor] | None = None,
edge_weights: Sequence[Tensor | None] | None = None,
num_nodes_dict: Mapping[str, int] | None = None,
+ parameters: Tensor | None = None,
+ phase_index: int | None = None,
) -> Tensor:
"""Advance latent states via the unified Koopman contract.
@@ -251,6 +318,11 @@ def propagate_latent(
num_nodes_dict : mapping of str to int or None, optional
Per-type node counts for typed hetero operators (forwarded only when
the operator's ``advance`` declares the parameter).
+ parameters : Tensor or None, optional
+ Regime coordinate :math:`\\mu` forwarded when ``advance`` declares
+ it (parametric interpolants).
+ phase_index : int or None, optional
+ Switched-by-clock mode forwarded when ``advance`` declares it.
Returns
-------
@@ -274,6 +346,8 @@ def propagate_latent(
edge_indices=edge_indices,
edge_weights=edge_weights,
num_nodes_dict=num_nodes_dict,
+ parameters=parameters,
+ phase_index=phase_index,
),
)
@@ -296,6 +370,8 @@ def inverse_propagate_latent(
edge_indices: Sequence[Tensor] | None = None,
edge_weights: Sequence[Tensor | None] | None = None,
num_nodes_dict: Mapping[str, int] | None = None,
+ parameters: Tensor | None = None,
+ phase_index: int | None = None,
) -> Tensor:
"""Apply one inverse Koopman step via the unified contract.
@@ -338,6 +414,11 @@ def inverse_propagate_latent(
Optional per-relation edge weights.
num_nodes_dict : mapping of str to int or None, optional
Per-type node counts for typed hetero operators.
+ parameters : Tensor or None, optional
+ Regime coordinate forwarded when ``inverse_advance`` declares it.
+ phase_index : int or None, optional
+ Switched-by-clock mode forwarded when ``inverse_advance``
+ declares it.
Returns
-------
@@ -362,6 +443,8 @@ def inverse_propagate_latent(
tail_index=tail_index,
head_index=head_index,
num_nodes_dict=num_nodes_dict,
+ parameters=parameters,
+ phase_index=phase_index,
),
)
@@ -381,6 +464,8 @@ def advance_and_decode(
tail_index: Tensor | None = None,
head_index: Tensor | None = None,
latent_window: Tensor | None = None,
+ parameters: Tensor | None = None,
+ phase_index: int | None = None,
) -> tuple[Tensor, Tensor]:
"""Advance latent state once and decode to physical node features.
@@ -415,6 +500,10 @@ def advance_and_decode(
Directed-hypergraph incidence for random-walk modes.
latent_window : Tensor or None, optional
Latent history for global/local operators.
+ parameters : Tensor or None, optional
+ Regime coordinate forwarded to parametric interpolants.
+ phase_index : int or None, optional
+ Switched-by-clock mode forwarded to switched operators.
Returns
-------
@@ -434,6 +523,8 @@ def advance_and_decode(
tail_index=tail_index,
head_index=head_index,
latent_window=latent_window,
+ parameters=parameters,
+ phase_index=phase_index,
)
prediction = decoder(z_next, edge_index, edge_weight)
return z_next, prediction
@@ -535,6 +626,7 @@ def autoregressive_latent_rollout(
steps: int,
topology_at: TopologyAtFn,
control_at: ControlAtFn | None = None,
+ parameters_at: ParametersAtFn | None = None,
delta_t_at: DeltaTAtFn | None = None,
presence_at: PresenceAtFn | None = None,
initial_features: Tensor | None = None,
@@ -568,11 +660,16 @@ def autoregressive_latent_rollout(
steps : int
Number of advance/decode steps (must be >= 1).
topology_at : callable
- ``topology_at(step) -> (edge_index, edge_weight)`` for decode step
- ``step`` in ``0 .. steps - 1``.
+ ``topology_at(step) -> (edge_index, edge_weight)`` or
+ ``topology_at(step, latent) -> (edge_index, edge_weight)`` for
+ decode step ``step`` in ``0 .. steps - 1``. Unary callables keep
+ the 0.14 hold-last / teacher-target schedules.
control_at : callable or None, optional
``control_at(step) -> Tensor | None``. When omitted, every step is
uncontrolled.
+ parameters_at : callable or None, optional
+ ``parameters_at(step) -> Tensor | None``. When omitted, parametric
+ operators use a stored :math:`\\mu` or raise.
delta_t_at : callable or None, optional
``delta_t_at(step) -> float | Tensor | None``. When omitted (or when a
step returns ``None``), ``default_delta_t`` is used.
@@ -618,8 +715,9 @@ def autoregressive_latent_rollout(
accepts_window = "latent_window" in inspect.signature(koopman.advance).parameters
local_window = int(getattr(koopman, "local_window", 0) or 0)
for step in range(steps):
- edge_index, edge_weight = topology_at(step)
+ edge_index, edge_weight = call_topology_at(topology_at, step, latent)
control = None if control_at is None else control_at(step)
+ parameters = None if parameters_at is None else parameters_at(step)
delta_t = None if delta_t_at is None else delta_t_at(step)
latent_window = None
if accepts_window and local_window > 0:
@@ -644,6 +742,7 @@ def autoregressive_latent_rollout(
tail_index=tail_index,
head_index=head_index,
latent_window=latent_window,
+ parameters=parameters,
)
if presence_at is not None:
presence = presence_at(step)
diff --git a/src/koopman_graph/hierarchical/model.py b/src/koopman_graph/hierarchical/model.py
index b942878..0b0aa9c 100644
--- a/src/koopman_graph/hierarchical/model.py
+++ b/src/koopman_graph/hierarchical/model.py
@@ -1326,6 +1326,8 @@ def _pool_sequence(
}
if sequence.timestamps is not None:
kwargs["timestamps"] = sequence.timestamps
+ if getattr(sequence, "parameter_trajectory", None) is not None:
+ kwargs["parameter_trajectory"] = sequence.parameter_trajectory
# Observation masks are fine-node specific; drop on coarse (documented).
if self._uses_hetero:
return (
diff --git a/src/koopman_graph/identification/__init__.py b/src/koopman_graph/identification/__init__.py
new file mode 100644
index 0000000..a4cd1ee
--- /dev/null
+++ b/src/koopman_graph/identification/__init__.py
@@ -0,0 +1,146 @@
+"""Opt-in operator identification types, closed-form solvers, and reports.
+
+Capability layout
+-----------------
+``config``
+ Frozen :class:`~koopman_graph.identification.IdentificationConfig`
+ (solver name, ridge weight, selection keys).
+``report``
+ Frozen :class:`~koopman_graph.identification.IdentificationReport`
+ with reconstruction, one-step, rollout, closure, invariance,
+ spectral, and stability groups.
+``protocol``
+ :class:`~koopman_graph.identification.IdentificationBackend` plus
+ :class:`~koopman_graph.identification.LatentPairs` /
+ :class:`~koopman_graph.identification.OperatorSnapshot`.
+``solvers``
+ Ridge, TLS, and constrained least-squares maps on frozen encodings
+ (:func:`~koopman_graph.identification.identify_operator`).
+``metrics``
+ One-step / short-rollout MSE and spectral-radius report helpers.
+``invariance``
+ Finite-sample subspace leakage :math:`\\eta`
+ (:func:`~koopman_graph.identification.subspace_invariance_report`).
+``gate``
+ Residual-aware dictionary selection
+ (:func:`~koopman_graph.identification.select_resdmd_gated`).
+``sparse_factors``
+ :func:`~koopman_graph.identification.identify_sparse_graph_factors`
+ (STLSQ / group-lasso on :math:`K_{\\mathrm{self}}` /
+ :math:`K_{\\mathrm{nbr}}`; unpenalized refit). Distinct from
+ :func:`~koopman_graph.analysis.identify_sparse_dynamics` and
+ :class:`~koopman_graph.losses.KoopmanSparsityLoss`.
+``rank``
+ :func:`~koopman_graph.identification.select_latent_rank` over a
+ candidate grid (VAMP-2, ResDMD elbow, stability-penalized
+ held-out MSE). Distinct from Ray Tune HPO for encoder
+ ``latent_dim``.
+
+Honesty
+-------
+This package is importable and **off** root ``__all__``. Pass
+``fit(..., identification=IdentificationConfig(...))`` to alternate
+encoder Adam steps with a closed-form :math:`K` update. Default
+``identification=None`` keeps the 0.14 Adam path. Reports record
+finite-sample MSE and :math:`\\rho(K)`; they are not Haseli–Cortés,
+certified ResDMD, or stability certificates. ``solver="varpro"`` is not
+implemented. Graph / hetero / continuous / controlled operators raise.
+:func:`~koopman_graph.identification.subspace_invariance_report` is a
+finite-sample projection-leakage ratio, not a Haseli–Cortés certificate.
+``fit`` does not populate
+:attr:`~koopman_graph.identification.IdentificationReport.invariance`.
+``gate_resdmd=True`` fills the finite-dictionary ``spectral`` block on
+the final report; :func:`~koopman_graph.identification.select_resdmd_gated`
+rejects polluted RMSE-only winners.
+:func:`~koopman_graph.identification.identify_sparse_graph_factors`
+selects sparse graph factors on frozen encodings; it does not replace
+latent SINDy or :class:`~koopman_graph.losses.KoopmanSparsityLoss` and
+is not Pan et al. (2021) multi-task EDMD dictionary pruning.
+:func:`~koopman_graph.identification.select_latent_rank` scores truncated
+SVD ranks of frozen encodings; it is not Ray Tune AutoML for encoder
+``latent_dim``.
+
+Import rules
+------------
+This package must not import :mod:`koopman_graph.training`,
+:mod:`koopman_graph.model`, or :mod:`koopman_graph.adaptation`.
+:mod:`koopman_graph.training` must not import this package at module
+load (lazy bind inside :func:`~koopman_graph.training.run_fit_loop`).
+:class:`~koopman_graph.adaptation.JointStateTopologyObserver` may import
+this package; RLS and :class:`~koopman_graph.adaptation.KoopmanObserver`
+must not load it at package import.
+"""
+
+from koopman_graph.identification.config import (
+ IDENTIFICATION_SOLVERS,
+ IdentificationConfig,
+ IdentificationSolver,
+)
+from koopman_graph.identification.gate import (
+ DEFAULT_RESDMD_GATE_TOLERANCE,
+ ResDMDGateCandidate,
+ ResDMDGateResult,
+ select_resdmd_gated,
+)
+from koopman_graph.identification.invariance import (
+ SubspaceInvarianceReport,
+ subspace_invariance_report,
+)
+from koopman_graph.identification.metrics import (
+ DEFAULT_IDENTIFICATION_ROLLOUT_HORIZON,
+ build_identification_report,
+)
+from koopman_graph.identification.protocol import (
+ IdentificationBackend,
+ LatentPairs,
+ OperatorSnapshot,
+)
+from koopman_graph.identification.rank import (
+ LatentRankReport,
+ select_latent_rank,
+)
+from koopman_graph.identification.report import (
+ IdentificationReport,
+ InvarianceBlock,
+ MetricBlock,
+ SpectralReliabilityBlock,
+ StabilityBlock,
+)
+from koopman_graph.identification.solvers import (
+ ClosedFormBackend,
+ apply_operator_snapshot,
+ identify_operator,
+)
+from koopman_graph.identification.sparse_factors import (
+ SparseFactorReport,
+ identify_sparse_graph_factors,
+)
+
+__all__ = [
+ "DEFAULT_IDENTIFICATION_ROLLOUT_HORIZON",
+ "DEFAULT_RESDMD_GATE_TOLERANCE",
+ "IDENTIFICATION_SOLVERS",
+ "ClosedFormBackend",
+ "IdentificationBackend",
+ "IdentificationConfig",
+ "IdentificationReport",
+ "IdentificationSolver",
+ "InvarianceBlock",
+ "LatentPairs",
+ "LatentRankReport",
+ "MetricBlock",
+ "OperatorSnapshot",
+ "ResDMDGateCandidate",
+ "ResDMDGateResult",
+ "SpectralReliabilityBlock",
+ "SparseFactorReport",
+ "StabilityBlock",
+ "SubspaceInvarianceReport",
+ "apply_operator_snapshot",
+ "build_identification_report",
+ "identify_operator",
+ "identify_sparse_graph_factors",
+ "select_latent_rank",
+ "select_resdmd_gated",
+ "subspace_invariance_report",
+]
diff --git a/src/koopman_graph/identification/config.py b/src/koopman_graph/identification/config.py
new file mode 100644
index 0000000..512918b
--- /dev/null
+++ b/src/koopman_graph/identification/config.py
@@ -0,0 +1,99 @@
+"""Frozen configuration for opt-in operator identification.
+
+Pass an instance to :meth:`~koopman_graph.model.GraphKoopmanModel.fit` as
+``identification=...``. ``identification=None`` (the default) keeps the
+Adam path. This module must not import :mod:`koopman_graph.training` or
+:mod:`koopman_graph.model`.
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Sequence
+from dataclasses import dataclass
+from typing import Literal
+
+IdentificationSolver = Literal[
+ "ridge",
+ "tls",
+ "constrained_ls",
+ "varpro",
+ "alternating",
+]
+
+IDENTIFICATION_SOLVERS: frozenset[str] = frozenset(
+ {"ridge", "tls", "constrained_ls", "varpro", "alternating"}
+)
+
+__all__ = [
+ "IDENTIFICATION_SOLVERS",
+ "IdentificationConfig",
+ "IdentificationSolver",
+]
+
+
+@dataclass(frozen=True)
+class IdentificationConfig:
+ """Options for closed-form operator identification at fit time.
+
+ ``ridge`` is a dimensionless Tikhonov weight on the least-squares
+ Gram, not a physical regularization in feature units.
+ ``solver="alternating"`` uses the ridge formula; the fit loop
+ supplies encoder/operator alternation. ``solver="varpro"`` raises
+ :class:`NotImplementedError`. ``gate_resdmd=True`` fills the final
+ identification report's finite-dictionary ``spectral`` block; it
+ does not abort ``fit``. Residual-aware reject uses
+ :func:`~koopman_graph.identification.select_resdmd_gated` or
+ :class:`~koopman_graph.training.ResDMDFitCallback` ``mode="gate"``.
+
+ Attributes
+ ----------
+ solver : {"ridge", "tls", "constrained_ls", "varpro", "alternating"}
+ Closed-form ``K`` method. Default ``"ridge"``.
+ ridge : float
+ Non-negative finite Tikhonov weight. Default ``1e-4``. Ignored
+ by ``tls``.
+ select_on : tuple of str
+ Model-selection keys a later gate may consult. Default
+ ``("rollout", "invariance", "resdmd")``. Empty is allowed.
+ gate_resdmd : bool
+ When ``True``, fill ``spectral`` on the final identification
+ report. Default ``False`` (unset spectral block).
+ """
+
+ solver: IdentificationSolver = "ridge"
+ ridge: float = 1e-4
+ select_on: tuple[str, ...] = ("rollout", "invariance", "resdmd")
+ gate_resdmd: bool = False
+
+ def __post_init__(self) -> None:
+ """Validate solver name, ridge weight, and selection keys.
+
+ Raises
+ ------
+ ValueError
+ If ``solver`` is unknown, ``ridge`` is negative or non-finite,
+ ``select_on`` is not a string sequence, or ``gate_resdmd`` is
+ not a ``bool``.
+ """
+ if self.solver not in IDENTIFICATION_SOLVERS:
+ allowed = ", ".join(sorted(IDENTIFICATION_SOLVERS))
+ msg = f"solver must be one of {{{allowed}}}; got {self.solver!r}"
+ raise ValueError(msg)
+ if not math.isfinite(self.ridge) or self.ridge < 0.0:
+ msg = f"ridge must be a finite non-negative float, got {self.ridge!r}"
+ raise ValueError(msg)
+ if not isinstance(self.select_on, Sequence) or isinstance(
+ self.select_on, (str, bytes)
+ ):
+ msg = "select_on must be a sequence of strings"
+ raise ValueError(msg)
+ keys = tuple(self.select_on)
+ if any(not isinstance(key, str) or key == "" for key in keys):
+ msg = "select_on entries must be non-empty strings"
+ raise ValueError(msg)
+ if keys != self.select_on:
+ object.__setattr__(self, "select_on", keys)
+ if type(self.gate_resdmd) is not bool:
+ msg = f"gate_resdmd must be a bool, got {type(self.gate_resdmd).__name__}"
+ raise ValueError(msg)
diff --git a/src/koopman_graph/identification/gate.py b/src/koopman_graph/identification/gate.py
new file mode 100644
index 0000000..7a8e975
--- /dev/null
+++ b/src/koopman_graph/identification/gate.py
@@ -0,0 +1,229 @@
+"""Residual-aware candidate selection for finite-dictionary ResDMD.
+
+``select_resdmd_gated`` compares already-scored dictionaries. It does
+**not** fit an operator, does **not** run
+:func:`~koopman_graph.analysis.resdmd`, and is **not** a certified
+infinite-dimensional residual bound. Callers supply a train one-step
+mean squared error (MSE) and a max Colbrook–Townsend residual per
+candidate.
+
+This module must not import :mod:`koopman_graph.training` or
+:mod:`koopman_graph.model`.
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+__all__ = [
+ "DEFAULT_RESDMD_GATE_TOLERANCE",
+ "ResDMDGateCandidate",
+ "ResDMDGateResult",
+ "select_resdmd_gated",
+]
+
+# Matches :func:`~koopman_graph.analysis.resdmd` default ``tolerance``.
+DEFAULT_RESDMD_GATE_TOLERANCE = 1e-2
+
+
+@dataclass(frozen=True)
+class ResDMDGateCandidate:
+ """One dictionary scored by train MSE and max ResDMD residual.
+
+ ``mse`` is a dimensionless one-step mean squared error on that
+ dictionary, not a physical unit.
+
+ Attributes
+ ----------
+ name : str
+ Non-empty candidate label.
+ mse : float
+ Train one-step mean squared error (non-negative, finite).
+ residual_max : float
+ Maximum finite-dictionary ResDMD residual (non-negative, finite).
+ """
+
+ name: str
+ mse: float
+ residual_max: float
+
+ def __post_init__(self) -> None:
+ """Validate name and finite non-negative scalars.
+
+ Raises
+ ------
+ ValueError
+ If ``name`` is empty or a scalar is invalid.
+ """
+ if not isinstance(self.name, str) or self.name == "":
+ msg = "name must be a non-empty str"
+ raise ValueError(msg)
+ for field, value in (("mse", self.mse), ("residual_max", self.residual_max)):
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ msg = f"{field} must be a finite float, got {type(value).__name__}"
+ raise ValueError(msg)
+ if not math.isfinite(float(value)) or float(value) < 0.0:
+ msg = f"{field} must be a finite non-negative float, got {value!r}"
+ raise ValueError(msg)
+
+
+@dataclass(frozen=True)
+class ResDMDGateResult:
+ """Outcome of :func:`select_resdmd_gated`.
+
+ ``rejected_alternatives`` lists names dropped for exceeding the
+ residual tolerance when ``gated`` is True. Non-winners that passed
+ the residual check are not listed.
+
+ Attributes
+ ----------
+ selected : str
+ Name of the retained candidate.
+ rejected_alternatives : tuple of str
+ Polluted names dropped by the gate (empty when ``gated`` is
+ False).
+ residual_tolerance : float
+ Cutoff applied to ``residual_max``.
+ gated : bool
+ Whether residual filtering ran.
+ """
+
+ selected: str
+ rejected_alternatives: tuple[str, ...]
+ residual_tolerance: float
+ gated: bool
+
+ def __post_init__(self) -> None:
+ """Validate selected name, rejected list, and tolerance.
+
+ Raises
+ ------
+ ValueError
+ If fields are malformed.
+ """
+ if not isinstance(self.selected, str) or self.selected == "":
+ msg = "selected must be a non-empty str"
+ raise ValueError(msg)
+ if not isinstance(self.rejected_alternatives, tuple):
+ msg = "rejected_alternatives must be a tuple of str"
+ raise ValueError(msg)
+ if any(
+ not isinstance(name, str) or name == ""
+ for name in self.rejected_alternatives
+ ):
+ msg = "rejected_alternatives entries must be non-empty strings"
+ raise ValueError(msg)
+ if isinstance(self.residual_tolerance, bool) or not isinstance(
+ self.residual_tolerance, (int, float)
+ ):
+ msg = (
+ "residual_tolerance must be a finite float, got "
+ f"{type(self.residual_tolerance).__name__}"
+ )
+ raise ValueError(msg)
+ if (
+ not math.isfinite(float(self.residual_tolerance))
+ or float(self.residual_tolerance) < 0.0
+ ):
+ msg = (
+ "residual_tolerance must be a finite non-negative float, "
+ f"got {self.residual_tolerance!r}"
+ )
+ raise ValueError(msg)
+ if type(self.gated) is not bool:
+ msg = f"gated must be a bool, got {type(self.gated).__name__}"
+ raise ValueError(msg)
+
+
+def select_resdmd_gated(
+ candidates: Sequence[ResDMDGateCandidate],
+ *,
+ residual_tolerance: float = DEFAULT_RESDMD_GATE_TOLERANCE,
+ gate_resdmd: bool = True,
+) -> ResDMDGateResult:
+ """Pick a candidate by train MSE, optionally dropping polluted dictionaries.
+
+ When ``gate_resdmd`` is False, the lowest ``mse`` wins (RMSE-only
+ rule on these scalars). When True, candidates with
+ ``residual_max > residual_tolerance`` are dropped first. This is
+ finite-dictionary selection, not a certified ResDMD spectral
+ measure.
+
+ Parameters
+ ----------
+ candidates : sequence of ResDMDGateCandidate
+ Named scores. Names must be unique.
+ residual_tolerance : float, optional
+ Max-residual cutoff. Default ``1e-2`` (same as
+ :func:`~koopman_graph.analysis.resdmd`).
+ gate_resdmd : bool, optional
+ Apply residual filtering. Default is ``True``.
+
+ Returns
+ -------
+ ResDMDGateResult
+ Selected name and any residual-rejected alternatives.
+
+ Raises
+ ------
+ TypeError
+ If ``gate_resdmd`` is not a ``bool``.
+ ValueError
+ If ``candidates`` is empty, names collide, the tolerance is
+ invalid, or every candidate is polluted when gating.
+ """
+ if type(gate_resdmd) is not bool:
+ msg = f"gate_resdmd must be a bool, got {type(gate_resdmd).__name__}"
+ raise TypeError(msg)
+ if isinstance(residual_tolerance, bool) or not isinstance(
+ residual_tolerance, (int, float)
+ ):
+ msg = (
+ "residual_tolerance must be a finite float, got "
+ f"{type(residual_tolerance).__name__}"
+ )
+ raise ValueError(msg)
+ if not math.isfinite(float(residual_tolerance)) or float(residual_tolerance) < 0.0:
+ msg = (
+ "residual_tolerance must be a finite non-negative float, "
+ f"got {residual_tolerance!r}"
+ )
+ raise ValueError(msg)
+ if not isinstance(candidates, Sequence) or isinstance(candidates, (str, bytes)):
+ msg = "candidates must be a sequence of ResDMDGateCandidate"
+ raise ValueError(msg)
+ pool = tuple(candidates)
+ if not pool:
+ msg = "select_resdmd_gated requires at least one candidate"
+ raise ValueError(msg)
+ if any(not isinstance(item, ResDMDGateCandidate) for item in pool):
+ msg = "candidates entries must be ResDMDGateCandidate"
+ raise ValueError(msg)
+ names = [item.name for item in pool]
+ if len(set(names)) != len(names):
+ msg = "candidate names must be unique"
+ raise ValueError(msg)
+ rejected: tuple[str, ...] = ()
+ eligible = pool
+ if gate_resdmd:
+ rejected = tuple(
+ item.name for item in pool if item.residual_max > residual_tolerance
+ )
+ eligible = tuple(
+ item for item in pool if item.residual_max <= residual_tolerance
+ )
+ if not eligible:
+ msg = (
+ "ResDMD gate rejected every candidate "
+ f"(residual_max > {residual_tolerance:g})"
+ )
+ raise ValueError(msg)
+ selected = min(eligible, key=lambda item: (item.mse, item.name))
+ return ResDMDGateResult(
+ selected=selected.name,
+ rejected_alternatives=rejected,
+ residual_tolerance=float(residual_tolerance),
+ gated=gate_resdmd,
+ )
diff --git a/src/koopman_graph/identification/invariance.py b/src/koopman_graph/identification/invariance.py
new file mode 100644
index 0000000..3c3931c
--- /dev/null
+++ b/src/koopman_graph/identification/invariance.py
@@ -0,0 +1,278 @@
+"""Finite-sample subspace invariance proximity (projection leakage).
+
+Let :math:`\\psi_t` be encoded snapshots and :math:`P` the orthogonal
+projector onto a numerical basis of those encodings (truncated SVD).
+The leakage
+
+.. math::
+
+ \\eta
+ =
+ \\frac{\\mathbb{E}\\,\\|(I-P)K\\psi\\|_{2}}
+ {\\mathbb{E}\\,\\|K\\psi\\|_{2}}
+
+is a dimensionless finite-sample proximity of the encoded cloud to a
+:math:`K`-invariant subspace of :math:`\\mathbb{R}^{d}`. It is **not**
+the Haseli–Cortés invariance-proximity certificate (principal angles /
+worst-case relative error on a function subspace), **not**
+:class:`~koopman_graph.losses.ForwardConsistencyLoss`, and **not**
+:func:`~koopman_graph.analysis.spectral_residuals`.
+
+This module must not import :mod:`koopman_graph.training` or
+:mod:`koopman_graph.model`.
+
+References
+----------
+Haseli, M. & Cortés, J. (2023). Invariance proximity: closed-form
+error bounds for finite-dimensional Koopman-based models.
+arXiv:2311.13033.
+(``HaseliCortes2023``)
+"""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass
+
+import torch
+from torch import Tensor
+
+__all__ = [
+ "SINGULAR_VALUE_REL_CUTOFF",
+ "SubspaceInvarianceReport",
+ "subspace_invariance_report",
+]
+
+SINGULAR_VALUE_REL_CUTOFF = 1e-8
+_MIN_HELDOUT_TIMES = 4
+
+
+@dataclass(frozen=True)
+class SubspaceInvarianceReport:
+ """Finite-sample projection leakage for a per-node latent map.
+
+ ``leakage`` is dimensionless (a ratio of Euclidean means). It is
+ **not** a Haseli–Cortés certificate.
+
+ Attributes
+ ----------
+ leakage : float
+ :math:`\\eta` on the selected time window.
+ n_samples : int
+ Number of node×time rows in that window.
+ rank : int
+ Numerical rank of the encoding basis (truncated SVD).
+ held_out : bool
+ Whether the last half of the time axis was used.
+ """
+
+ leakage: float
+ n_samples: int
+ rank: int
+ held_out: bool
+
+ def __post_init__(self) -> None:
+ """Validate leakage, counts, and rank.
+
+ Raises
+ ------
+ ValueError
+ If ``leakage`` is non-finite or negative, or if counts are
+ invalid.
+ """
+ if isinstance(self.leakage, bool) or not isinstance(self.leakage, (int, float)):
+ msg = f"leakage must be a finite float, got {type(self.leakage).__name__}"
+ raise ValueError(msg)
+ if not math.isfinite(float(self.leakage)) or self.leakage < 0.0:
+ msg = f"leakage must be a finite non-negative float, got {self.leakage!r}"
+ raise ValueError(msg)
+ if isinstance(self.n_samples, bool) or not isinstance(self.n_samples, int):
+ msg = (
+ f"n_samples must be a positive int, got {type(self.n_samples).__name__}"
+ )
+ raise ValueError(msg)
+ if self.n_samples < 1:
+ msg = f"n_samples must be >= 1, got {self.n_samples}"
+ raise ValueError(msg)
+ if isinstance(self.rank, bool) or not isinstance(self.rank, int):
+ msg = f"rank must be a positive int, got {type(self.rank).__name__}"
+ raise ValueError(msg)
+ if self.rank < 1:
+ msg = f"rank must be >= 1, got {self.rank}"
+ raise ValueError(msg)
+ if type(self.held_out) is not bool:
+ msg = f"held_out must be a bool, got {type(self.held_out).__name__}"
+ raise ValueError(msg)
+
+
+def _as_time_major(encodings: Tensor) -> Tensor:
+ """Require time-major encodings with trailing latent width.
+
+ Parameters
+ ----------
+ encodings : Tensor
+ ``(T, d)`` or ``(T, N, d)``.
+
+ Returns
+ -------
+ Tensor
+ The input tensor if the layout is valid.
+
+ Raises
+ ------
+ ValueError
+ If the tensor is not 2-D or 3-D.
+ """
+ if encodings.ndim not in {2, 3}:
+ msg = (
+ "encodings must have shape (T, d) or (T, N, d), "
+ f"got {tuple(encodings.shape)}"
+ )
+ raise ValueError(msg)
+ return encodings
+
+
+def _select_window(encodings: Tensor, held_out: bool) -> Tensor:
+ """Optionally keep the last half of the time axis.
+
+ Parameters
+ ----------
+ encodings : Tensor
+ Time-major encodings.
+ held_out : bool
+ When ``True``, keep ``encodings[T // 2 :]``.
+
+ Returns
+ -------
+ Tensor
+ Selected time window.
+
+ Raises
+ ------
+ ValueError
+ If ``held_out`` is True and ``T < 4``.
+ """
+ n_times = encodings.shape[0]
+ if not held_out:
+ if n_times < 1:
+ msg = "encodings must contain at least one snapshot"
+ raise ValueError(msg)
+ return encodings
+ if n_times < _MIN_HELDOUT_TIMES:
+ msg = (
+ "held_out=True requires at least "
+ f"{_MIN_HELDOUT_TIMES} snapshots, got {n_times}"
+ )
+ raise ValueError(msg)
+ return encodings[n_times // 2 :]
+
+
+def _orthonormal_basis(rows: Tensor) -> Tensor:
+ """Orthonormal columns spanning the row space of ``rows``.
+
+ Parameters
+ ----------
+ rows : Tensor
+ Samples with shape ``(n_samples, d)``.
+
+ Returns
+ -------
+ Tensor
+ Basis ``Q`` with shape ``(d, rank)``.
+
+ Raises
+ ------
+ ValueError
+ If every singular value is below the relative cutoff.
+ """
+ _, singular_values, vh = torch.linalg.svd(rows, full_matrices=False)
+ peak = float(singular_values[0].item()) if singular_values.numel() else 0.0
+ if not math.isfinite(peak) or peak <= 0.0:
+ msg = "encoding basis is degenerate (no positive singular value)"
+ raise ValueError(msg)
+ cutoff = SINGULAR_VALUE_REL_CUTOFF * peak
+ keep = singular_values > cutoff
+ rank = int(keep.sum().item())
+ if rank < 1:
+ msg = (
+ "encoding basis rank is 0 after relative SVD cutoff "
+ f"{SINGULAR_VALUE_REL_CUTOFF:g}"
+ )
+ raise ValueError(msg)
+ return vh[:rank].T
+
+
+def subspace_invariance_report(
+ encodings: Tensor,
+ matrix: Tensor,
+ *,
+ held_out: bool = True,
+) -> SubspaceInvarianceReport:
+ """Compute projection leakage :math:`\\eta` for a dense per-node map.
+
+ Encodings are samples in :math:`\\mathbb{R}^{d}` (node×time rows).
+ ``matrix`` is the package row-convention operator ``K`` so
+ ``K ψ = ψ @ K.T``. ``P`` is the orthogonal projector onto the
+ truncated-SVD basis of the selected encodings. ``held_out=True``
+ (default) builds ``P`` and the expectation on the last half of the
+ time axis (same window: is that cloud approximately ``K``-invariant?).
+
+ Parameters
+ ----------
+ encodings : Tensor
+ Time-major latents ``(T, d)`` or ``(T, N, d)``.
+ matrix : Tensor
+ Dense ``K`` with shape ``(d, d)``.
+ held_out : bool, optional
+ Use the last half of the time axis. Default is ``True``.
+ Requires ``T >= 4``.
+
+ Returns
+ -------
+ SubspaceInvarianceReport
+ Leakage, sample count, numerical rank, and ``held_out`` flag.
+
+ Raises
+ ------
+ ValueError
+ If layouts disagree, the hold-out window is too short, the
+ basis is degenerate, or :math:`\\mathbb{E}\\|K\\psi\\|_{2}` is
+ zero.
+ TypeError
+ If ``held_out`` is not a ``bool``.
+ """
+ if type(held_out) is not bool:
+ msg = f"held_out must be a bool, got {type(held_out).__name__}"
+ raise TypeError(msg)
+ encodings = _as_time_major(encodings)
+ if matrix.ndim != 2 or matrix.shape[0] != matrix.shape[1]:
+ msg = f"matrix must be square 2-D, got shape {tuple(matrix.shape)}"
+ raise ValueError(msg)
+ latent_dim = encodings.shape[-1]
+ if matrix.shape[0] != latent_dim:
+ msg = (
+ "matrix trailing width must match encodings, "
+ f"got K {tuple(matrix.shape)} vs latent_dim {latent_dim}"
+ )
+ raise ValueError(msg)
+ window = _select_window(encodings, held_out)
+ rows = window.reshape(-1, latent_dim)
+ if rows.shape[0] < 1:
+ msg = "invariance report requires at least one encoding row"
+ raise ValueError(msg)
+ basis = _orthonormal_basis(rows)
+ mapped = rows @ matrix.T.to(dtype=rows.dtype, device=rows.device)
+ projected = (mapped @ basis) @ basis.T
+ residual_mean = (mapped - projected).norm(dim=-1).mean()
+ mapped_mean = mapped.norm(dim=-1).mean()
+ denom = float(mapped_mean.item())
+ if not math.isfinite(denom) or denom <= 0.0:
+ msg = "E||K ψ||_2 must be positive and finite to form η"
+ raise ValueError(msg)
+ leakage = float((residual_mean / mapped_mean).item())
+ return SubspaceInvarianceReport(
+ leakage=leakage,
+ n_samples=int(rows.shape[0]),
+ rank=int(basis.shape[1]),
+ held_out=held_out,
+ )
diff --git a/src/koopman_graph/identification/metrics.py b/src/koopman_graph/identification/metrics.py
new file mode 100644
index 0000000..1c05ee1
--- /dev/null
+++ b/src/koopman_graph/identification/metrics.py
@@ -0,0 +1,283 @@
+"""Finite-sample identification report helpers.
+
+Populate one-step latent mean squared error (MSE), a short latent
+rollout MSE, and the spectral radius of identified ``K``. When
+``gate_resdmd=True``, also fill the finite-dictionary ResDMD
+``spectral`` block. Invariance, reconstruction, and closure stay
+unset. These scalars are not Haseli–Cortés, certified ResDMD, or
+stability certificates.
+
+This module must not import :mod:`koopman_graph.training` or
+:mod:`koopman_graph.model`.
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Sequence
+
+import torch
+from torch import Tensor
+
+from koopman_graph.identification.protocol import LatentPairs, OperatorSnapshot
+from koopman_graph.identification.report import (
+ IdentificationReport,
+ MetricBlock,
+ SpectralReliabilityBlock,
+ StabilityBlock,
+)
+
+__all__ = [
+ "DEFAULT_IDENTIFICATION_ROLLOUT_HORIZON",
+ "build_identification_report",
+]
+
+DEFAULT_IDENTIFICATION_ROLLOUT_HORIZON = 5
+
+
+def _flatten_trailing(z_t: Tensor, z_next: Tensor) -> tuple[Tensor, Tensor]:
+ """Flatten encodings to ``(n_samples, d)``.
+
+ Parameters
+ ----------
+ z_t : Tensor
+ Source encodings.
+ z_next : Tensor
+ Target encodings, same shape as ``z_t``.
+
+ Returns
+ -------
+ tuple of Tensor
+ Flattened source and target.
+ """
+ width = z_t.shape[-1]
+ return z_t.reshape(-1, width), z_next.reshape(-1, width)
+
+
+def _one_step_mse(encodings: LatentPairs, matrix: Tensor) -> MetricBlock:
+ """Latent one-step mean squared error under ``z @ K.T``.
+
+ Parameters
+ ----------
+ encodings : LatentPairs
+ Consecutive encodings.
+ matrix : Tensor
+ Identified ``K`` with shape ``(d, d)``.
+
+ Returns
+ -------
+ MetricBlock
+ MSE over tensor entries and the entry count.
+ """
+ left, right = _flatten_trailing(encodings.z_t, encodings.z_next)
+ residual = left @ matrix.T - right
+ squared = residual.square()
+ n_samples = int(squared.numel())
+ mse = float(squared.mean().item()) if n_samples else 0.0
+ return MetricBlock(mse=mse, n_samples=n_samples)
+
+
+def _rollout_mse(
+ encodings: LatentPairs,
+ matrix: Tensor,
+ horizon: int,
+) -> MetricBlock:
+ """Latent multi-step MSE along the leading time axis.
+
+ Restores a length-``T`` trajectory as ``[z_t[0], z_next[0], …]`` and
+ rolls ``z_t[0]`` for ``min(horizon, T-1)`` steps. For 2-D pairs the
+ sample axis is treated as time (one vector per row); prefer 3-D
+ ``(T-1, N, d)`` so nodes stay aligned.
+
+ Parameters
+ ----------
+ encodings : LatentPairs
+ Consecutive encodings.
+ matrix : Tensor
+ Identified ``K``.
+ horizon : int
+ Maximum rollout steps.
+
+ Returns
+ -------
+ MetricBlock
+ Mean squared error over rollout entries, or empty when ``T < 2``.
+ """
+ stacked = torch.cat([encodings.z_t[:1], encodings.z_next], dim=0)
+ n_times = stacked.shape[0]
+ steps = min(horizon, n_times - 1)
+ if steps < 1:
+ return MetricBlock()
+ predicted = stacked[0]
+ total = stacked.new_zeros(())
+ n_samples = 0
+ for step in range(1, steps + 1):
+ predicted = predicted @ matrix.T
+ err = (predicted - stacked[step]).square()
+ total = total + err.sum()
+ n_samples += int(err.numel())
+ mse = float((total / n_samples).item()) if n_samples else 0.0
+ return MetricBlock(mse=mse, n_samples=n_samples)
+
+
+def _mean_metric(blocks: Sequence[MetricBlock]) -> MetricBlock:
+ """Weighted mean of MSE blocks by ``n_samples``.
+
+ Parameters
+ ----------
+ blocks : sequence of MetricBlock
+ Per-trajectory summaries. Empty or unset blocks are skipped.
+
+ Returns
+ -------
+ MetricBlock
+ Pooled MSE, or an empty block when nothing was scored.
+ """
+ weighted = 0.0
+ n_total = 0
+ for block in blocks:
+ if block.mse is None or block.n_samples is None or block.n_samples == 0:
+ continue
+ weighted += block.mse * block.n_samples
+ n_total += block.n_samples
+ if n_total == 0:
+ return MetricBlock()
+ return MetricBlock(mse=weighted / n_total, n_samples=n_total)
+
+
+def _spectral_reliability(
+ blocks: Sequence[LatentPairs],
+ *,
+ tolerance: float,
+) -> SpectralReliabilityBlock:
+ """Finite-dictionary ResDMD max residual on flattened latent pairs.
+
+ Parameters
+ ----------
+ blocks : sequence of LatentPairs
+ Consecutive encodings (pooled along the sample axis).
+ tolerance : float
+ Pollution cutoff (same units as ResDMD residuals).
+
+ Returns
+ -------
+ SpectralReliabilityBlock
+ ``residual_max`` and ``polluted`` (``residual_max > tolerance``).
+ """
+ from koopman_graph.analysis.resdmd import resdmd
+
+ left_parts = []
+ right_parts = []
+ for block in blocks:
+ left, right = _flatten_trailing(block.z_t, block.z_next)
+ left_parts.append(left)
+ right_parts.append(right)
+ psi0 = torch.cat(left_parts, dim=0)
+ psi1 = torch.cat(right_parts, dim=0)
+ report = resdmd(psi0, psi1, tolerance=tolerance)
+ residual_max = float(report.residuals.max().real.item())
+ if not math.isfinite(residual_max) or residual_max < 0.0:
+ msg = (
+ "ResDMD residual_max must be a finite non-negative float, "
+ f"got {residual_max!r}"
+ )
+ raise ValueError(msg)
+ return SpectralReliabilityBlock(
+ residual_max=residual_max,
+ polluted=residual_max > tolerance,
+ )
+
+
+def build_identification_report(
+ encodings: LatentPairs | Sequence[LatentPairs],
+ snapshot: OperatorSnapshot,
+ *,
+ rollout_horizon: int = DEFAULT_IDENTIFICATION_ROLLOUT_HORIZON,
+ gate_resdmd: bool = False,
+ residual_tolerance: float = 1e-2,
+) -> IdentificationReport:
+ """Fill one-step, short rollout, and spectral-radius slots.
+
+ Reconstruction, closure, and invariance stay empty. When
+ ``gate_resdmd`` is True, ``spectral`` records the max
+ finite-dictionary ResDMD residual on the flattened encodings.
+ ``rollout_horizon`` is a step count, not a physical duration.
+
+ Parameters
+ ----------
+ encodings : LatentPairs or sequence of LatentPairs
+ Encodings used to score the snapshot (typically the same pairs
+ used to fit, or a re-encode after the last encoder step).
+ snapshot : OperatorSnapshot
+ Identified dense ``matrix``.
+ rollout_horizon : int, optional
+ Maximum latent rollout steps. Default is ``5``.
+ gate_resdmd : bool, optional
+ Fill :class:`~koopman_graph.identification.SpectralReliabilityBlock`.
+ Default is ``False``.
+ residual_tolerance : float, optional
+ Pollution cutoff when ``gate_resdmd`` is True. Default ``1e-2``.
+
+ Returns
+ -------
+ IdentificationReport
+ Frozen report with populated ``one_step``, ``rollout``, and
+ ``stability`` when ``matrix`` is set.
+
+ Raises
+ ------
+ ValueError
+ If ``snapshot.matrix`` is missing, ``rollout_horizon < 1``, or
+ ``residual_tolerance`` is invalid.
+ TypeError
+ If ``gate_resdmd`` is not a ``bool``.
+ """
+ if type(gate_resdmd) is not bool:
+ msg = f"gate_resdmd must be a bool, got {type(gate_resdmd).__name__}"
+ raise TypeError(msg)
+ if rollout_horizon < 1:
+ msg = f"rollout_horizon must be >= 1, got {rollout_horizon}"
+ raise ValueError(msg)
+ if isinstance(residual_tolerance, bool) or not isinstance(
+ residual_tolerance, (int, float)
+ ):
+ msg = (
+ "residual_tolerance must be a finite float, got "
+ f"{type(residual_tolerance).__name__}"
+ )
+ raise ValueError(msg)
+ if not math.isfinite(float(residual_tolerance)) or float(residual_tolerance) < 0.0:
+ msg = (
+ "residual_tolerance must be a finite non-negative float, "
+ f"got {residual_tolerance!r}"
+ )
+ raise ValueError(msg)
+ matrix = snapshot.matrix
+ if matrix is None:
+ msg = "build_identification_report requires snapshot.matrix"
+ raise ValueError(msg)
+ radius = float(torch.linalg.eigvals(matrix).abs().max().real.item())
+ if not math.isfinite(radius) or radius < 0.0:
+ msg = f"spectral radius must be a finite non-negative float, got {radius!r}"
+ raise ValueError(msg)
+ blocks: tuple[LatentPairs, ...]
+ if isinstance(encodings, LatentPairs):
+ blocks = (encodings,)
+ else:
+ blocks = tuple(encodings)
+ if not blocks:
+ msg = "build_identification_report requires at least one LatentPairs"
+ raise ValueError(msg)
+ spectral = (
+ _spectral_reliability(blocks, tolerance=float(residual_tolerance))
+ if gate_resdmd
+ else SpectralReliabilityBlock()
+ )
+ return IdentificationReport(
+ one_step=_mean_metric([_one_step_mse(block, matrix) for block in blocks]),
+ rollout=_mean_metric(
+ [_rollout_mse(block, matrix, rollout_horizon) for block in blocks]
+ ),
+ spectral=spectral,
+ stability=StabilityBlock(spectral_radius=radius),
+ )
diff --git a/src/koopman_graph/identification/protocol.py b/src/koopman_graph/identification/protocol.py
new file mode 100644
index 0000000..f52092e
--- /dev/null
+++ b/src/koopman_graph/identification/protocol.py
@@ -0,0 +1,150 @@
+"""Identification backend protocol and encoding / operator snapshots.
+
+:class:`IdentificationBackend.fit_operator` is the plug-in for closed-form
+solvers. The default Adam ``fit`` path does not call this protocol.
+
+This module must not import :mod:`koopman_graph.training` or
+:mod:`koopman_graph.model`.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Protocol, runtime_checkable
+
+from torch import Tensor
+
+from koopman_graph.identification.config import IdentificationConfig
+
+__all__ = [
+ "IdentificationBackend",
+ "LatentPairs",
+ "OperatorSnapshot",
+]
+
+
+@dataclass(frozen=True)
+class LatentPairs:
+ """Consecutive latent encodings for a linear operator fit.
+
+ ``z_t`` and ``z_next`` share ``shape``, dtype, and device. Trailing
+ width is the latent dimension :math:`d` (or :math:`N\\cdot d` when
+ flattened).
+
+ Attributes
+ ----------
+ z_t : Tensor
+ Encodings at time :math:`t`.
+ z_next : Tensor
+ Encodings at time :math:`t+1`, same shape as ``z_t``.
+ """
+
+ z_t: Tensor
+ z_next: Tensor
+
+ def __post_init__(self) -> None:
+ """Validate matching shape, dtype, and device.
+
+ Raises
+ ------
+ TypeError
+ If either field is not a ``Tensor``.
+ ValueError
+ If shape, dtype, or device differ.
+ """
+ z_t = self.z_t
+ z_next = self.z_next
+ if not isinstance(z_t, Tensor) or not isinstance(z_next, Tensor):
+ msg = "z_t and z_next must be torch.Tensor"
+ raise TypeError(msg)
+ if z_t.shape != z_next.shape:
+ msg = (
+ "z_t and z_next must share shape, "
+ f"got {tuple(z_t.shape)} vs {tuple(z_next.shape)}"
+ )
+ raise ValueError(msg)
+ if z_t.dtype != z_next.dtype:
+ msg = f"z_t and z_next must share dtype, got {z_t.dtype} vs {z_next.dtype}"
+ raise ValueError(msg)
+ if z_t.device != z_next.device:
+ msg = (
+ f"z_t and z_next must share device, got {z_t.device} vs {z_next.device}"
+ )
+ raise ValueError(msg)
+
+
+@dataclass(frozen=True)
+class OperatorSnapshot:
+ """Identified operator factors or a dense matrix.
+
+ At least one of ``matrix``, ``k_self``, or ``k_nbr`` must be set.
+ Tensors are borrowed, not cloned.
+
+ Attributes
+ ----------
+ matrix : Tensor or None
+ Dense :math:`(n, n)` map on flattened latents.
+ k_self : Tensor or None
+ Shared self factor :math:`(d, d)`.
+ k_nbr : Tensor or None
+ Shared neighbor factor :math:`(d, d)`.
+ """
+
+ matrix: Tensor | None = None
+ k_self: Tensor | None = None
+ k_nbr: Tensor | None = None
+
+ def __post_init__(self) -> None:
+ """Validate presence and types of factor slots.
+
+ Raises
+ ------
+ TypeError
+ If a provided slot is not a ``Tensor``.
+ ValueError
+ If every slot is ``None``.
+ """
+ for name, value in (
+ ("matrix", self.matrix),
+ ("k_self", self.k_self),
+ ("k_nbr", self.k_nbr),
+ ):
+ if value is not None and not isinstance(value, Tensor):
+ msg = f"{name} must be a Tensor or None"
+ raise TypeError(msg)
+ if self.matrix is None and self.k_self is None and self.k_nbr is None:
+ msg = "OperatorSnapshot requires matrix, k_self, or k_nbr"
+ raise ValueError(msg)
+
+
+@runtime_checkable
+class IdentificationBackend(Protocol):
+ """Closed-form operator estimator on frozen encodings.
+
+ Notes
+ -----
+ Implementers return an :class:`OperatorSnapshot`. Opt-in
+ ``fit(..., identification=...)`` calls
+ :func:`~koopman_graph.identification.identify_operator` by default.
+ """
+
+ def fit_operator(
+ self,
+ encodings: LatentPairs,
+ config: IdentificationConfig,
+ ) -> OperatorSnapshot:
+ """Fit an operator snapshot from consecutive latent encodings.
+
+ Parameters
+ ----------
+ encodings : LatentPairs
+ Frozen ``z_t`` / ``z_next`` pairs.
+ config : IdentificationConfig
+ Solver name and ridge / selection options.
+
+ Returns
+ -------
+ OperatorSnapshot
+ Dense map and/or factorized :math:`d\\times d` blocks.
+ """
+ ...
diff --git a/src/koopman_graph/identification/rank.py b/src/koopman_graph/identification/rank.py
new file mode 100644
index 0000000..97c325d
--- /dev/null
+++ b/src/koopman_graph/identification/rank.py
@@ -0,0 +1,607 @@
+"""Package-owned latent-rank selection on frozen encodings.
+
+For each candidate rank :math:`r`, encodings are projected onto the
+leading :math:`r` right singular vectors of the train cloud and scored
+by VAMP-2, a finite-dictionary ResDMD residual, or a
+stability-penalized held-out one-step mean squared error (MSE). This
+selects a numerical dictionary rank. It is **not** Ray Tune / AutoML
+for encoder ``latent_dim``, and it does not train a model per
+candidate.
+
+``criterion="vamp2"`` uses the in-tree topology-blind
+:func:`~koopman_graph.baselines.vamp2.vamp2_score` (Wu and Noé, 2020).
+deeptime (``[msm]``) is an optional cross-check, not a runtime
+requirement. ``criterion="resdmd_elbow"`` is not a certified
+infinite-dimensional residual bound.
+
+This module must not import :mod:`koopman_graph.training` or
+:mod:`koopman_graph.model`. ``analysis.resdmd`` and ``baselines.vamp2``
+are imported lazily inside the matching criterion branch so loading
+this package does not import :mod:`koopman_graph.model`.
+
+References
+----------
+Wu, H. & Noé, F. (2020). Variational approach for learning Markov
+processes from time series data. *Journal of Nonlinear Science*,
+30(1), 23–66. https://doi.org/10.1007/s00332-019-09567-y
+(``Wu2020VAMP``)
+
+Colbrook, M. J. and Townsend, A. (2023/2024). Rigorous data-driven
+computation of spectral properties of Koopman operators for dynamical
+systems. *Communications on Pure and Applied Mathematics*, 77(1),
+221–283. https://doi.org/10.1002/cpa.22125
+(``ColbrookTownsend2023ResDMD``)
+
+Hoffmann, M., Scherer, M., Hempel, T., Mardt, A., de Silva, B.,
+Husic, B. E., Klus, S., Wu, H., Kutz, J. N., Brunton, S. L. & Noé, F.
+(2022). Deeptime: a Python library for machine learning dynamical
+models from time series data. *Machine Learning: Science and
+Technology*, 3(1), 015009. https://doi.org/10.1088/2632-2153/ac3de0
+(``deeptime2021``)
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Sequence
+from dataclasses import dataclass
+from typing import Literal
+
+import torch
+from torch import Tensor
+
+from koopman_graph.identification.config import IdentificationConfig
+from koopman_graph.identification.invariance import SINGULAR_VALUE_REL_CUTOFF
+from koopman_graph.identification.protocol import LatentPairs
+from koopman_graph.identification.solvers import identify_operator
+
+__all__ = [
+ "DEFAULT_RANK_RIDGE",
+ "DEFAULT_STABILITY_PENALTY",
+ "LatentRankCriterion",
+ "LatentRankReport",
+ "VAMP2_PLATEAU_RELATIVE",
+ "select_latent_rank",
+]
+
+LatentRankCriterion = Literal["vamp2", "resdmd_elbow", "stability_penalized"]
+
+DEFAULT_RANK_RIDGE = 1e-4
+DEFAULT_STABILITY_PENALTY = 1.0
+VAMP2_PLATEAU_RELATIVE = 1e-3
+_MIN_HELDOUT_TIMES = 4
+_CRITERIA = frozenset({"vamp2", "resdmd_elbow", "stability_penalized"})
+
+
+@dataclass(frozen=True)
+class LatentRankReport:
+ """Outcome of :func:`select_latent_rank`.
+
+ ``scores`` align with ``candidates`` (ranks that were scored).
+ ``rejected_alternatives`` lists candidate ranks dropped above the
+ numerical SVD rank. This is not a certificate that encoder
+ ``latent_dim`` should equal ``selected_rank``.
+
+ Attributes
+ ----------
+ selected_rank : int
+ Winning candidate.
+ criterion : {"vamp2", "resdmd_elbow", "stability_penalized"}
+ Scoring rule.
+ candidates : tuple of int
+ Sorted ranks that were scored.
+ scores : tuple of float
+ Criterion values for ``candidates`` (VAMP-2 score, max ResDMD
+ residual, or penalized held-out MSE).
+ rejected_alternatives : tuple of str
+ Candidate ranks discarded as above numerical rank.
+ numerical_rank : int
+ Truncated-SVD rank of the train cloud.
+ n_samples : int
+ Number of train lag pairs used to form the basis / fit.
+ """
+
+ selected_rank: int
+ criterion: LatentRankCriterion
+ candidates: tuple[int, ...]
+ scores: tuple[float, ...]
+ rejected_alternatives: tuple[str, ...]
+ numerical_rank: int
+ n_samples: int
+
+ def __post_init__(self) -> None:
+ """Validate ranks, scores, and counts.
+
+ Raises
+ ------
+ ValueError
+ If ranks, lengths, or scores are invalid.
+ """
+ if self.criterion not in _CRITERIA:
+ msg = (
+ f"criterion must be one of {sorted(_CRITERIA)}, got {self.criterion!r}"
+ )
+ raise ValueError(msg)
+ if type(self.selected_rank) is not int or self.selected_rank < 1:
+ msg = f"selected_rank must be an int >= 1, got {self.selected_rank!r}"
+ raise ValueError(msg)
+ if type(self.numerical_rank) is not int or self.numerical_rank < 1:
+ msg = f"numerical_rank must be an int >= 1, got {self.numerical_rank!r}"
+ raise ValueError(msg)
+ if type(self.n_samples) is not int or self.n_samples < 1:
+ msg = f"n_samples must be an int >= 1, got {self.n_samples!r}"
+ raise ValueError(msg)
+ if len(self.candidates) != len(self.scores) or len(self.candidates) < 1:
+ msg = "candidates and scores must be non-empty and the same length"
+ raise ValueError(msg)
+ if self.selected_rank not in self.candidates:
+ msg = (
+ f"selected_rank {self.selected_rank} is not among scored "
+ f"candidates {self.candidates}"
+ )
+ raise ValueError(msg)
+ for rank in self.candidates:
+ if type(rank) is not int or rank < 1:
+ msg = f"candidates must be positive ints, got {rank!r}"
+ raise ValueError(msg)
+ for score in self.scores:
+ if isinstance(score, bool) or not isinstance(score, (int, float)):
+ msg = f"scores must be finite floats, got {type(score).__name__}"
+ raise ValueError(msg)
+ if not math.isfinite(float(score)):
+ msg = f"scores must be finite, got {score!r}"
+ raise ValueError(msg)
+ if not isinstance(self.rejected_alternatives, tuple) or any(
+ not isinstance(name, str) or name == ""
+ for name in self.rejected_alternatives
+ ):
+ msg = "rejected_alternatives must be a tuple of non-empty strings"
+ raise ValueError(msg)
+
+
+def _finite_float(value: float) -> bool:
+ """Return whether ``value`` is a finite Python float.
+
+ Parameters
+ ----------
+ value : float
+ Scalar to test.
+
+ Returns
+ -------
+ bool
+ ``True`` when ``value`` is finite.
+ """
+ return value == value and value not in (float("inf"), float("-inf"))
+
+
+def _as_time_major(encodings: Tensor, *, name: str) -> Tensor:
+ """Require time-major encodings with trailing width.
+
+ Parameters
+ ----------
+ encodings : Tensor
+ ``(T, d)`` or ``(T, N, d)``.
+ name : str
+ Field name for errors.
+
+ Returns
+ -------
+ Tensor
+ The input tensor if the layout is valid.
+
+ Raises
+ ------
+ ValueError
+ If the layout is not 2-D / 3-D or ``T < 2``.
+ """
+ if encodings.ndim not in {2, 3}:
+ msg = (
+ f"{name} must have shape (T, d) or (T, N, d), got {tuple(encodings.shape)}"
+ )
+ raise ValueError(msg)
+ if not encodings.is_floating_point():
+ msg = f"{name} must be floating-point, got {encodings.dtype}"
+ raise ValueError(msg)
+ if int(encodings.shape[0]) < 2:
+ msg = (
+ f"{name} requires T >= 2 consecutive encodings, got T={encodings.shape[0]}"
+ )
+ raise ValueError(msg)
+ if int(encodings.shape[-1]) < 1:
+ msg = f"{name} requires trailing width d >= 1, got d={encodings.shape[-1]}"
+ raise ValueError(msg)
+ return encodings
+
+
+def _lag_pairs(encodings: Tensor) -> tuple[Tensor, Tensor]:
+ """Flatten consecutive snapshots to ``(M, d)`` lag pairs.
+
+ Parameters
+ ----------
+ encodings : Tensor
+ Time-major ``(T, d)`` or ``(T, N, d)``.
+
+ Returns
+ -------
+ tuple of Tensor
+ Source and target with shape ``((T-1) N, d)``.
+ """
+ width = encodings.shape[-1]
+ return encodings[:-1].reshape(-1, width), encodings[1:].reshape(-1, width)
+
+
+def _right_singular_basis(rows: Tensor) -> Tensor:
+ """Leading right singular vectors of the encoding cloud.
+
+ Parameters
+ ----------
+ rows : Tensor
+ Stacked samples with shape ``(n_samples, d)``.
+
+ Returns
+ -------
+ Tensor
+ ``Vh`` with shape ``(rank, d)``.
+
+ Raises
+ ------
+ ValueError
+ If every singular value is below the relative cutoff.
+ """
+ _, singular_values, vh = torch.linalg.svd(rows, full_matrices=False)
+ peak = float(singular_values[0].item()) if singular_values.numel() else 0.0
+ if not math.isfinite(peak) or peak <= 0.0:
+ msg = "encoding basis is degenerate (no positive singular value)"
+ raise ValueError(msg)
+ cutoff = SINGULAR_VALUE_REL_CUTOFF * peak
+ rank = int((singular_values > cutoff).sum().item())
+ if rank < 1:
+ msg = (
+ "encoding basis rank is 0 after relative SVD cutoff "
+ f"{SINGULAR_VALUE_REL_CUTOFF:g}"
+ )
+ raise ValueError(msg)
+ return vh[:rank]
+
+
+def _project(encodings: Tensor, basis: Tensor) -> Tensor:
+ """Project trailing features onto ``basis`` rows.
+
+ Parameters
+ ----------
+ encodings : Tensor
+ Time-major encodings with trailing width ``d``.
+ basis : Tensor
+ Right singular vectors with shape ``(r, d)``.
+
+ Returns
+ -------
+ Tensor
+ Encodings with trailing width ``r``.
+ """
+ return encodings @ basis.to(dtype=encodings.dtype, device=encodings.device).T
+
+
+def _parse_candidates(candidates: Sequence[int]) -> tuple[int, ...]:
+ """Return unique sorted positive ranks.
+
+ Parameters
+ ----------
+ candidates : sequence of int
+ Proposed ranks.
+
+ Returns
+ -------
+ tuple of int
+ Unique ranks in increasing order.
+
+ Raises
+ ------
+ ValueError
+ If ``candidates`` is empty or contains a non-positive int.
+ """
+ if isinstance(candidates, (str, bytes)) or not isinstance(candidates, Sequence):
+ msg = "candidates must be a sequence of positive ints"
+ raise ValueError(msg)
+ parsed: list[int] = []
+ for rank in candidates:
+ if type(rank) is not int or rank < 1:
+ msg = f"candidates must be positive ints, got {rank!r}"
+ raise ValueError(msg)
+ parsed.append(rank)
+ if not parsed:
+ msg = "candidates must be a non-empty sequence of positive ints"
+ raise ValueError(msg)
+ return tuple(sorted(set(parsed)))
+
+
+def _spectral_radius(matrix: Tensor) -> float:
+ """Return :math:`\\rho(K)` as a finite float.
+
+ Parameters
+ ----------
+ matrix : Tensor
+ Square operator.
+
+ Returns
+ -------
+ float
+ Spectral radius.
+
+ Raises
+ ------
+ ValueError
+ If the radius is non-finite.
+ """
+ radius = float(torch.linalg.eigvals(matrix).abs().max().real.item())
+ if not math.isfinite(radius):
+ msg = f"spectral radius is non-finite, got {radius!r}"
+ raise ValueError(msg)
+ return radius
+
+
+def _vamp2_score(left: Tensor, right: Tensor) -> float:
+ """Lazy in-tree VAMP-2 score on lag pairs.
+
+ Parameters
+ ----------
+ left, right : Tensor
+ Flattened lag pair.
+
+ Returns
+ -------
+ float
+ Topology-blind VAMP-2 score.
+ """
+ from koopman_graph.baselines.vamp2 import vamp2_score
+
+ return float(vamp2_score(left, right).item())
+
+
+def _resdmd_max_residual(left: Tensor, right: Tensor) -> float:
+ """Lazy max finite-dictionary ResDMD residual on a projected dictionary.
+
+ Parameters
+ ----------
+ left, right : Tensor
+ Flattened lag pair (the dictionary).
+
+ Returns
+ -------
+ float
+ Maximum Colbrook–Townsend residual.
+ """
+ from koopman_graph.analysis.resdmd import resdmd
+
+ report = resdmd(left, right)
+ return float(report.residuals.max().item())
+
+
+def _stability_score(
+ train_left: Tensor,
+ train_right: Tensor,
+ score_left: Tensor,
+ score_right_ambient: Tensor,
+ basis_r: Tensor,
+ *,
+ ridge: float,
+ penalty: float,
+) -> float:
+ """Held-out ambient reconstruction MSE plus a spectral-radius excess penalty.
+
+ Parameters
+ ----------
+ train_left, train_right : Tensor
+ Projected pairs used to fit ``K``.
+ score_left : Tensor
+ Held-out projected source encodings.
+ score_right_ambient : Tensor
+ Held-out target encodings in the original trailing width.
+ basis_r : Tensor
+ Top-``r`` right singular vectors with shape ``(r, d)``.
+ ridge : float
+ Tikhonov weight.
+ penalty : float
+ Weight on ``max(0, ρ(K) - 1)``.
+
+ Returns
+ -------
+ float
+ Penalized held-out ambient MSE.
+ """
+ snapshot = identify_operator(
+ LatentPairs(z_t=train_left, z_next=train_right),
+ IdentificationConfig(solver="ridge", ridge=ridge),
+ )
+ matrix = snapshot.matrix
+ if matrix is None:
+ msg = "identify_operator must return a dense matrix"
+ raise ValueError(msg)
+ predicted = (score_left @ matrix.T) @ basis_r.to(
+ dtype=score_left.dtype,
+ device=score_left.device,
+ )
+ mse = float((predicted - score_right_ambient).square().mean().item())
+ excess = max(0.0, _spectral_radius(matrix) - 1.0)
+ return mse + penalty * excess
+
+
+def _pick_rank(
+ candidates: tuple[int, ...],
+ scores: tuple[float, ...],
+ *,
+ criterion: LatentRankCriterion,
+) -> int:
+ """Apply the documented selection rule.
+
+ Parameters
+ ----------
+ candidates, scores : tuple
+ Aligned scored ranks.
+ criterion : str
+ Scoring rule.
+
+ Returns
+ -------
+ int
+ Selected rank.
+ """
+ if criterion == "vamp2":
+ peak = max(scores)
+ floor = (1.0 - VAMP2_PLATEAU_RELATIVE) * peak
+ eligible = [
+ rank
+ for rank, score in zip(candidates, scores, strict=True)
+ if score >= floor
+ ]
+ return min(eligible)
+ best = scores[0]
+ chosen = candidates[0]
+ for rank, score in zip(candidates[1:], scores[1:], strict=True):
+ if score < best:
+ best = score
+ chosen = rank
+ return chosen
+
+
+def select_latent_rank(
+ z_pairs: Tensor,
+ candidates: Sequence[int],
+ *,
+ criterion: LatentRankCriterion,
+ validation_z: Tensor | None = None,
+ ridge: float = DEFAULT_RANK_RIDGE,
+ stability_penalty: float = DEFAULT_STABILITY_PENALTY,
+) -> LatentRankReport:
+ """Select a numerical rank of frozen encodings over a candidate grid.
+
+ Distinct from :mod:`koopman_graph.tuning` Ray Tune scaffolds and from
+ choosing encoder ``latent_dim`` by held-out forecast HPO. Existing
+ VAMP-2 training weights and ResDMD helpers still ship.
+
+ Parameters
+ ----------
+ z_pairs : Tensor
+ Train encodings with shape ``(T, d)`` or ``(T, N, d)``, ``T >= 2``.
+ candidates : sequence of int
+ Positive ranks to score. Values above the numerical SVD rank are
+ rejected rather than scored.
+ criterion : {"vamp2", "resdmd_elbow", "stability_penalized"}
+ ``"vamp2"`` maximizes in-tree VAMP-2 and then takes the smallest
+ rank within relative gap :data:`VAMP2_PLATEAU_RELATIVE` of the
+ peak. ``"resdmd_elbow"`` minimizes the max finite-dictionary
+ ResDMD residual (ties → smallest rank). ``"stability_penalized"``
+ minimizes held-out one-step MSE plus
+ ``stability_penalty * max(0, ρ(K) - 1)`` (ties → smallest rank).
+ The MSE is ambient reconstruction of the held-out encodings, not
+ the error in the truncated coordinates.
+ validation_z : Tensor or None, optional
+ Optional hold-out encodings with the same trailing width (and
+ node axis when 3-D). Default is ``None``. For
+ ``stability_penalized`` without ``validation_z``, the last half
+ of ``z_pairs`` is the hold-out (requires ``T >= 4``).
+ ridge : float, optional
+ Tikhonov weight for the ridge map used by
+ ``stability_penalized``. Default ``1e-4``.
+ stability_penalty : float, optional
+ Weight on spectral-radius excess. Default ``1.0``.
+
+ Returns
+ -------
+ LatentRankReport
+ Selected rank, scores, and rejected candidates.
+
+ Raises
+ ------
+ ValueError
+ If layouts, ``criterion``, ``candidates``, or scalars are
+ invalid, or no candidate is at most the numerical rank.
+ """
+ if criterion not in _CRITERIA:
+ msg = f"criterion must be one of {sorted(_CRITERIA)}, got {criterion!r}"
+ raise ValueError(msg)
+ if not _finite_float(float(ridge)) or float(ridge) < 0.0:
+ msg = f"ridge must be a finite non-negative float, got {ridge}"
+ raise ValueError(msg)
+ if not _finite_float(float(stability_penalty)) or float(stability_penalty) < 0.0:
+ msg = (
+ "stability_penalty must be a finite non-negative float, "
+ f"got {stability_penalty}"
+ )
+ raise ValueError(msg)
+
+ train = _as_time_major(z_pairs, name="z_pairs")
+ ranks = _parse_candidates(candidates)
+ score_encodings = train
+ fit_encodings = train
+ if validation_z is not None:
+ held = _as_time_major(validation_z, name="validation_z")
+ if held.shape[1:] != train.shape[1:]:
+ msg = (
+ "validation_z trailing layout must match z_pairs, "
+ f"got {tuple(held.shape)} vs {tuple(train.shape)}"
+ )
+ raise ValueError(msg)
+ score_encodings = held
+ elif criterion == "stability_penalized":
+ n_times = int(train.shape[0])
+ if n_times < _MIN_HELDOUT_TIMES:
+ msg = (
+ "stability_penalized without validation_z requires "
+ f"T >= {_MIN_HELDOUT_TIMES}, got T={n_times}"
+ )
+ raise ValueError(msg)
+ split = n_times // 2
+ fit_encodings = train[:split]
+ score_encodings = train[split:]
+
+ basis = _right_singular_basis(fit_encodings.reshape(-1, fit_encodings.shape[-1]))
+ numerical_rank = int(basis.shape[0])
+ scored_ranks = tuple(rank for rank in ranks if rank <= numerical_rank)
+ rejected = tuple(str(rank) for rank in ranks if rank > numerical_rank)
+ if not scored_ranks:
+ msg = (
+ "no candidate rank is at most the numerical encoding rank "
+ f"{numerical_rank}; candidates={ranks}"
+ )
+ raise ValueError(msg)
+
+ scores: list[float] = []
+ train_left_full, train_right_full = _lag_pairs(_project(fit_encodings, basis))
+ score_left_full, score_right_full = _lag_pairs(_project(score_encodings, basis))
+ _, score_right_ambient = _lag_pairs(score_encodings)
+ n_samples = int(train_left_full.shape[0])
+ for rank in scored_ranks:
+ train_left = train_left_full[:, :rank]
+ train_right = train_right_full[:, :rank]
+ score_left = score_left_full[:, :rank]
+ score_right = score_right_full[:, :rank]
+ if criterion == "vamp2":
+ scores.append(_vamp2_score(score_left, score_right))
+ elif criterion == "resdmd_elbow":
+ scores.append(_resdmd_max_residual(score_left, score_right))
+ else:
+ scores.append(
+ _stability_score(
+ train_left,
+ train_right,
+ score_left,
+ score_right_ambient,
+ basis[:rank],
+ ridge=float(ridge),
+ penalty=float(stability_penalty),
+ )
+ )
+
+ score_tuple = tuple(scores)
+ selected = _pick_rank(scored_ranks, score_tuple, criterion=criterion)
+ return LatentRankReport(
+ selected_rank=selected,
+ criterion=criterion,
+ candidates=scored_ranks,
+ scores=score_tuple,
+ rejected_alternatives=rejected,
+ numerical_rank=numerical_rank,
+ n_samples=n_samples,
+ )
diff --git a/src/koopman_graph/identification/report.py b/src/koopman_graph/identification/report.py
new file mode 100644
index 0000000..91e8ac7
--- /dev/null
+++ b/src/koopman_graph/identification/report.py
@@ -0,0 +1,266 @@
+"""Frozen identification report and placeholder metric blocks.
+
+Field groups follow the public identification record: reconstruction,
+one-step, rollout, closure, invariance, spectral reliability, and
+stability, plus optional rank metadata. Values default to ``None`` /
+empty until solvers and diagnostics populate them. This is **not** a
+Haseli–Cortés invariance certificate, not a ResDMD residual bound, and
+not a stability certificate.
+
+This module must not import :mod:`koopman_graph.training` or
+:mod:`koopman_graph.model`.
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Sequence
+from dataclasses import dataclass, field
+
+__all__ = [
+ "IdentificationReport",
+ "InvarianceBlock",
+ "MetricBlock",
+ "SpectralReliabilityBlock",
+ "StabilityBlock",
+]
+
+
+def _optional_finite_float(name: str, value: float | None) -> None:
+ """Reject non-finite optional floats.
+
+ Parameters
+ ----------
+ name : str
+ Field name for the error message.
+ value : float or None
+ Optional scalar.
+
+ Raises
+ ------
+ ValueError
+ If ``value`` is not ``None`` and is NaN or infinite.
+ """
+ if value is None:
+ return
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ msg = f"{name} must be a finite float or None, got {type(value).__name__}"
+ raise ValueError(msg)
+ if not math.isfinite(float(value)):
+ msg = f"{name} must be a finite float or None, got {value!r}"
+ raise ValueError(msg)
+
+
+def _optional_nonneg_int(name: str, value: int | None) -> None:
+ """Reject negative optional integers.
+
+ Parameters
+ ----------
+ name : str
+ Field name for the error message.
+ value : int or None
+ Optional count.
+
+ Raises
+ ------
+ ValueError
+ If ``value`` is not ``None`` and is not a non-negative ``int``.
+ """
+ if value is None:
+ return
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
+ msg = f"{name} must be a non-negative int or None, got {value!r}"
+ raise ValueError(msg)
+
+
+@dataclass(frozen=True)
+class MetricBlock:
+ """Named error summary for one identification arm.
+
+ ``mse`` is a dimensionless mean squared error in the coordinates of
+ that arm (latent or decoded), not a physical unit. ``None`` means
+ the arm was not populated.
+
+ Attributes
+ ----------
+ mse : float or None
+ Mean squared error, or ``None`` when unset.
+ n_samples : int or None
+ Number of terms in the mean, or ``None`` when unset.
+ """
+
+ mse: float | None = None
+ n_samples: int | None = None
+
+ def __post_init__(self) -> None:
+ """Validate optional MSE and sample count.
+
+ Raises
+ ------
+ ValueError
+ If ``mse`` is non-finite or ``n_samples`` is negative.
+ """
+ _optional_finite_float("mse", self.mse)
+ _optional_nonneg_int("n_samples", self.n_samples)
+
+
+@dataclass(frozen=True)
+class InvarianceBlock:
+ """Placeholder for subspace-invariance proximity on a fit report.
+
+ ``leakage`` is the slot for finite-sample :math:`\\eta`. The
+ populated report type is
+ :class:`~koopman_graph.identification.SubspaceInvarianceReport`
+ from :func:`~koopman_graph.identification.subspace_invariance_report`
+ (evaluate / model helper). Closed-form ``fit`` still leaves this
+ block unset. It is **not** a Haseli–Cortés certificate.
+
+ Attributes
+ ----------
+ leakage : float or None
+ Non-negative proximity scalar, or ``None`` when unset.
+ """
+
+ leakage: float | None = None
+
+ def __post_init__(self) -> None:
+ """Validate optional leakage.
+
+ Raises
+ ------
+ ValueError
+ If ``leakage`` is non-finite or negative.
+ """
+ _optional_finite_float("leakage", self.leakage)
+ if self.leakage is not None and self.leakage < 0.0:
+ msg = f"leakage must be non-negative, got {self.leakage!r}"
+ raise ValueError(msg)
+
+
+@dataclass(frozen=True)
+class SpectralReliabilityBlock:
+ """Finite-dictionary residual / pollution flags.
+
+ Filled when identification ``gate_resdmd=True``. Not a certified
+ infinite-dimensional residual bound and not
+ :func:`~koopman_graph.analysis.spectral_residuals`.
+
+ Attributes
+ ----------
+ residual_max : float or None
+ Maximum dictionary residual, or ``None`` when unset.
+ polluted : bool or None
+ ``True`` when a later gate marks the spectrum as polluted.
+ """
+
+ residual_max: float | None = None
+ polluted: bool | None = None
+
+ def __post_init__(self) -> None:
+ """Validate residual and pollution flag.
+
+ Raises
+ ------
+ ValueError
+ If ``residual_max`` is non-finite or ``polluted`` is not a
+ ``bool`` or ``None``.
+ """
+ _optional_finite_float("residual_max", self.residual_max)
+ if self.polluted is not None and type(self.polluted) is not bool:
+ msg = f"polluted must be a bool or None, got {type(self.polluted).__name__}"
+ raise ValueError(msg)
+
+
+@dataclass(frozen=True)
+class StabilityBlock:
+ """Placeholder for a spectral-radius snapshot of identified ``K``.
+
+ Not a joint Gershgorin certificate and not
+ :meth:`~koopman_graph.operators.KoopmanOperator.stability_certificate`.
+
+ Attributes
+ ----------
+ spectral_radius : float or None
+ :math:`\\rho(K)` when populated, dimensionless for discrete maps.
+ """
+
+ spectral_radius: float | None = None
+
+ def __post_init__(self) -> None:
+ """Validate optional spectral radius.
+
+ Raises
+ ------
+ ValueError
+ If ``spectral_radius`` is non-finite or negative.
+ """
+ _optional_finite_float("spectral_radius", self.spectral_radius)
+ if self.spectral_radius is not None and self.spectral_radius < 0.0:
+ msg = f"spectral_radius must be non-negative, got {self.spectral_radius!r}"
+ raise ValueError(msg)
+
+
+@dataclass(frozen=True)
+class IdentificationReport:
+ """Structured identification record with named metric groups.
+
+ Empty reports are valid. Closed-form ``fit(..., identification=...)``
+ fills ``one_step``, short latent ``rollout``, and
+ ``stability.spectral_radius``. ``spectral`` fills only when
+ ``gate_resdmd=True``. Invariance, reconstruction, and closure stay
+ unset. Evaluate-time :math:`\\eta` lives on
+ :class:`~koopman_graph.metrics.EvaluationResult.invariance`, not here.
+
+ Attributes
+ ----------
+ reconstruction : MetricBlock
+ Decode / reconstruction error.
+ one_step : MetricBlock
+ One-step latent or decoded error.
+ rollout : MetricBlock
+ Multi-step rollout error.
+ closure : MetricBlock
+ Topology / graph-state closure error when applicable.
+ invariance : InvarianceBlock
+ Subspace leakage slot.
+ spectral : SpectralReliabilityBlock
+ Finite-dictionary residual / pollution slot.
+ stability : StabilityBlock
+ Spectral-radius slot.
+ selected_rank : int or None
+ Latent rank chosen by a later selector, or ``None``.
+ rejected_alternatives : tuple of str
+ Names of candidates a later gate discarded.
+ """
+
+ reconstruction: MetricBlock = field(default_factory=MetricBlock)
+ one_step: MetricBlock = field(default_factory=MetricBlock)
+ rollout: MetricBlock = field(default_factory=MetricBlock)
+ closure: MetricBlock = field(default_factory=MetricBlock)
+ invariance: InvarianceBlock = field(default_factory=InvarianceBlock)
+ spectral: SpectralReliabilityBlock = field(default_factory=SpectralReliabilityBlock)
+ stability: StabilityBlock = field(default_factory=StabilityBlock)
+ selected_rank: int | None = None
+ rejected_alternatives: tuple[str, ...] = ()
+
+ def __post_init__(self) -> None:
+ """Validate rank metadata and rejected-name sequence.
+
+ Raises
+ ------
+ ValueError
+ If ``selected_rank`` is negative or
+ ``rejected_alternatives`` is not a string sequence.
+ """
+ _optional_nonneg_int("selected_rank", self.selected_rank)
+ if not isinstance(self.rejected_alternatives, Sequence) or isinstance(
+ self.rejected_alternatives, (str, bytes)
+ ):
+ msg = "rejected_alternatives must be a sequence of strings"
+ raise ValueError(msg)
+ names = tuple(self.rejected_alternatives)
+ if any(not isinstance(name, str) or name == "" for name in names):
+ msg = "rejected_alternatives entries must be non-empty strings"
+ raise ValueError(msg)
+ if names != self.rejected_alternatives:
+ object.__setattr__(self, "rejected_alternatives", names)
diff --git a/src/koopman_graph/identification/solvers.py b/src/koopman_graph/identification/solvers.py
new file mode 100644
index 0000000..4e92612
--- /dev/null
+++ b/src/koopman_graph/identification/solvers.py
@@ -0,0 +1,354 @@
+"""Closed-form Koopman identification on frozen latent encodings.
+
+Ridge (Tikhonov) and total-least-squares maps follow the package row
+convention ``z_next = z @ K.T``. Constrained least squares is the same
+ridge solve followed by a spectral-radius projection onto the unit disk.
+This is **not** ResKoopNet residual training, not Askham–Kutz variable
+projection, and not graph-factor identification.
+
+This module must not import :mod:`koopman_graph.training` or
+:mod:`koopman_graph.model`. It does not import :mod:`koopman_graph.baselines`;
+tests may cross-check against ``fit_row_operator`` / ``fit_tls_row_operator``.
+
+References
+----------
+Williams, M. O., Kevrekidis, I. G. & Rowley, C. W. (2015). A data-driven
+approximation of the Koopman operator: extending dynamic mode
+decomposition. *Journal of Nonlinear Science*, 25(6), 1307–1346.
+https://doi.org/10.1007/s00332-015-9258-5
+
+Dawson, S. T. M., Hemati, M. S., Williams, M. O. & Rowley, C. W. (2016).
+Characterizing and correcting for the effect of sensor noise in the
+dynamic mode decomposition. *Experiments in Fluids*, 57(3), 42.
+https://doi.org/10.1007/s00348-016-2127-7
+
+Hemati, M. S., Rowley, C. W., Deem, E. A. & Cattafesta, L. N. (2017).
+De-biasing the dynamic mode decomposition for applied Koopman spectral
+analysis of noisy datasets. *Theoretical and Computational Fluid
+Dynamics*, 31(4), 349–368. https://doi.org/10.1007/s00162-017-0432-2
+
+Xu, Y., Shao, K., Logothetis, N. K. & Shen, Z. (2025). ResKoopNet:
+learning Koopman representations for complex dynamics with spectral
+residuals. *Proceedings of the 42nd International Conference on Machine
+Learning*, PMLR 267:69647–69674.
+https://proceedings.mlr.press/v267/xu25y.html
+(``Xu2025ResKoopNet``)
+"""
+
+from __future__ import annotations
+
+import math
+
+import torch
+from torch import Tensor, nn
+
+from koopman_graph.identification.config import IdentificationConfig
+from koopman_graph.identification.protocol import LatentPairs, OperatorSnapshot
+from koopman_graph.operators.discrete import KoopmanOperator
+
+__all__ = [
+ "ClosedFormBackend",
+ "apply_operator_snapshot",
+ "identify_operator",
+]
+
+_UNIT_DISK_RADIUS = 1.0
+
+
+def _flatten_latent_pairs(encodings: LatentPairs) -> tuple[Tensor, Tensor]:
+ """Reshape consecutive encodings to ``(n_samples, d)``.
+
+ Parameters
+ ----------
+ encodings : LatentPairs
+ ``z_t`` / ``z_next`` with trailing width :math:`d`.
+
+ Returns
+ -------
+ tuple of Tensor
+ Flattened source and target matrices sharing dtype and device.
+
+ Raises
+ ------
+ ValueError
+ If the encodings are not at least 2-D.
+ """
+ z_t = encodings.z_t
+ z_next = encodings.z_next
+ if z_t.ndim < 2:
+ msg = (
+ "latent encodings must be at least 2-D with trailing latent "
+ f"width, got shape {tuple(z_t.shape)}"
+ )
+ raise ValueError(msg)
+ width = z_t.shape[-1]
+ return z_t.reshape(-1, width), z_next.reshape(-1, width)
+
+
+def _fit_ridge_row_operator(left: Tensor, right: Tensor, ridge: float) -> Tensor:
+ """Fit ``right ~= left @ K.T`` with optional Tikhonov weight.
+
+ When ``ridge > 0``, solves the augmented system
+ ``[left; sqrt(ridge) I] A = [right; 0]`` and returns ``K = A.T``.
+ ``ridge`` is a dimensionless Gram weight, not a physical regularization.
+
+ Parameters
+ ----------
+ left : Tensor
+ Source encodings with shape ``(n_samples, d)``.
+ right : Tensor
+ Target encodings with shape ``(n_samples, d)``.
+ ridge : float
+ Non-negative finite Tikhonov weight.
+
+ Returns
+ -------
+ Tensor
+ Row-convention operator ``K`` with shape ``(d, d)``.
+
+ Raises
+ ------
+ ValueError
+ If ``ridge == 0`` and there are fewer samples than the latent width.
+ """
+ n_samples, dim = left.shape
+ if right.shape != left.shape:
+ msg = (
+ "ridge left/right must share shape, "
+ f"got {tuple(left.shape)} vs {tuple(right.shape)}"
+ )
+ raise ValueError(msg)
+ if ridge == 0.0 and n_samples < dim:
+ msg = (
+ "ridge identification with ridge=0 needs at least latent_dim "
+ f"samples; got {n_samples} samples and dim {dim}"
+ )
+ raise ValueError(msg)
+ if ridge == 0.0:
+ return torch.linalg.lstsq(left, right).solution.T
+
+ sqrt_ridge = math.sqrt(ridge)
+ identity = torch.eye(dim, dtype=left.dtype, device=left.device)
+ stacked_left = torch.cat([left, sqrt_ridge * identity], dim=0)
+ stacked_right = torch.cat(
+ [right, torch.zeros(dim, right.shape[1], dtype=left.dtype, device=left.device)],
+ dim=0,
+ )
+ return torch.linalg.lstsq(stacked_left, stacked_right).solution.T
+
+
+def _fit_tls_row_operator(left: Tensor, right: Tensor) -> Tensor:
+ """Fit a total-least-squares map in the package row convention.
+
+ Stacks column-oriented snapshot matrices, truncates the joint SVD to
+ the state dimension, and forms ``K = U_y @ pinv(U_x)``.
+
+ Parameters
+ ----------
+ left : Tensor
+ Source encodings with shape ``(n_samples, d)``.
+ right : Tensor
+ Target encodings with shape ``(n_samples, d)``.
+
+ Returns
+ -------
+ Tensor
+ Row-convention operator ``K`` with shape ``(d, d)``.
+
+ Raises
+ ------
+ ValueError
+ If shapes disagree, rank is degenerate, or ``U_x`` is rank-deficient.
+ """
+ if left.shape != right.shape:
+ msg = (
+ "TLS left/right must share shape, "
+ f"got {tuple(left.shape)} vs {tuple(right.shape)}"
+ )
+ raise ValueError(msg)
+ if left.ndim != 2:
+ msg = f"TLS left/right must be 2-D, got shape {tuple(left.shape)}"
+ raise ValueError(msg)
+
+ state_dim = left.shape[1]
+ stacked = torch.cat([left.T, right.T], dim=0)
+ truncated_rank = min(state_dim, min(stacked.shape))
+ if truncated_rank < 1:
+ msg = f"TLS truncation rank must be >= 1, got {truncated_rank}"
+ raise ValueError(msg)
+
+ u, _, _ = torch.linalg.svd(stacked, full_matrices=False)
+ u_r = u[:, :truncated_rank]
+ u_x = u_r[:state_dim, :]
+ u_y = u_r[state_dim:, :]
+ try:
+ return u_y @ torch.linalg.pinv(u_x)
+ except RuntimeError as exc:
+ msg = "TLS identification failed: truncated U_x block is rank-deficient"
+ raise ValueError(msg) from exc
+
+
+def _scale_spectral_radius(matrix: Tensor, max_radius: float) -> Tensor:
+ """Scale ``matrix`` so that :math:`\\rho(K) \\le` ``max_radius``.
+
+ Leaves the matrix unchanged when the spectral radius is already at
+ or below the bound. This is a projection, not a Lyapunov or Schur
+ certificate.
+
+ Parameters
+ ----------
+ matrix : Tensor
+ Square operator.
+ max_radius : float
+ Positive spectral-radius cap.
+
+ Returns
+ -------
+ Tensor
+ Scaled operator, same dtype and device as ``matrix``.
+
+ Raises
+ ------
+ ValueError
+ If the spectral radius is non-finite.
+ """
+ radius = torch.linalg.eigvals(matrix).abs().max()
+ rho = float(radius.real)
+ if not math.isfinite(rho):
+ msg = f"constrained_ls spectral radius is non-finite, got {rho!r}"
+ raise ValueError(msg)
+ if rho <= max_radius:
+ return matrix
+ return matrix * (max_radius / rho)
+
+
+def identify_operator(
+ encodings: LatentPairs,
+ config: IdentificationConfig,
+) -> OperatorSnapshot:
+ """Fit a dense per-node operator from consecutive encodings.
+
+ ``solver="alternating"`` uses the ridge formula (the fit loop supplies
+ the encoder/operator alternation). ``solver="varpro"`` is not
+ implemented here; use :class:`~koopman_graph.baselines.OptDMDBaseline`
+ for a teaching variable-projection baseline.
+
+ Parameters
+ ----------
+ encodings : LatentPairs
+ Frozen ``z_t`` / ``z_next`` pairs. Trailing width is the latent
+ dimension :math:`d`; leading axes are flattened.
+ config : IdentificationConfig
+ Solver name and Tikhonov weight.
+
+ Returns
+ -------
+ OperatorSnapshot
+ Dense ``matrix`` with shape ``(d, d)``. Factor slots stay ``None``.
+
+ Raises
+ ------
+ NotImplementedError
+ If ``config.solver`` is ``"varpro"``.
+ ValueError
+ If the encodings are empty or a solver-specific assumption fails.
+ """
+ if config.solver == "varpro":
+ msg = (
+ "solver='varpro' is not implemented in identification; "
+ "use koopman_graph.baselines.OptDMDBaseline for a teaching "
+ "variable-projection DMD, or choose ridge / tls / "
+ "constrained_ls / alternating"
+ )
+ raise NotImplementedError(msg)
+
+ left, right = _flatten_latent_pairs(encodings)
+ if left.shape[0] < 1:
+ msg = "identification requires at least one latent pair"
+ raise ValueError(msg)
+
+ method = "ridge" if config.solver == "alternating" else config.solver
+ if method == "ridge":
+ matrix = _fit_ridge_row_operator(left, right, config.ridge)
+ elif method == "tls":
+ matrix = _fit_tls_row_operator(left, right)
+ elif method == "constrained_ls":
+ matrix = _fit_ridge_row_operator(left, right, config.ridge)
+ matrix = _scale_spectral_radius(matrix, _UNIT_DISK_RADIUS)
+ else:
+ msg = f"unsupported identification solver {config.solver!r}"
+ raise ValueError(msg)
+ return OperatorSnapshot(matrix=matrix)
+
+
+def apply_operator_snapshot(koopman: nn.Module, snapshot: OperatorSnapshot) -> None:
+ """Write an identified dense matrix onto a per-node discrete operator.
+
+ Parameters
+ ----------
+ koopman : nn.Module
+ Must be a densely parameterized uncontrolled
+ :class:`~koopman_graph.operators.KoopmanOperator`.
+ snapshot : OperatorSnapshot
+ Snapshot whose ``matrix`` slot is set.
+
+ Raises
+ ------
+ ValueError
+ If the operator is not a dense uncontrolled ``KoopmanOperator``,
+ or ``snapshot.matrix`` is missing or the wrong shape.
+ """
+ if not isinstance(koopman, KoopmanOperator):
+ msg = (
+ "identification currently supports discrete per-node "
+ "KoopmanOperator only, got "
+ f"{type(koopman).__name__}"
+ )
+ raise ValueError(msg)
+ if koopman.parameterization != "dense":
+ msg = (
+ "identification requires parameterization='dense', "
+ f"got {koopman.parameterization!r}"
+ )
+ raise ValueError(msg)
+ if koopman.control_dim != 0:
+ msg = (
+ "identification does not support controlled operators "
+ f"(control_dim={koopman.control_dim})"
+ )
+ raise ValueError(msg)
+ matrix = snapshot.matrix
+ if matrix is None:
+ msg = "OperatorSnapshot.matrix is required for per-node identification"
+ raise ValueError(msg)
+ koopman.set_dense_matrix(matrix)
+
+
+class ClosedFormBackend:
+ """Default :class:`IdentificationBackend` using :func:`identify_operator`.
+
+ Notes
+ -----
+ This class is a thin dispatch; it does not store fitted state.
+ """
+
+ def fit_operator(
+ self,
+ encodings: LatentPairs,
+ config: IdentificationConfig,
+ ) -> OperatorSnapshot:
+ """Fit a dense operator snapshot from frozen encodings.
+
+ Parameters
+ ----------
+ encodings : LatentPairs
+ Consecutive latent pairs.
+ config : IdentificationConfig
+ Solver name and ridge weight.
+
+ Returns
+ -------
+ OperatorSnapshot
+ Dense identified ``K``.
+ """
+ return identify_operator(encodings, config)
diff --git a/src/koopman_graph/identification/sparse_factors.py b/src/koopman_graph/identification/sparse_factors.py
new file mode 100644
index 0000000..272eee3
--- /dev/null
+++ b/src/koopman_graph/identification/sparse_factors.py
@@ -0,0 +1,631 @@
+"""Closed-form sparse identification of graph Koopman factors.
+
+Fits shared :math:`K_{\\mathrm{self}}` / :math:`K_{\\mathrm{nbr}}` on frozen
+node latents for the one-tap map
+
+.. math::
+
+ Z_{t+1}
+ \\approx
+ Z_t K_{\\mathrm{self}}^{\\top}
+ +
+ (\\hat A Z_t) K_{\\mathrm{nbr}}^{\\top}.
+
+STLSQ (Brunton et al., 2016) or a proximal group-threshold iteration
+selects support; an unpenalized least-squares refit follows. This is
+**not** :func:`~koopman_graph.analysis.identify_sparse_dynamics`
+(library :math:`\\Theta(z)`, not factor :math:`K`), **not**
+:class:`~koopman_graph.losses.KoopmanSparsityLoss` (training penalty),
+and **not** Pan et al. (2021) multi-task EDMD dictionary pruning.
+
+This module must not import :mod:`koopman_graph.training` or
+:mod:`koopman_graph.model`. Dual random-walk and polynomial
+:math:`P>1` maps are out of scope.
+
+References
+----------
+Brunton, S. L., Proctor, J. L. & Kutz, J. N. (2016). Discovering
+governing equations from data by sparse identification of nonlinear
+dynamical systems. *Proceedings of the National Academy of Sciences*,
+113(15), 3932–3937. https://doi.org/10.1073/pnas.1517384113
+(``Brunton2016SINDy``)
+
+Pan, S., Arnold-Medabalimi, N. & Duraisamy, K. (2021). Sparsity-promoting
+algorithms for the discovery of informative Koopman-invariant subspaces.
+*Journal of Fluid Mechanics*, 917, A18. https://doi.org/10.1017/jfm.2021.271
+(``Pan2021SparseSubspace``)
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Literal
+
+import torch
+from torch import Tensor
+
+from koopman_graph.graph_utils.topology import (
+ dense_random_walk_normalized_adjacency,
+ dense_symmetric_normalized_adjacency,
+)
+
+__all__ = [
+ "DEFAULT_GROUP_LASSO_MAX_ITER",
+ "DEFAULT_STLSQ_MAX_ITER",
+ "SparseFactorGroup",
+ "SparseFactorMethod",
+ "SparseFactorReport",
+ "identify_sparse_graph_factors",
+]
+
+SparseFactorGroup = Literal["none", "self_nbr", "orbit"]
+SparseFactorMethod = Literal["stlsq", "group_lasso"]
+SparseFactorAdjacency = Literal["symmetric", "random_walk"]
+
+DEFAULT_STLSQ_MAX_ITER = 10
+DEFAULT_GROUP_LASSO_MAX_ITER = 200
+_ISTA_REL_TOL = 1e-8
+_GROUPS = frozenset({"none", "self_nbr", "orbit"})
+_METHODS = frozenset({"stlsq", "group_lasso"})
+_ADJACENCIES = frozenset({"symmetric", "random_walk"})
+
+
+@dataclass(frozen=True)
+class SparseFactorReport:
+ """Sparse one-tap graph-factor fit on frozen encodings.
+
+ ``K_self`` / ``K_nbr`` follow the package row convention
+ ``Z_next = Z @ K_self.T + (Â Z) @ K_nbr.T``. Inactive entries are
+ exactly zero after the unpenalized refit.
+
+ Attributes
+ ----------
+ K_self, K_nbr : Tensor
+ Identified factors with shape ``(d, d)``.
+ active_mask_self, active_mask_nbr : Tensor
+ Boolean masks with the same shape as the factors.
+ residual : float
+ Mean squared residual of the refit map on the stacked pairs.
+ nnz : int
+ Number of nonzero entries in both factors.
+ n_samples : int
+ Number of node×transition rows in the stacked design.
+ method : {"stlsq", "group_lasso"}
+ Support selector.
+ group : {"none", "self_nbr", "orbit"}
+ Grouping used for thresholding.
+ threshold : float
+ Absolute (STLSQ) or group-norm (group-lasso) cutoff.
+ """
+
+ K_self: Tensor
+ K_nbr: Tensor
+ active_mask_self: Tensor
+ active_mask_nbr: Tensor
+ residual: float
+ nnz: int
+ n_samples: int
+ method: SparseFactorMethod
+ group: SparseFactorGroup
+ threshold: float
+
+ def __post_init__(self) -> None:
+ """Validate factor shapes, residual, and counts.
+
+ Raises
+ ------
+ ValueError
+ If shapes disagree, ``residual`` is non-finite, or counts
+ are invalid.
+ """
+ if self.K_self.shape != self.K_nbr.shape:
+ msg = (
+ "K_self and K_nbr must share shape, "
+ f"got {tuple(self.K_self.shape)} vs {tuple(self.K_nbr.shape)}"
+ )
+ raise ValueError(msg)
+ if self.K_self.ndim != 2 or self.K_self.shape[0] != self.K_self.shape[1]:
+ msg = f"K_self must be square 2-D, got shape {tuple(self.K_self.shape)}"
+ raise ValueError(msg)
+ if self.active_mask_self.shape != self.K_self.shape:
+ msg = "active_mask_self must match K_self"
+ raise ValueError(msg)
+ if self.active_mask_nbr.shape != self.K_nbr.shape:
+ msg = "active_mask_nbr must match K_nbr"
+ raise ValueError(msg)
+ if not _finite_float(self.residual) or self.residual < 0.0:
+ msg = f"residual must be a finite non-negative float, got {self.residual}"
+ raise ValueError(msg)
+ if self.nnz < 0 or self.n_samples < 1:
+ msg = (
+ "nnz must be non-negative and n_samples >= 1, "
+ f"got nnz={self.nnz}, n_samples={self.n_samples}"
+ )
+ raise ValueError(msg)
+ if self.threshold < 0.0:
+ msg = f"threshold must be non-negative, got {self.threshold}"
+ raise ValueError(msg)
+
+
+def _finite_float(value: float) -> bool:
+ """Return whether ``value`` is a finite Python float.
+
+ Parameters
+ ----------
+ value : float
+ Scalar to test.
+
+ Returns
+ -------
+ bool
+ ``True`` when ``value`` is finite.
+ """
+ return value == value and value not in (float("inf"), float("-inf"))
+
+
+def _assemble_adjacency(
+ edge_index: Tensor,
+ *,
+ num_nodes: int,
+ edge_weight: Tensor | None,
+ dtype: torch.dtype,
+ adjacency: SparseFactorAdjacency,
+) -> Tensor:
+ """Build dense :math:`\\hat A` matching the one-tap graph operator.
+
+ Parameters
+ ----------
+ edge_index : Tensor
+ COO edges with shape ``(2, E)``.
+ num_nodes : int
+ Node count ``N``.
+ edge_weight : Tensor or None
+ Optional non-negative weights with shape ``(E,)``.
+ dtype : torch.dtype
+ Floating dtype of the encodings.
+ adjacency : {"symmetric", "random_walk"}
+ Normalization.
+
+ Returns
+ -------
+ Tensor
+ Dense ``(N, N)`` shift.
+
+ Raises
+ ------
+ ValueError
+ If ``edge_index`` is not ``(2, E)`` or indices exceed ``N``.
+ """
+ if edge_index.ndim != 2 or edge_index.shape[0] != 2:
+ msg = (
+ f"edge_index must have shape (2, num_edges), got {tuple(edge_index.shape)}"
+ )
+ raise ValueError(msg)
+ if int(edge_index.numel()) > 0:
+ max_index = int(edge_index.max().item())
+ min_index = int(edge_index.min().item())
+ if min_index < 0 or max_index >= num_nodes:
+ msg = (
+ f"edge_index entries must lie in [0, {num_nodes}), "
+ f"got min={min_index}, max={max_index}"
+ )
+ raise ValueError(msg)
+ if adjacency == "symmetric":
+ return dense_symmetric_normalized_adjacency(
+ edge_index,
+ num_nodes,
+ edge_weight=edge_weight,
+ dtype=dtype,
+ )
+ return dense_random_walk_normalized_adjacency(
+ edge_index,
+ num_nodes,
+ edge_weight=edge_weight,
+ dtype=dtype,
+ direction="forward",
+ )
+
+
+def _stacked_design(
+ z_pairs: Tensor,
+ adjacency: Tensor,
+) -> tuple[Tensor, Tensor]:
+ """Stack consecutive pairs into ``X = [Z | ÂZ]`` and ``Y = Z_next``.
+
+ Parameters
+ ----------
+ z_pairs : Tensor
+ Encodings with shape ``(T, N, d)``.
+ adjacency : Tensor
+ Dense ``Â`` with shape ``(N, N)``.
+
+ Returns
+ -------
+ tuple of Tensor
+ ``X`` with shape ``((T-1) N, 2d)`` and ``Y`` with shape
+ ``((T-1) N, d)``.
+ """
+ current = z_pairs[:-1]
+ target = z_pairs[1:]
+ shift = adjacency.to(device=current.device, dtype=current.dtype)
+ neighbor = shift @ current
+ left = current.reshape(-1, current.shape[-1])
+ right = neighbor.reshape(-1, neighbor.shape[-1])
+ design = torch.cat([left, right], dim=1)
+ return design, target.reshape(-1, target.shape[-1])
+
+
+def _group_masks(
+ group: SparseFactorGroup,
+ latent_dim: int,
+ *,
+ device: torch.device,
+) -> tuple[Tensor, ...]:
+ """Boolean masks over the stacked solution ``(2d, d)``.
+
+ Parameters
+ ----------
+ group : {"none", "self_nbr", "orbit"}
+ Grouping.
+ latent_dim : int
+ Factor width ``d``.
+ device : torch.device
+ Device of the encodings.
+
+ Returns
+ -------
+ tuple of Tensor
+ Masks with shape ``(2d, d)``.
+ """
+ rows = 2 * latent_dim
+ cols = latent_dim
+ if group == "none":
+ masks = []
+ for row in range(rows):
+ for col in range(cols):
+ mask = torch.zeros(rows, cols, dtype=torch.bool, device=device)
+ mask[row, col] = True
+ masks.append(mask)
+ return tuple(masks)
+ if group == "self_nbr":
+ self_mask = torch.zeros(rows, cols, dtype=torch.bool, device=device)
+ self_mask[:latent_dim] = True
+ neighbor_mask = torch.zeros(rows, cols, dtype=torch.bool, device=device)
+ neighbor_mask[latent_dim:] = True
+ return (self_mask, neighbor_mask)
+ masks = []
+ for col in range(cols):
+ mask = torch.zeros(rows, cols, dtype=torch.bool, device=device)
+ mask[:, col] = True
+ masks.append(mask)
+ return tuple(masks)
+
+
+def _threshold_groups(
+ solution: Tensor,
+ masks: tuple[Tensor, ...],
+ threshold: float,
+) -> Tensor:
+ """Zero groups whose Euclidean norm is below ``threshold``.
+
+ Parameters
+ ----------
+ solution : Tensor
+ Stacked ``[K_self.T; K_nbr.T]`` with shape ``(2d, d)``.
+ masks : tuple of Tensor
+ Group membership masks.
+ threshold : float
+ Group-norm cutoff (absolute value when a group is one entry).
+
+ Returns
+ -------
+ Tensor
+ Thresholded copy of ``solution``.
+ """
+ out = solution.clone()
+ for mask in masks:
+ values = out[mask]
+ if float(values.norm().item()) < threshold:
+ out[mask] = 0
+ return out
+
+
+def _refit_on_support(
+ design: Tensor,
+ target: Tensor,
+ support: Tensor,
+) -> Tensor:
+ """Unpenalized least squares on the remaining entries of ``S``.
+
+ Parameters
+ ----------
+ design : Tensor
+ Stacked ``X`` with shape ``(M, 2d)``.
+ target : Tensor
+ Stacked ``Y`` with shape ``(M, d)``.
+ support : Tensor
+ Boolean mask with shape ``(2d, d)``.
+
+ Returns
+ -------
+ Tensor
+ Refit ``S`` with inactive entries exactly zero.
+ """
+ latent_dim = target.shape[1]
+ solution = torch.zeros(
+ design.shape[1],
+ latent_dim,
+ dtype=design.dtype,
+ device=design.device,
+ )
+ shared_rows = torch.equal(support, support[:, :1].expand_as(support))
+ if shared_rows:
+ active = support[:, 0]
+ if not bool(active.any().item()):
+ return solution
+ fitted = torch.linalg.lstsq(design[:, active], target).solution
+ solution[active] = fitted
+ return solution
+ for col in range(latent_dim):
+ active = support[:, col]
+ if not bool(active.any().item()):
+ continue
+ fitted = torch.linalg.lstsq(design[:, active], target[:, col]).solution
+ solution[active, col] = fitted
+ return solution
+
+
+def _stlsq_factors(
+ design: Tensor,
+ target: Tensor,
+ masks: tuple[Tensor, ...],
+ *,
+ threshold: float,
+ max_iter: int,
+) -> Tensor:
+ """Sequentially thresholded least squares on stacked factor columns.
+
+ Parameters
+ ----------
+ design, target : Tensor
+ Stacked regression pair.
+ masks : tuple of Tensor
+ Group masks.
+ threshold : float
+ Group-norm cutoff.
+ max_iter : int
+ Maximum STLSQ iterations.
+
+ Returns
+ -------
+ Tensor
+ Sparse stacked solution.
+ """
+ solution = torch.linalg.lstsq(design, target).solution
+ for _ in range(max_iter):
+ previous = solution.clone()
+ solution = _threshold_groups(solution, masks, threshold)
+ support = solution.abs() > 0
+ solution = _refit_on_support(design, target, support)
+ solution = _threshold_groups(solution, masks, threshold)
+ if torch.allclose(solution, previous):
+ break
+ return solution
+
+
+def _group_soft_threshold(
+ solution: Tensor,
+ masks: tuple[Tensor, ...],
+ tau: float,
+) -> Tensor:
+ """Proximal group soft-threshold with scale ``tau``.
+
+ Parameters
+ ----------
+ solution : Tensor
+ Current stacked map.
+ masks : tuple of Tensor
+ Group masks.
+ tau : float
+ ``step * lambda`` shrinkage.
+
+ Returns
+ -------
+ Tensor
+ Shrinkage of each group.
+ """
+ out = solution.clone()
+ for mask in masks:
+ values = out[mask]
+ norm = float(values.norm().item())
+ if norm == 0.0:
+ continue
+ scale = max(0.0, 1.0 - tau / norm)
+ out[mask] = values * scale
+ return out
+
+
+def _ista_group_lasso(
+ design: Tensor,
+ target: Tensor,
+ masks: tuple[Tensor, ...],
+ *,
+ threshold: float,
+ max_iter: int,
+) -> Tensor:
+ """ISTA group-lasso followed by a hard group cutoff and unpenalized refit.
+
+ Parameters
+ ----------
+ design, target : Tensor
+ Stacked regression pair.
+ masks : tuple of Tensor
+ Group masks.
+ threshold : float
+ Penalty ``λ`` and post-ISTA group-norm cutoff.
+ max_iter : int
+ Maximum ISTA steps.
+
+ Returns
+ -------
+ Tensor
+ Sparse stacked solution after refit.
+ """
+ gram = design.T @ design
+ lipschitz = torch.linalg.eigvalsh(gram).amax()
+ step = 1.0 / float(lipschitz.clamp(min=1e-12).item())
+ solution = torch.linalg.lstsq(design, target).solution
+ for _ in range(max_iter):
+ previous = solution.clone()
+ residual = design @ solution - target
+ solution = solution - step * (design.T @ residual)
+ solution = _group_soft_threshold(solution, masks, step * threshold)
+ delta = (solution - previous).norm()
+ scale = previous.norm().clamp(min=1e-12)
+ if float((delta / scale).item()) < _ISTA_REL_TOL:
+ break
+ solution = _threshold_groups(solution, masks, threshold)
+ support = solution.abs() > 0
+ solution = _refit_on_support(design, target, support)
+ return _threshold_groups(solution, masks, threshold)
+
+
+def identify_sparse_graph_factors(
+ z_pairs: Tensor,
+ edge_index: Tensor,
+ *,
+ group: SparseFactorGroup = "self_nbr",
+ method: SparseFactorMethod = "stlsq",
+ threshold: float,
+ edge_weight: Tensor | None = None,
+ adjacency: SparseFactorAdjacency = "symmetric",
+ max_iter: int | None = None,
+) -> SparseFactorReport:
+ """Identify sparse :math:`K_{\\mathrm{self}}` / :math:`K_{\\mathrm{nbr}}`.
+
+ Distinct from :func:`~koopman_graph.analysis.identify_sparse_dynamics`
+ and :class:`~koopman_graph.losses.KoopmanSparsityLoss`. Existing L1
+ training and latent SINDy paths still ship. This is not Pan et al.
+ (2021) multi-task EDMD dictionary pruning.
+
+ Parameters
+ ----------
+ z_pairs : Tensor
+ Consecutive frozen encodings with shape ``(T, N, d)``, ``T >= 2``.
+ edge_index : Tensor
+ COO topology with shape ``(2, E)``.
+ group : {"none", "self_nbr", "orbit"}, optional
+ ``"none"`` is elementwise; ``"self_nbr"`` treats each factor as
+ one group; ``"orbit"`` groups latent **rows** (row ``i`` of
+ both factors). Default is ``"self_nbr"``.
+ method : {"stlsq", "group_lasso"}, optional
+ ``"stlsq"`` is sequentially thresholded least squares.
+ ``"group_lasso"`` is ISTA group soft-threshold (teaching-thin),
+ then the same unpenalized refit. Default is ``"stlsq"``.
+ threshold : float
+ Non-negative cutoff. STLSQ uses group Euclidean norms (absolute
+ value for ``group="none"``). Group-lasso uses the same value as
+ ``λ`` and as the post-ISTA cutoff.
+ edge_weight : Tensor or None, optional
+ Optional edge weights with shape ``(E,)``. Default is ``None``.
+ adjacency : {"symmetric", "random_walk"}, optional
+ Shift used to form ``Â Z``. Dual random-walk is refused.
+ Default is ``"symmetric"``.
+ max_iter : int or None, optional
+ Iteration cap. Default is ``10`` for STLSQ and ``200`` for
+ group-lasso.
+
+ Returns
+ -------
+ SparseFactorReport
+ Factors, masks, residual, and metadata.
+
+ Raises
+ ------
+ ValueError
+ If shapes, ``group``, ``method``, ``adjacency``, or
+ ``threshold`` are invalid, or ``T < 2``.
+ """
+ if group not in _GROUPS:
+ msg = f"group must be one of {sorted(_GROUPS)}, got {group!r}"
+ raise ValueError(msg)
+ if method not in _METHODS:
+ msg = f"method must be one of {sorted(_METHODS)}, got {method!r}"
+ raise ValueError(msg)
+ if adjacency not in _ADJACENCIES:
+ msg = (
+ "adjacency must be 'symmetric' or 'random_walk' "
+ f"(dual random-walk is out of scope), got {adjacency!r}"
+ )
+ raise ValueError(msg)
+ if threshold < 0.0 or not _finite_float(float(threshold)):
+ msg = f"threshold must be a finite non-negative float, got {threshold}"
+ raise ValueError(msg)
+ if z_pairs.ndim != 3:
+ msg = f"z_pairs must have shape (T, N, d), got {tuple(z_pairs.shape)}"
+ raise ValueError(msg)
+ num_times, num_nodes, latent_dim = (int(dim) for dim in z_pairs.shape)
+ if num_times < 2:
+ msg = f"z_pairs requires T >= 2 consecutive encodings, got T={num_times}"
+ raise ValueError(msg)
+ if num_nodes < 1 or latent_dim < 1:
+ msg = f"z_pairs requires N >= 1 and d >= 1, got N={num_nodes}, d={latent_dim}"
+ raise ValueError(msg)
+ if not z_pairs.is_floating_point():
+ msg = f"z_pairs must be floating-point, got {z_pairs.dtype}"
+ raise ValueError(msg)
+ if max_iter is None:
+ resolved_iter = (
+ DEFAULT_STLSQ_MAX_ITER
+ if method == "stlsq"
+ else DEFAULT_GROUP_LASSO_MAX_ITER
+ )
+ else:
+ resolved_iter = int(max_iter)
+ if resolved_iter < 1:
+ msg = f"max_iter must be >= 1, got {resolved_iter}"
+ raise ValueError(msg)
+
+ shift = _assemble_adjacency(
+ edge_index,
+ num_nodes=num_nodes,
+ edge_weight=edge_weight,
+ dtype=z_pairs.dtype,
+ adjacency=adjacency,
+ )
+ design, target = _stacked_design(z_pairs, shift)
+ masks = _group_masks(group, latent_dim, device=z_pairs.device)
+ if method == "stlsq":
+ stacked = _stlsq_factors(
+ design,
+ target,
+ masks,
+ threshold=float(threshold),
+ max_iter=resolved_iter,
+ )
+ else:
+ stacked = _ista_group_lasso(
+ design,
+ target,
+ masks,
+ threshold=float(threshold),
+ max_iter=resolved_iter,
+ )
+ k_self = stacked[:latent_dim].T.contiguous()
+ k_nbr = stacked[latent_dim:].T.contiguous()
+ residual = float((design @ stacked - target).square().mean().item())
+ mask_self = k_self.abs() > 0
+ mask_nbr = k_nbr.abs() > 0
+ nnz = int(mask_self.sum().item() + mask_nbr.sum().item())
+ return SparseFactorReport(
+ K_self=k_self,
+ K_nbr=k_nbr,
+ active_mask_self=mask_self,
+ active_mask_nbr=mask_nbr,
+ residual=residual,
+ nnz=nnz,
+ n_samples=int(design.shape[0]),
+ method=method,
+ group=group,
+ threshold=float(threshold),
+ )
diff --git a/src/koopman_graph/losses/rollout.py b/src/koopman_graph/losses/rollout.py
index 66d38a2..851999c 100644
--- a/src/koopman_graph/losses/rollout.py
+++ b/src/koopman_graph/losses/rollout.py
@@ -26,6 +26,10 @@
resolve_multiplex_relation_inputs,
resolve_typed_relation_inputs,
)
+from koopman_graph.nn.predicted_topology import (
+ make_recursive_topology_at,
+ recursive_training_enabled,
+)
from koopman_graph.operators import HeteroGraphKoopmanOperator
from koopman_graph.protocols import TrainableKoopmanModel
@@ -410,17 +414,31 @@ def rollout_sequence_loss(
hyperedge_index,
sequence.hyperedge_weight,
)
+ origin = sequence[start]
+ if recursive_training_enabled(model):
+ head = getattr(model, "predicted_topology", None)
+ if head is None:
+ msg = "recursive graph-state training requires a predicted topology head"
+ raise ValueError(msg)
+ topology_at = make_recursive_topology_at(head, origin.edge_index)
+ else:
+ topology_at = snapshot_topology_at(targets)
rollout = autoregressive_latent_rollout(
model.koopman,
decoder_fn,
z,
steps=horizon,
- topology_at=snapshot_topology_at(targets),
+ topology_at=topology_at,
control_at=(
None
if not sequence.has_controls
else (lambda step: sequence.control_at(start + step))
),
+ parameters_at=(
+ None
+ if getattr(sequence, "parameter_trajectory", None) is None
+ else (lambda step: sequence.parameter_trajectory[start + step])
+ ),
delta_t_at=lambda step: resolve_pair_delta_t(
sequence,
start + step,
diff --git a/src/koopman_graph/metrics.py b/src/koopman_graph/metrics.py
index 5a8c9ab..c656dc9 100644
--- a/src/koopman_graph/metrics.py
+++ b/src/koopman_graph/metrics.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import inspect
from collections.abc import Sequence
from dataclasses import dataclass
@@ -223,6 +224,10 @@ class EvaluationResult:
resdmd : object or None
Optional finite-dictionary :class:`~koopman_graph.analysis.ResDMDReport`
when ``include_resdmd=True``.
+ invariance : object or None
+ Optional :class:`~koopman_graph.identification.SubspaceInvarianceReport`
+ when ``include_invariance=True``. Typed as ``object`` so this module
+ does not import ``identification`` at load.
"""
horizons: tuple[HorizonMetrics, ...]
@@ -231,6 +236,7 @@ class EvaluationResult:
aggregate_mape: float
num_origins: int
resdmd: object | None = None
+ invariance: object | None = None
def _hetero_eval_feature_vector(
@@ -380,6 +386,142 @@ def _identity_resdmd(model: TrainableKoopmanModel, sequence: GraphSnapshotSequen
return resdmd(stacked[:-1], stacked[1:])
+def _dense_pernode_discrete_k(model: TrainableKoopmanModel) -> Tensor:
+ """Return dense per-node ``K`` or refuse unsupported layouts.
+
+ Parameters
+ ----------
+ model : TrainableKoopmanModel
+ Model exposing ``koopman`` and layout metadata.
+
+ Returns
+ -------
+ Tensor
+ Dense discrete map with shape ``(d, d)``.
+
+ Raises
+ ------
+ ValueError
+ If the operator is not an uncontrolled dense discrete
+ :class:`~koopman_graph.operators.KoopmanOperator`.
+ """
+ from koopman_graph.operators import KoopmanOperator
+
+ kind = getattr(model, "koopman_kind", "pernode")
+ if kind != "pernode":
+ msg = (
+ "subspace invariance currently supports discrete per-node "
+ f"KoopmanOperator only, got koopman_kind={kind!r}"
+ )
+ raise ValueError(msg)
+ if getattr(model, "dynamics_mode", "discrete") != "discrete":
+ msg = "subspace invariance requires dynamics_mode='discrete'"
+ raise ValueError(msg)
+ if getattr(model, "n_delays", 1) != 1:
+ msg = (
+ "subspace invariance does not support delay embeddings "
+ f"(n_delays={getattr(model, 'n_delays', None)})"
+ )
+ raise ValueError(msg)
+ if int(getattr(model, "control_dim", 0)) != 0:
+ msg = (
+ "subspace invariance does not support controlled models "
+ f"(control_dim={getattr(model, 'control_dim', None)})"
+ )
+ raise ValueError(msg)
+ koopman = model.koopman
+ if not isinstance(koopman, KoopmanOperator):
+ msg = (
+ "subspace invariance currently supports discrete per-node "
+ f"KoopmanOperator only, got {type(koopman).__name__}"
+ )
+ raise ValueError(msg)
+ if koopman.parameterization != "dense":
+ msg = (
+ "subspace invariance requires parameterization='dense', "
+ f"got {koopman.parameterization!r}"
+ )
+ raise ValueError(msg)
+ if koopman.control_dim != 0:
+ msg = (
+ "subspace invariance does not support controlled operators "
+ f"(control_dim={koopman.control_dim})"
+ )
+ raise ValueError(msg)
+ return koopman.K
+
+
+def _encode_invariance_latents(
+ model: TrainableKoopmanModel,
+ sequence: GraphSnapshotSequence,
+) -> Tensor:
+ """Stack per-snapshot encodings as ``(T, N, d)``.
+
+ Parameters
+ ----------
+ model : TrainableKoopmanModel
+ Model exposing ``encode_at`` or ``encode``.
+ sequence : GraphSnapshotSequence
+ Homogeneous snapshots.
+
+ Returns
+ -------
+ Tensor
+ Time-major latents.
+
+ Raises
+ ------
+ ValueError
+ If the model has no encoder façade.
+ """
+ encode_at = getattr(model, "encode_at", None)
+ encode = getattr(model, "encode", None)
+ if encode_at is None and encode is None:
+ msg = "subspace invariance requires model.encode or model.encode_at"
+ raise ValueError(msg)
+ stacked = []
+ for index in range(sequence.num_timesteps):
+ if encode_at is not None:
+ stacked.append(encode_at(sequence, index))
+ else:
+ stacked.append(encode(sequence[index]))
+ return torch.stack(stacked, dim=0)
+
+
+def _identity_invariance(model: TrainableKoopmanModel, sequence: GraphSnapshotSequence):
+ """Finite-sample subspace leakage on frozen encodings.
+
+ Parameters
+ ----------
+ model : TrainableKoopmanModel
+ Homogeneous discrete per-node model.
+ sequence : GraphSnapshotSequence
+ Evaluation snapshots.
+
+ Returns
+ -------
+ object
+ :class:`~koopman_graph.identification.SubspaceInvarianceReport`.
+
+ Raises
+ ------
+ ValueError
+ If the operator layout is unsupported or the leakage denominator
+ is degenerate.
+ """
+ matrix = _dense_pernode_discrete_k(model)
+ was_training = model.training
+ model.eval()
+ try:
+ with torch.no_grad():
+ encodings = _encode_invariance_latents(model, sequence)
+ finally:
+ model.train(was_training)
+ from koopman_graph.identification import subspace_invariance_report
+
+ return subspace_invariance_report(encodings, matrix, held_out=True)
+
+
def _pack_evaluation_result(
horizons: Sequence[int],
mae_sums: dict[int, float],
@@ -387,6 +529,7 @@ def _pack_evaluation_result(
mape_sums: dict[int, float],
origins: Sequence[int],
resdmd: object | None = None,
+ invariance: object | None = None,
) -> EvaluationResult:
"""Assemble per-horizon averages into :class:`EvaluationResult`.
@@ -400,6 +543,8 @@ def _pack_evaluation_result(
Forecast origins used for averaging.
resdmd : object or None, optional
Optional finite-dictionary ResDMD report.
+ invariance : object or None, optional
+ Optional finite-sample subspace-invariance report.
Returns
-------
@@ -426,6 +571,7 @@ def _pack_evaluation_result(
/ len(horizon_metrics),
num_origins=num_origins,
resdmd=resdmd,
+ invariance=invariance,
)
@@ -436,6 +582,8 @@ def evaluate_forecast(
horizons: Sequence[int] = (3, 6, 12),
start_indices: Sequence[int] | None = None,
include_resdmd: bool = False,
+ include_invariance: bool = False,
+ topology_policy: str = "auto",
) -> EvaluationResult:
"""Evaluate autoregressive multi-horizon forecasts on a snapshot sequence.
@@ -470,6 +618,17 @@ def evaluate_forecast(
include_resdmd : bool, optional
When ``True``, attach a finite-dictionary ResDMD report from frozen
encodings. Default is ``False``.
+ include_invariance : bool, optional
+ When ``True``, attach finite-sample subspace leakage :math:`\\eta`
+ from frozen encodings (truncated-SVD projector; last half of the
+ time axis). Default is ``False``. Discrete dense per-node
+ :math:`K` only; hetero / graph / continuous layouts raise.
+ This is not a Haseli–Cortés certificate.
+ topology_policy : {"auto", "recursive", "hold_last"}, optional
+ Homogeneous topology schedule forwarded to ``predict``. When the
+ resolved policy is recursive, dynamic sequences do **not** inject
+ oracle future edges. Default ``"auto"`` keeps 0.14 oracle
+ injection unless a recursive graph-state head is attached.
Returns
-------
@@ -480,7 +639,8 @@ def evaluate_forecast(
------
ValueError
If ``horizons`` is empty, any horizon is invalid, the sequence is too
- short, or unsupported hetero evaluate options are requested.
+ short, unsupported hetero evaluate options are requested, or
+ ``include_invariance=True`` is used on an unsupported layout.
TypeError
If ``sequence`` is neither homogeneous nor hetero.
"""
@@ -495,6 +655,12 @@ def evaluate_forecast(
resolved = _resolve_evaluate_sequence(sequence)
if isinstance(resolved, HeteroGraphSnapshotSequence):
+ if include_invariance:
+ msg = (
+ "include_invariance is unsupported for hetero evaluate; "
+ "use a discrete dense per-node KoopmanOperator"
+ )
+ raise ValueError(msg)
return _evaluate_hetero_forecast(
model,
resolved,
@@ -507,6 +673,8 @@ def evaluate_forecast(
horizons=sorted_horizons,
start_indices=start_indices,
include_resdmd=include_resdmd,
+ include_invariance=include_invariance,
+ topology_policy=topology_policy,
)
@@ -517,6 +685,8 @@ def _evaluate_homogeneous_forecast(
horizons: Sequence[int],
start_indices: Sequence[int] | None,
include_resdmd: bool = False,
+ include_invariance: bool = False,
+ topology_policy: str = "auto",
) -> EvaluationResult:
"""Homogeneous multi-horizon evaluate path.
@@ -532,12 +702,18 @@ def _evaluate_homogeneous_forecast(
Explicit origins, or ``None`` for all valid origins.
include_resdmd : bool, optional
Attach finite-dictionary ResDMD when ``True``.
+ include_invariance : bool, optional
+ Attach finite-sample subspace leakage when ``True``.
+ topology_policy : {"auto", "recursive", "hold_last"}, optional
+ Topology schedule forwarded to ``predict``.
Returns
-------
EvaluationResult
Per-horizon and aggregate metrics.
"""
+ if include_invariance:
+ _dense_pernode_discrete_k(model)
max_horizon = horizons[-1]
origins = resolve_rollout_start_indices(
sequence,
@@ -559,7 +735,16 @@ def _evaluate_homogeneous_forecast(
if model.control_dim > 0:
controls = sequence.rollout_controls(start, max_horizon)
future_topologies = None
- if sequence.is_dynamic_topology:
+ inject_oracle = sequence.is_dynamic_topology
+ if inject_oracle:
+ from koopman_graph.nn.predicted_topology import (
+ resolve_topology_policy,
+ )
+
+ resolved_policy = resolve_topology_policy(model, topology_policy)
+ if topology_policy == "hold_last" or resolved_policy == "recursive":
+ inject_oracle = False
+ if inject_oracle:
future_topologies = [
sequence[start + step] for step in range(1, max_horizon + 1)
]
@@ -580,6 +765,14 @@ def _evaluate_homogeneous_forecast(
# evaluate_forecast on all-present sequences.
if future_presence is not None:
predict_kwargs["future_presence"] = future_presence
+ if "topology_policy" in inspect.signature(model.predict).parameters:
+ predict_kwargs["topology_policy"] = topology_policy
+ predict_params = inspect.signature(model.predict).parameters
+ trajectory = sequence.parameter_trajectory
+ if trajectory is not None and "parameters" in predict_params:
+ predict_kwargs["parameters"] = [
+ trajectory[start + step] for step in range(max_horizon)
+ ]
if n_delays > 1:
history = [
sequence[t] for t in range(max(0, start - n_delays + 1), start)
@@ -618,8 +811,15 @@ def _evaluate_homogeneous_forecast(
model.train(was_training)
report = _identity_resdmd(model, sequence) if include_resdmd else None
+ invariance = _identity_invariance(model, sequence) if include_invariance else None
return _pack_evaluation_result(
- horizons, mae_sums, rmse_sums, mape_sums, origins, resdmd=report
+ horizons,
+ mae_sums,
+ rmse_sums,
+ mape_sums,
+ origins,
+ resdmd=report,
+ invariance=invariance,
)
diff --git a/src/koopman_graph/model/estimator.py b/src/koopman_graph/model/estimator.py
index 290405b..0767df0 100644
--- a/src/koopman_graph/model/estimator.py
+++ b/src/koopman_graph/model/estimator.py
@@ -14,6 +14,7 @@
from koopman_graph.adaptation.rls import AdaptationStepResult, RecursiveKoopmanAdapter
from koopman_graph.data import (
+ GraphDynamicsConfig,
GraphSnapshotSequence,
HeteroGraphSnapshotSequence,
RolloutStartIndices,
@@ -42,9 +43,14 @@
DelayEmbeddingEncoder,
HypergraphDecoder,
HypergraphEncoder,
+ PredictedTopologyHead,
+ PresenceHead,
RelGraphDecoder,
RelGraphEncoder,
+ SparseCandidateTopologyHead,
+ TopologyPolicy,
bind_hypergraph_decoder,
+ check_encoder_operator_receptive_field,
)
from koopman_graph.nn.delay import history_from_snapshots
from koopman_graph.nn.heterogeneous import (
@@ -66,6 +72,7 @@
Parameterization,
)
from koopman_graph.operators.control import ControlMode
+from koopman_graph.operators.parametric import WeightKind
from koopman_graph.protocols import DynamicsMode
from koopman_graph.spectrum_types import KoopmanSpectrum
from koopman_graph.training import (
@@ -96,7 +103,10 @@
DEFAULT_KOOPMAN_LOCAL_RANK,
DEFAULT_KOOPMAN_LOCAL_WINDOW,
DEFAULT_KOOPMAN_MAX_SPECTRAL_RADIUS,
+ DEFAULT_KOOPMAN_NUM_MODES,
+ DEFAULT_KOOPMAN_PARAMETER_DIM,
DEFAULT_KOOPMAN_PARAMETERIZATION,
+ DEFAULT_KOOPMAN_WEIGHT_KIND,
Decoder,
Encoder,
KoopmanArg,
@@ -109,6 +119,7 @@
latent_decode_rollout,
predict_at_snapshots,
predict_snapshots,
+ sequence_subspace_invariance,
)
from .online_adaptation import (
disable_online_adaptation as disable_online_adaptation_helper,
@@ -132,6 +143,11 @@
if TYPE_CHECKING:
from koopman_graph.distributed import DistributedWindowSampler
+ from koopman_graph.identification import (
+ IdentificationConfig,
+ IdentificationReport,
+ SubspaceInvarianceReport,
+ )
class GraphKoopmanModel(nn.Module):
@@ -175,6 +191,16 @@ class GraphKoopmanModel(nn.Module):
dynamics_mode : {"discrete", "continuous"}
Whether latent evolution uses a discrete step map or a continuous
generator integrated with matrix exponentials.
+ identification_report : IdentificationReport or None
+ Filled after ``fit(..., identification=...)``; ``None`` on the
+ Adam path.
+ graph_dynamics : GraphDynamicsConfig or None
+ Opt-in graph-state closure. ``None`` keeps the 0.14 encode /
+ decode / advance path. Distinct from ``learn_topology``.
+ predicted_topology : PredictedTopologyHead or SparseCandidateTopologyHead or None
+ Head constructed from ``graph_dynamics.topology_head``.
+ presence_head : PresenceHead or None
+ Linear per-node presence logits when ``graph_dynamics`` is set.
"""
def __init__(
@@ -193,6 +219,7 @@ def __init__(
koopman_auxiliary_hidden_dims: Sequence[int] | None = None,
koopman_sparsity: str = "dense",
koopman_adjacency: str = "symmetric",
+ koopman_filter_degree: int = 1,
koopman_hypergraph_incidence_mode: str = "zhou_symmetric",
koopman_local_window: int = DEFAULT_KOOPMAN_LOCAL_WINDOW,
koopman_local_rank: int = DEFAULT_KOOPMAN_LOCAL_RANK,
@@ -211,12 +238,16 @@ def __init__(
n_delays: int = 1,
learn_topology: str | None = None,
topology_embedding_dim: int = DEFAULT_TOPOLOGY_EMBEDDING_DIM,
+ graph_dynamics: GraphDynamicsConfig | None = None,
koopman_node_types: Sequence[str] | None = None,
koopman_edge_types: Sequence[Sequence[str]] | None = None,
koopman_relation_tying: str = "independent",
koopman_basis_size: int | None = None,
koopman_synthesize_reverse_relations: bool = False,
koopman_latent_dims: Mapping[str, int] | None = None,
+ koopman_num_modes: int = DEFAULT_KOOPMAN_NUM_MODES,
+ koopman_parameter_dim: int = DEFAULT_KOOPMAN_PARAMETER_DIM,
+ koopman_weight_kind: WeightKind = DEFAULT_KOOPMAN_WEIGHT_KIND,
) -> None:
"""Initialize encoder, decoder, and Koopman operator.
@@ -253,6 +284,12 @@ def __init__(
:class:`~koopman_graph.operators.KoopmanOperatorContract`
``nn.Module``. When injecting, factory kwargs must remain at their
defaults. Continuous models ignore ``"graph"`` (raises).
+ ``"switched"`` and ``"mixture"`` are piecewise or latent-gated
+ LTI banks, not a parameter interpolant :math:`K(\\mu)`.
+ ``koopman="parametric"`` is the discrete interpolant
+ :math:`K(\\mu)=\\sum_j \\alpha_j(\\mu) K_j`. Store
+ :math:`\\mu_t` on
+ :attr:`~koopman_graph.data.GraphSnapshotSequence.parameter_trajectory`.
koopman_init_mode : {"identity", "identity_noise", "xavier"}, optional
Initialization strategy for the Koopman matrix. Default is
``"identity_noise"``. Ignored (and must stay default) when
@@ -288,6 +325,10 @@ def __init__(
Neighbor-coupling normalization for ``koopman="graph"`` and
continuous-graph peers. Default ``"symmetric"``. Rejected for
non-networked ``koopman`` choices.
+ koopman_filter_degree : int, optional
+ Monomial hop degree for discrete ``koopman="graph"``. Default
+ ``1`` (one-tap factorization). ``0`` is self only; ``P>1`` is
+ opt-in. Rejected for other kinds and for continuous graph.
koopman_local_window : int, optional
Latent history length for ``koopman="global_local"`` (default
``4``). Must stay default when not using global/local.
@@ -361,6 +402,18 @@ def __init__(
topology_embedding_dim : int, optional
Embedding width ``k`` for ``learn_topology="self_adaptive"``.
Default ``8``. Ignored when ``learn_topology`` is ``None``.
+ graph_dynamics : GraphDynamicsConfig or None, optional
+ Opt-in graph-state closure. ``None`` (default) keeps the 0.14
+ encode / decode / advance path. When set, a
+ :class:`~koopman_graph.nn.SparseCandidateTopologyHead` or
+ :class:`~koopman_graph.nn.PredictedTopologyHead` is constructed
+ from ``topology_head`` (default ``"sparse_candidate"`` with
+ ``candidate_k=8``). Distinct from ``learn_topology``. When a
+ head is attached and ``recursive_training`` is ``True``,
+ omitted ``future_topologies`` uses predicted
+ :math:`\\hat A_{t+1}=g_\\phi(z_t)` (sigmoid weights on the
+ candidate COO). ``learn_topology="self_adaptive"`` cannot be
+ combined with a non-``none`` topology head.
koopman_node_types : sequence of str or None, optional
Ordered node-type names for ``koopman="hetero_graph"`` (defaults
to multiplex ``("node",)``).
@@ -380,6 +433,15 @@ def __init__(
koopman_latent_dims : mapping of str to int or None, optional
Opt-in per-type latent widths. When set, RelGraph peers are
aligned and the discrete hetero operator receives the same map.
+ koopman_num_modes : int, optional
+ Interpolant mode count for ``koopman="parametric"``. Default
+ is 2. Rejected for other kinds when not left at the default.
+ koopman_parameter_dim : int, optional
+ Regime-coordinate width :math:`d_\\mu` for
+ ``koopman="parametric"``. Default is 1.
+ koopman_weight_kind : {"rbf", "simplex"}, optional
+ Interpolant weights for ``koopman="parametric"``. Default is
+ ``"rbf"``.
koopman_hypergraph_incidence_mode
See signature.
Raises
@@ -392,7 +454,9 @@ def __init__(
factory kwargs or dimensions, ``dynamics_mode`` disagrees with a
built-in injected operator type, or ``learn_topology`` is invalid.
TypeError
- If ``koopman`` is provided but is not a string kind or ``nn.Module``."""
+ If ``koopman`` is provided but is not a string kind or ``nn.Module``,
+ or if ``graph_dynamics`` is neither ``None`` nor a
+ :class:`~koopman_graph.data.GraphDynamicsConfig`."""
super().__init__()
components = resolve_model_components(
encoder,
@@ -408,6 +472,7 @@ def __init__(
koopman_auxiliary_hidden_dims=koopman_auxiliary_hidden_dims,
koopman_sparsity=koopman_sparsity,
koopman_adjacency=koopman_adjacency, # type: ignore[arg-type]
+ koopman_filter_degree=koopman_filter_degree,
koopman_hypergraph_incidence_mode=koopman_hypergraph_incidence_mode,
koopman_local_window=koopman_local_window,
koopman_local_rank=koopman_local_rank,
@@ -430,6 +495,9 @@ def __init__(
koopman_basis_size=koopman_basis_size,
koopman_synthesize_reverse_relations=(koopman_synthesize_reverse_relations),
koopman_latent_dims=koopman_latent_dims,
+ koopman_num_modes=koopman_num_modes,
+ koopman_parameter_dim=koopman_parameter_dim,
+ koopman_weight_kind=koopman_weight_kind,
)
apply_resolved_components(self, components)
if learn_topology is not None and learn_topology != "self_adaptive":
@@ -458,10 +526,61 @@ def __init__(
self.adaptive_topology = AdaptiveAdjacency(topology_embedding_dim)
else:
self.adaptive_topology = None
+ if graph_dynamics is not None and not isinstance(
+ graph_dynamics, GraphDynamicsConfig
+ ):
+ msg = (
+ "graph_dynamics must be GraphDynamicsConfig or None, got "
+ f"{type(graph_dynamics).__name__}"
+ )
+ raise TypeError(msg)
+ if graph_dynamics is not None:
+ if uses_relgraph_modules(components.encoder, components.decoder):
+ msg = (
+ "graph_dynamics is unsupported with RelGraphEncoder / "
+ "RelGraphDecoder (koopman='hetero_graph')"
+ )
+ raise ValueError(msg)
+ if uses_hypergraph_modules(components.encoder, components.decoder):
+ msg = (
+ "graph_dynamics is unsupported with HypergraphEncoder / "
+ "HypergraphDecoder"
+ )
+ raise ValueError(msg)
+ if (
+ learn_topology == "self_adaptive"
+ and graph_dynamics.topology_head != "none"
+ ):
+ msg = (
+ "learn_topology='self_adaptive' cannot be combined with "
+ "graph_dynamics.topology_head="
+ f"{graph_dynamics.topology_head!r}"
+ )
+ raise ValueError(msg)
+ self.graph_dynamics = graph_dynamics
+ self.predicted_topology: (
+ PredictedTopologyHead | SparseCandidateTopologyHead | None
+ )
+ self.presence_head: PresenceHead | None
+ if graph_dynamics is None:
+ self.predicted_topology = None
+ self.presence_head = None
+ else:
+ self.presence_head = PresenceHead(components.latent_dim)
+ if graph_dynamics.topology_head == "none":
+ self.predicted_topology = None
+ elif graph_dynamics.topology_head == "sparse_candidate":
+ self.predicted_topology = SparseCandidateTopologyHead(
+ components.latent_dim,
+ candidate_k=graph_dynamics.candidate_k,
+ )
+ else:
+ self.predicted_topology = PredictedTopologyHead(components.latent_dim)
# Sequence-contract metadata for format-1 checkpoints (not presence tensors).
self._allow_node_churn = False
self._has_presence_masks = False
self._entity_ids: tuple[str | int, ...] | None = None
+ self.identification_report: IdentificationReport | None = None
@property
def allow_node_churn(self) -> bool:
@@ -875,6 +994,8 @@ def _advance_latent(
edge_indices: Sequence[Tensor] | None = None,
edge_weights: Sequence[Tensor | None] | None = None,
num_nodes_dict: Mapping[str, int] | None = None,
+ parameters: Tensor | None = None,
+ phase_index: int | None = None,
) -> Tensor:
"""Advance latent states with the active Koopman operator.
@@ -905,6 +1026,10 @@ def _advance_latent(
Optional per-relation weights for hetero advance.
num_nodes_dict : Mapping[str, int] | None
Per-type node counts for typed hetero advance.
+ parameters : Tensor or None, optional
+ Regime coordinate :math:`\\mu` for parametric interpolants.
+ phase_index : int or None, optional
+ Switched-by-clock mode for ``koopman="switched"``.
Returns
-------
@@ -927,6 +1052,8 @@ def _advance_latent(
edge_indices=edge_indices,
edge_weights=edge_weights,
num_nodes_dict=num_nodes_dict,
+ parameters=parameters,
+ phase_index=phase_index,
)
def spectrum(
@@ -1466,6 +1593,8 @@ def forward(
edge_weight: Tensor | None = None,
control: Tensor | None = None,
delta_t: float | Tensor | None = None,
+ parameters: Tensor | None = None,
+ phase_index: int | None = None,
) -> Tensor | dict[str, Tensor]:
"""Predict the next graph snapshot from the current one.
@@ -1490,6 +1619,13 @@ def forward(
delta_t : float, Tensor, or None, optional
Integration interval for continuous-time dynamics. Defaults to
:attr:`time_step` when omitted.
+ parameters : Tensor or None, optional
+ Regime coordinate :math:`\\mu` with shape ``(d_mu,)`` for
+ ``koopman="parametric"``. Ignored by other operators.
+ phase_index : int or None, optional
+ Switched-by-clock mode for ``koopman="switched"``. Ignored
+ by other operators. Does not write the operator
+ ``mode_index``.
Returns
-------
@@ -1517,6 +1653,8 @@ def forward(
edge_indices=edge_indices,
edge_weights=edge_weights,
num_nodes_dict=num_nodes_dict,
+ parameters=parameters,
+ phase_index=phase_index,
)
return self._decode_hetero(
z_next, edge_indices, edge_weights, num_nodes_dict
@@ -1551,6 +1689,8 @@ def forward(
hyperedge_weight=hyperedge_weight,
tail_index=tail_index,
head_index=head_index,
+ parameters=parameters,
+ phase_index=phase_index,
)
if isinstance(self.decoder, HypergraphDecoder):
if hyperedge_index is None:
@@ -1678,6 +1818,8 @@ def _rollout(
future_presence: Tensor | Sequence[Tensor] | None = None,
step_deltas: Sequence[float] | Sequence[Tensor] | None = None,
history: Sequence[Data] | None = None,
+ topology_policy: TopologyPolicy | str = "auto",
+ parameters: Sequence[Tensor] | None = None,
) -> list[tuple[Tensor, Tensor, Tensor | None]]:
"""Autoregressively advance latent state and decode for multiple steps.
@@ -1702,6 +1844,9 @@ def _rollout(
controls : sequence of Tensor or None, optional
Control inputs for each rollout step. Required with length
``steps`` when :attr:`control_dim` is positive.
+ parameters : sequence of Tensor or None, optional
+ Per-step regime coordinates for ``koopman="parametric"``.
+ Length ``steps``; each entry has shape ``(d_mu,)``.
future_topologies : sequence of Data or None, optional
Known graph topologies for rollout decode steps. Entry ``step`` is
used when present; otherwise the last known topology is held
@@ -1717,6 +1862,10 @@ def _rollout(
Past snapshots (oldest → newest) used with ``x_or_data`` to form a
delay window when ``n_delays > 1``. When omitted, missing history
is zero-padded.
+ topology_policy : {"auto", "recursive", "hold_last"}, optional
+ Homogeneous rollout topology policy. ``auto`` uses predicted
+ :math:`\\hat A_{t+1}` when a recursive graph-state head is
+ attached.
Returns
-------
@@ -1781,6 +1930,9 @@ def _rollout(
future_presence=future_presence,
step_deltas=step_deltas,
history=history,
+ topology_policy=topology_policy,
+ topology_model=self,
+ parameters=parameters,
)
def predict(
@@ -1793,6 +1945,9 @@ def predict(
future_topologies: Sequence[Data] | Sequence[HeteroData] | None = None,
future_presence: Tensor | Sequence[Tensor] | None = None,
history: Sequence[Data] | None = None,
+ *,
+ topology_policy: TopologyPolicy | str = "auto",
+ parameters: Sequence[Tensor] | None = None,
) -> list[Data] | list[HeteroData]:
"""Autoregressively predict future graph snapshots.
@@ -1812,11 +1967,18 @@ def predict(
**not** interchangeable with classical baselines (DMDc always requires
``controls``).
- When ``future_topologies`` is omitted, each rollout step decodes with
- the **hold-last-known** topology: the initial graph topology is used
- for step 0, and each subsequent step reuses the most recently provided
- topology. Pass one ``Data`` object per rollout step (topology only; node
- features are ignored) to supply a known future rewiring schedule.
+ When ``future_topologies`` is omitted, the default
+ ``topology_policy="auto"`` uses predicted
+ :math:`\\hat A_{t+1}=g_\\phi(z_t)` (sigmoid weights on the
+ candidate COO) when a graph-state head is attached and
+ ``recursive_training`` is ``True``. Otherwise each rollout step
+ uses the **hold-last-known** topology: the initial graph
+ topology is used for step 0, and each subsequent step reuses the
+ most recently provided topology. Pass ``topology_policy="hold_last"``
+ to force that 0.14 control. Pass one ``Data`` object per rollout
+ step (topology only; node features are ignored) as
+ ``future_topologies`` to supply a known future rewiring schedule
+ (oracle; overrides predicted topology).
When ``future_presence`` is provided, inactive entities follow the
**hold last active state** policy (latent and decoded features freeze
@@ -1856,6 +2018,13 @@ def predict(
history : sequence of Data or None, optional
Prior observations (oldest → newest, excluding ``initial_graph``)
for delay embedding when ``n_delays > 1``. Homogeneous-only.
+ topology_policy : {"auto", "recursive", "hold_last"}, optional
+ Homogeneous topology schedule when ``future_topologies`` is
+ omitted. Default ``"auto"``.
+ parameters : sequence of Tensor or None, optional
+ Per-step regime coordinates for ``koopman="parametric"``.
+ Length ``steps``; each entry has shape ``(d_mu,)``. Ignored
+ by other operators.
Returns
-------
@@ -1870,6 +2039,11 @@ def predict(
model.
"""
if self.uses_hetero_koopman or isinstance(initial_graph, HeteroData):
+ if topology_policy == "recursive":
+ msg = (
+ "topology_policy='recursive' is unsupported for HeteroData predict"
+ )
+ raise ValueError(msg)
if history is not None:
msg = "history / delay embedding is unsupported for HeteroData predict"
raise ValueError(msg)
@@ -1926,6 +2100,8 @@ def predict(
future_topologies=future_topologies, # type: ignore[arg-type]
future_presence=future_presence,
history=history,
+ topology_policy=topology_policy,
+ parameters=parameters,
)
def predict_at(
@@ -1939,6 +2115,7 @@ def predict_at(
controls: Sequence[Tensor] | None = None,
future_topologies: Sequence[Data] | Sequence[HeteroData] | None = None,
future_presence: Tensor | Sequence[Tensor] | None = None,
+ topology_policy: TopologyPolicy | str = "auto",
) -> list[Data] | list[HeteroData]:
"""Forecast graph snapshots at arbitrary query times.
@@ -1966,6 +2143,8 @@ def predict_at(
edge_index, edge_weight, controls, future_topologies, future_presence
Same semantics as :meth:`predict` (including the inactive-node
hold policy and ``N_max`` matvec cost note).
+ topology_policy : {"auto", "recursive", "hold_last"}, optional
+ Same semantics as :meth:`predict`.
Returns
-------
@@ -1973,6 +2152,12 @@ def predict_at(
Predicted snapshots, one per query interval.
"""
if self.uses_hetero_koopman or isinstance(initial_graph, HeteroData):
+ if topology_policy == "recursive":
+ msg = (
+ "topology_policy='recursive' is unsupported for HeteroData "
+ "predict_at"
+ )
+ raise ValueError(msg)
if future_presence is not None:
msg = "future_presence is unsupported for HeteroData predict_at"
raise ValueError(msg)
@@ -2038,6 +2223,7 @@ def predict_at(
controls=controls,
future_topologies=future_topologies, # type: ignore[arg-type]
future_presence=future_presence,
+ topology_policy=topology_policy,
)
def evaluate(
@@ -2047,6 +2233,8 @@ def evaluate(
horizons: Sequence[int] = (3, 6, 12),
start_indices: Sequence[int] | None = None,
include_resdmd: bool = False,
+ include_invariance: bool = False,
+ topology_policy: TopologyPolicy | str = "auto",
) -> EvaluationResult:
"""Evaluate multi-horizon forecast accuracy on a snapshot sequence.
@@ -2068,6 +2256,15 @@ def evaluate(
include_resdmd : bool, optional
When ``True``, attach a finite-dictionary ResDMD report from
frozen encodings. Default is ``False``.
+ include_invariance : bool, optional
+ When ``True``, attach finite-sample subspace leakage
+ :math:`\\eta` (truncated-SVD projector on the last half of
+ the time axis). Default is ``False``. Discrete dense
+ per-node :math:`K` only. Not a Haseli–Cortés certificate.
+ topology_policy : {"auto", "recursive", "hold_last"}, optional
+ Homogeneous forecast topology policy. ``auto`` uses predicted
+ topology when a recursive graph-state head is attached;
+ dynamic sequences otherwise keep oracle ``future_topologies``.
Returns
-------
@@ -2080,8 +2277,49 @@ def evaluate(
horizons=horizons,
start_indices=start_indices,
include_resdmd=include_resdmd,
+ include_invariance=include_invariance,
+ topology_policy=topology_policy,
)
+ def subspace_invariance_report(
+ self,
+ sequence: SnapshotSequence | Sequence[Data] | Sequence[HeteroData],
+ *,
+ held_out: bool = True,
+ ) -> SubspaceInvarianceReport:
+ """Finite-sample subspace leakage :math:`\\eta` on encoded snapshots.
+
+ Encodes each snapshot, then calls
+ :func:`~koopman_graph.identification.subspace_invariance_report`.
+ ``held_out=True`` (default) builds the projector and the
+ expectation on the last half of the provided time axis (same
+ window). Discrete dense per-node
+ :class:`~koopman_graph.operators.KoopmanOperator` only.
+
+ This is a dimensionless projection residual, **not** a
+ Haseli–Cortés invariance-proximity certificate.
+
+ Parameters
+ ----------
+ sequence : GraphSnapshotSequence or sequence of Data
+ Snapshots to encode. Hetero sequences raise.
+ held_out : bool, optional
+ Use the last half of the time axis. Default is ``True``.
+ Requires at least four snapshots.
+
+ Returns
+ -------
+ SubspaceInvarianceReport
+ Leakage, sample count, numerical rank, and ``held_out``.
+
+ Raises
+ ------
+ ValueError
+ If the operator layout is unsupported, the hold-out window
+ is too short, or :math:`\\mathbb{E}\\|K\\psi\\|_{2}` is zero.
+ """
+ return sequence_subspace_invariance(self, sequence, held_out=held_out)
+
def fit(
self,
data_sequence: TrainingInput,
@@ -2115,6 +2353,8 @@ def fit(
callbacks: Sequence[FitCallback] | None = None,
strategy: Literal["ddp"] | None = None,
find_unused_parameters: bool | None = None,
+ batch_graphs: bool = False,
+ identification: IdentificationConfig | None = None,
**optimizer_kwargs: Any,
) -> FitHistory:
"""Train encoder, Koopman operator, and decoder end-to-end.
@@ -2147,6 +2387,19 @@ def fit(
When ``data_sequence`` is a :class:`~koopman_graph.data.MultiTrajectory`,
losses are averaged across trajectories before each optimizer step.
+ Pass ``batch_graphs=True`` to collate those trajectories into one
+ PyG ``Batch`` (vectorized reconstruction / forward; default loop
+ unchanged). Discrete ``koopman="graph"`` emits
+ :class:`~koopman_graph.nn.ReceptiveFieldMismatchWarning` at fit
+ start when encoder hops exceed ``filter_degree``; training still
+ proceeds. Encoder mixing does not compensate for a one-hop
+ Koopman factor.
+
+ Pass ``identification=IdentificationConfig(...)`` to alternate
+ frozen-encoder closed-form ``K`` updates with encoder/decoder Adam
+ steps. Default ``None`` is the 0.14 Adam path. Discrete dense
+ per-node operators only; the report is stored on
+ :attr:`identification_report`.
Parameters
----------
@@ -2266,6 +2519,23 @@ def fit(
(default) resolves to ``True`` for
``koopman_kind="hetero_graph"`` and ``False`` otherwise. Ignored
for single-process ``strategy=None``.
+ batch_graphs : bool, optional
+ When ``True``, collate homogeneous trajectories into one PyG
+ ``Batch`` so shared :math:`K` applies to the disconnected union
+ with per-graph shifts. Reconstruction (and forward consistency
+ when weighted) match the mean of the per-sequence
+ ``MultiTrajectory`` loop. Default is ``False``. Mutually
+ exclusive with ``window_length``, ``sampler``, and
+ ``strategy="ddp"``. Hetero, hypergraph, continuous, controlled,
+ delay, orbit-tied, and learned-topology models raise.
+ identification : IdentificationConfig or None, optional
+ Opt-in closed-form operator identification. ``None`` (default)
+ keeps the Adam path. When set, requires a discrete dense
+ per-node :class:`~koopman_graph.operators.KoopmanOperator`.
+ Mutually exclusive with ``strategy="ddp"``, windowed sampling,
+ and ``batch_graphs=True``. After fit, read
+ :attr:`identification_report` (one-step / short-rollout MSE and
+ :math:`\\rho(K)`; not an invariance or ResDMD certificate).
**optimizer_kwargs
Additional keyword arguments forwarded to the optimizer constructor.
@@ -2285,8 +2555,22 @@ def fit(
validation list length mismatches training trajectories, fewer
than two snapshots are provided for training or validation, or
``strategy`` is not ``None`` / ``"ddp"``, or ``callbacks`` is set
- with ``strategy="ddp"``.
+ with ``strategy="ddp"``, or ``batch_graphs=True`` is combined
+ with windowed sampling or ``strategy="ddp"``, or
+ ``identification`` is combined with ``strategy="ddp"`` or an
+ unsupported operator layout.
+
+ Warnings
+ --------
+ ReceptiveFieldMismatchWarning
+ Encoder hops exceed discrete graph ``filter_degree``. Fit still
+ proceeds.
"""
+ check_encoder_operator_receptive_field(
+ self.encoder,
+ self.koopman,
+ stacklevel=3,
+ )
uses_simplicial_modules(self.encoder, self.decoder)
uses_sheaf_modules(self.encoder, self.decoder)
uses_cell_complex_modules(self.encoder, self.decoder)
@@ -2330,6 +2614,7 @@ def fit(
"val_sequences": prepared.val_sequences,
"restore_best_weights": restore_best_weights,
"checkpoint_path": checkpoint_path,
+ "batch_graphs": batch_graphs,
**optimizer_kwargs,
}
if strategy is None:
@@ -2337,9 +2622,22 @@ def fit(
self,
prepared.train_sequences,
callbacks=callbacks,
+ identification=identification,
**loop_kwargs,
)
+ if identification is not None:
+ msg = (
+ "fit(..., identification=...) is not supported with "
+ 'strategy="ddp"; use strategy=None (single-process)'
+ )
+ raise ValueError(msg)
if strategy == "ddp":
+ if batch_graphs:
+ msg = (
+ "fit(..., batch_graphs=True) is not supported with "
+ 'strategy="ddp"; use strategy=None (single-process)'
+ )
+ raise ValueError(msg)
if callbacks is not None:
msg = (
"fit(..., callbacks=...) is not supported with "
@@ -2349,11 +2647,16 @@ def fit(
raise ValueError(msg)
from koopman_graph.distributed import run_ddp_fit_loop
+ ddp_kwargs = {
+ key: value
+ for key, value in loop_kwargs.items()
+ if key != "batch_graphs"
+ }
return run_ddp_fit_loop(
self,
prepared.train_sequences,
find_unused_parameters=find_unused_parameters,
- **loop_kwargs,
+ **ddp_kwargs,
)
msg = (
f"unsupported fit strategy {strategy!r}; expected None or 'ddp' "
diff --git a/src/koopman_graph/model/factory.py b/src/koopman_graph/model/factory.py
index 4cfc5f8..ac8fc28 100644
--- a/src/koopman_graph/model/factory.py
+++ b/src/koopman_graph/model/factory.py
@@ -61,6 +61,7 @@
KoopmanOperatorContract,
MixtureKoopmanOperator,
Parameterization,
+ ParametricKoopmanOperator,
SwitchedKoopmanOperator,
)
from koopman_graph.operators.auxiliary_spectral import (
@@ -74,7 +75,14 @@
DEFAULT_LOCAL_WINDOW,
normalize_local_hidden_dims,
)
+from koopman_graph.operators.parametric import (
+ DEFAULT_PARAMETER_DIM,
+ DEFAULT_WEIGHT_KIND,
+ WeightKind,
+)
+from koopman_graph.operators.polynomial_graph import validate_filter_degree
from koopman_graph.operators.stochastic import attach_process_noise
+from koopman_graph.operators.switched import DEFAULT_NUM_MODES
from koopman_graph.protocols import DynamicsMode
Encoder = (
@@ -144,6 +152,10 @@
)
DEFAULT_KOOPMAN_SYMMETRY: str | None = None
DEFAULT_KOOPMAN_ADJACENCY: GraphAdjacency = "symmetric"
+DEFAULT_KOOPMAN_FILTER_DEGREE = 1
+DEFAULT_KOOPMAN_NUM_MODES = DEFAULT_NUM_MODES
+DEFAULT_KOOPMAN_PARAMETER_DIM = DEFAULT_PARAMETER_DIM
+DEFAULT_KOOPMAN_WEIGHT_KIND: WeightKind = DEFAULT_WEIGHT_KIND
_NETWORKED_ADJACENCY_KINDS: frozenset[str] = frozenset({"graph", "continuous_graph"})
_GRAPH_ADJACENCY_MODES: frozenset[str] = frozenset(
{"symmetric", "random_walk", "dual_random_walk"}
@@ -495,6 +507,7 @@ def resolve_model_components(
koopman_auxiliary_hidden_dims: Sequence[int] | None = None,
koopman_sparsity: str = "dense",
koopman_adjacency: GraphAdjacency = DEFAULT_KOOPMAN_ADJACENCY,
+ koopman_filter_degree: int = DEFAULT_KOOPMAN_FILTER_DEGREE,
koopman_hypergraph_incidence_mode: str = (
DEFAULT_KOOPMAN_HYPERGRAPH_INCIDENCE_MODE
),
@@ -521,6 +534,9 @@ def resolve_model_components(
koopman_basis_size: int | None = None,
koopman_synthesize_reverse_relations: bool = False,
koopman_latent_dims: Mapping[str, int] | None = None,
+ koopman_num_modes: int = DEFAULT_KOOPMAN_NUM_MODES,
+ koopman_parameter_dim: int = DEFAULT_KOOPMAN_PARAMETER_DIM,
+ koopman_weight_kind: WeightKind = DEFAULT_KOOPMAN_WEIGHT_KIND,
) -> ResolvedModelComponents:
"""Validate and assemble encoder / physics / Koopman construction inputs.
@@ -557,6 +573,9 @@ def resolve_model_components(
koopman_adjacency : {"symmetric", "random_walk", "dual_random_walk"}
Neighbor-coupling normalization for ``koopman="graph"`` /
``"continuous_graph"``. Default ``"symmetric"``.
+ koopman_filter_degree : int, optional
+ Monomial hop degree for discrete ``koopman="graph"``. Default ``1``
+ (one-tap). Rejected for other kinds and for continuous graph.
koopman_hypergraph_incidence_mode : str
Incidence normalization for ``koopman="hypergraph"``
(``zhou_symmetric`` / ``forward_random_walk`` / ``dual_random_walk``).
@@ -604,6 +623,12 @@ def resolve_model_components(
Opt-in per-type latent widths. When set, RelGraph peers are rebuilt to
match and the discrete or continuous hetero operator receives the
same mapping.
+ koopman_num_modes : int, optional
+ Interpolant mode count for ``koopman="parametric"``. Default 2.
+ koopman_parameter_dim : int, optional
+ Regime-coordinate width for ``koopman="parametric"``. Default 1.
+ koopman_weight_kind : {"rbf", "simplex"}, optional
+ Interpolant weights for ``koopman="parametric"``. Default ``"rbf"``.
Returns
-------
@@ -822,6 +847,7 @@ def resolve_model_components(
koopman_auxiliary_hidden_dims=koopman_auxiliary_hidden_dims,
koopman_sparsity=koopman_sparsity,
koopman_adjacency=koopman_adjacency,
+ koopman_filter_degree=koopman_filter_degree,
koopman_hypergraph_incidence_mode=koopman_hypergraph_incidence_mode,
koopman_local_window=koopman_local_window,
koopman_local_rank=koopman_local_rank,
@@ -837,6 +863,9 @@ def resolve_model_components(
relation_tying=koopman_relation_tying,
basis_size=koopman_basis_size,
latent_dims=resolved_latent_dims,
+ koopman_num_modes=koopman_num_modes,
+ koopman_parameter_dim=koopman_parameter_dim,
+ koopman_weight_kind=koopman_weight_kind,
)
if isinstance(operator, HeteroKoopmanOperator):
if not uses_relgraph:
@@ -951,12 +980,14 @@ def parse_koopman_arg(
"continuous_graph",
"switched",
"mixture",
+ "parametric",
"hodge",
}:
msg = (
"koopman string kind must be 'pernode', 'graph', "
"'hypergraph', 'hetero_graph', 'global_local', "
- "'continuous_graph', 'switched', 'mixture', or 'hodge', "
+ "'continuous_graph', 'switched', 'mixture', "
+ "'parametric', or 'hodge', "
f"got {koopman!r}"
)
raise ValueError(msg)
@@ -987,9 +1018,13 @@ def resolve_injected_koopman(
koopman_orbit_method: OrbitMethod = DEFAULT_KOOPMAN_ORBIT_METHOD,
koopman_symmetry: str | None = DEFAULT_KOOPMAN_SYMMETRY,
koopman_adjacency: GraphAdjacency = DEFAULT_KOOPMAN_ADJACENCY,
+ koopman_filter_degree: int = DEFAULT_KOOPMAN_FILTER_DEGREE,
koopman_hypergraph_incidence_mode: str = (
DEFAULT_KOOPMAN_HYPERGRAPH_INCIDENCE_MODE
),
+ koopman_num_modes: int = DEFAULT_KOOPMAN_NUM_MODES,
+ koopman_parameter_dim: int = DEFAULT_KOOPMAN_PARAMETER_DIM,
+ koopman_weight_kind: WeightKind = DEFAULT_KOOPMAN_WEIGHT_KIND,
) -> KoopmanOperatorContract:
"""Validate and return an injected Koopman operator module.
@@ -1032,11 +1067,19 @@ def resolve_injected_koopman(
See the function signature / summary for ``koopman_orbit_method``.
koopman_adjacency : GraphAdjacency
Factory adjacency mode (must be default when injecting).
+ koopman_filter_degree : int
+ Factory hop degree (must be default when injecting).
koopman_symmetry
See signature.
koopman_hypergraph_incidence_mode
See signature.
+ koopman_num_modes : int, optional
+ Must stay default when injecting.
+ koopman_parameter_dim : int, optional
+ Must stay default when injecting.
+ koopman_weight_kind : str, optional
+ Must stay default when injecting.
Returns
-------
@@ -1091,8 +1134,16 @@ def resolve_injected_koopman(
conflicting.append("koopman_symmetry")
if koopman_adjacency != DEFAULT_KOOPMAN_ADJACENCY:
conflicting.append("koopman_adjacency")
+ if koopman_filter_degree != DEFAULT_KOOPMAN_FILTER_DEGREE:
+ conflicting.append("koopman_filter_degree")
if koopman_hypergraph_incidence_mode != DEFAULT_KOOPMAN_HYPERGRAPH_INCIDENCE_MODE:
conflicting.append("koopman_hypergraph_incidence_mode")
+ if koopman_num_modes != DEFAULT_KOOPMAN_NUM_MODES:
+ conflicting.append("koopman_num_modes")
+ if koopman_parameter_dim != DEFAULT_KOOPMAN_PARAMETER_DIM:
+ conflicting.append("koopman_parameter_dim")
+ if koopman_weight_kind != DEFAULT_KOOPMAN_WEIGHT_KIND:
+ conflicting.append("koopman_weight_kind")
if conflicting:
names = ", ".join(conflicting)
msg = (
@@ -1151,7 +1202,14 @@ def resolve_injected_koopman(
)
raise ValueError(msg)
if (
- isinstance(koopman, SwitchedKoopmanOperator | MixtureKoopmanOperator)
+ isinstance(
+ koopman,
+ (
+ SwitchedKoopmanOperator
+ | MixtureKoopmanOperator
+ | ParametricKoopmanOperator
+ ),
+ )
and dynamics_mode != "discrete"
):
msg = f"Injected {type(koopman).__name__} requires dynamics_mode='discrete'"
@@ -1273,6 +1331,7 @@ def _reject_stochastic_kind(
"continuous_graph",
"switched",
"mixture",
+ "parametric",
}:
msg = (
"dynamics_mode='stochastic' supports koopman='pernode', 'graph', "
@@ -1339,6 +1398,44 @@ def _reject_local_kwargs_unless_global_local(
raise ValueError(msg)
+def _reject_parametric_kwargs_unless_parametric(
+ kind: KoopmanKind,
+ *,
+ koopman_num_modes: int,
+ koopman_parameter_dim: int,
+ koopman_weight_kind: str,
+) -> None:
+ """Reject non-default interpolant kwargs for non-parametric kinds.
+
+ Parameters
+ ----------
+ kind : KoopmanKind
+ Resolved factory kind.
+ koopman_num_modes : int
+ Requested mode count.
+ koopman_parameter_dim : int
+ Requested :math:`d_\\mu`.
+ koopman_weight_kind : str
+ Requested weight kind.
+
+ Raises
+ ------
+ ValueError
+ If interpolant kwargs are set for another kind.
+ """
+ non_default = (
+ koopman_num_modes != DEFAULT_KOOPMAN_NUM_MODES
+ or koopman_parameter_dim != DEFAULT_KOOPMAN_PARAMETER_DIM
+ or koopman_weight_kind != DEFAULT_KOOPMAN_WEIGHT_KIND
+ )
+ if kind != "parametric" and non_default:
+ msg = (
+ "koopman_num_modes / koopman_parameter_dim / "
+ "koopman_weight_kind require koopman='parametric'"
+ )
+ raise ValueError(msg)
+
+
def build_encoder_peers(
encoder: EncoderKind,
*,
@@ -1449,6 +1546,7 @@ def build_koopman(
koopman_auxiliary_hidden_dims: Sequence[int] | None,
koopman_sparsity: str = "dense",
koopman_adjacency: GraphAdjacency = DEFAULT_KOOPMAN_ADJACENCY,
+ koopman_filter_degree: int = DEFAULT_KOOPMAN_FILTER_DEGREE,
koopman_hypergraph_incidence_mode: str = (
DEFAULT_KOOPMAN_HYPERGRAPH_INCIDENCE_MODE
),
@@ -1468,6 +1566,9 @@ def build_koopman(
relation_tying: str = "independent",
basis_size: int | None = None,
latent_dims: Mapping[str, int] | None = None,
+ koopman_num_modes: int = DEFAULT_KOOPMAN_NUM_MODES,
+ koopman_parameter_dim: int = DEFAULT_KOOPMAN_PARAMETER_DIM,
+ koopman_weight_kind: WeightKind = DEFAULT_KOOPMAN_WEIGHT_KIND,
) -> tuple[KoopmanOperatorContract, KoopmanKind]:
"""Construct or validate the model Koopman operator.
@@ -1504,6 +1605,8 @@ def build_koopman(
trainer DDP / multi-GPU training (see :func:`build_koopman_model`).
koopman_adjacency : GraphAdjacency
Neighbor-coupling normalization for networked graph operators.
+ koopman_filter_degree : int
+ Monomial hop degree for discrete ``koopman="graph"``. Default ``1``.
koopman_orbit_partition : Sequence[Sequence[int]] | None
See the function signature / summary for ``koopman_orbit_partition``.
koopman_auto_orbits : bool
@@ -1528,6 +1631,12 @@ def build_koopman(
latent_dims : mapping of str to int or None, optional
Opt-in per-type widths for discrete or continuous
``koopman="hetero_graph"``.
+ koopman_num_modes : int, optional
+ Interpolant mode count for ``koopman="parametric"``.
+ koopman_parameter_dim : int, optional
+ Regime-coordinate width for ``koopman="parametric"``.
+ koopman_weight_kind : {"rbf", "simplex"}, optional
+ Interpolant weights for ``koopman="parametric"``.
koopman_hypergraph_incidence_mode
See signature.
@@ -1574,6 +1683,23 @@ def build_koopman(
f"{{{accepted}}}, got {koopman_adjacency!r}"
)
raise ValueError(msg)
+ filter_degree = validate_filter_degree(koopman_filter_degree)
+ discrete_graph = (
+ injected is None
+ and kind == "graph"
+ and dynamics_mode in {"discrete", "stochastic"}
+ )
+ if (
+ injected is None
+ and not discrete_graph
+ and filter_degree != DEFAULT_KOOPMAN_FILTER_DEGREE
+ ):
+ msg = (
+ "koopman_filter_degree is only meaningful for discrete "
+ f"koopman='graph'; got filter_degree={filter_degree!r} with "
+ f"koopman={kind!r} and dynamics_mode={dynamics_mode!r}"
+ )
+ raise ValueError(msg)
if koopman_hypergraph_incidence_mode not in _HYPERGRAPH_INCIDENCE_MODES:
accepted = ", ".join(sorted(_HYPERGRAPH_INCIDENCE_MODES))
msg = (
@@ -1658,6 +1784,18 @@ def build_koopman(
koopman_local_rank=koopman_local_rank,
koopman_local_hidden_dims=koopman_local_hidden_dims,
)
+ _reject_parametric_kwargs_unless_parametric(
+ kind
+ if injected is None
+ else (
+ "parametric"
+ if isinstance(injected, ParametricKoopmanOperator)
+ else "pernode"
+ ),
+ koopman_num_modes=koopman_num_modes,
+ koopman_parameter_dim=koopman_parameter_dim,
+ koopman_weight_kind=koopman_weight_kind,
+ )
if injected is not None:
# Injection path: local kwargs must stay default (checked above for
@@ -1691,7 +1829,11 @@ def build_koopman(
koopman_orbit_method=koopman_orbit_method,
koopman_symmetry=koopman_symmetry,
koopman_adjacency=koopman_adjacency,
+ koopman_filter_degree=koopman_filter_degree,
koopman_hypergraph_incidence_mode=koopman_hypergraph_incidence_mode,
+ koopman_num_modes=koopman_num_modes,
+ koopman_parameter_dim=koopman_parameter_dim,
+ koopman_weight_kind=koopman_weight_kind,
)
if isinstance(operator, ContinuousGraphKoopmanOperator):
resolved_kind: KoopmanKind = "continuous_graph"
@@ -1709,6 +1851,8 @@ def build_koopman(
resolved_kind = "switched"
elif isinstance(operator, MixtureKoopmanOperator):
resolved_kind = "mixture"
+ elif isinstance(operator, ParametricKoopmanOperator):
+ resolved_kind = "parametric"
else:
resolved_kind = "pernode"
return operator, resolved_kind
@@ -1721,11 +1865,18 @@ def build_koopman(
)
if dynamics_mode == "continuous":
- if kind in {"hypergraph", "global_local", "switched", "mixture", "hodge"}:
+ if kind in {
+ "hypergraph",
+ "global_local",
+ "switched",
+ "mixture",
+ "parametric",
+ "hodge",
+ }:
msg = (
f"koopman={kind!r} requires dynamics_mode='discrete'; "
"continuous hypergraph / global_local / switched / mixture / "
- "hodge operators are not implemented"
+ "parametric / hodge operators are not implemented"
)
raise ValueError(msg)
if kind == "hetero_graph":
@@ -1846,6 +1997,7 @@ def build_koopman(
bilinear_rank=bilinear_rank,
sparsity=koopman_sparsity, # type: ignore[arg-type]
adjacency=koopman_adjacency,
+ filter_degree=filter_degree,
orbit_partition=koopman_orbit_partition,
auto_orbits=koopman_auto_orbits,
orbit_method=koopman_orbit_method,
@@ -1980,6 +2132,33 @@ def build_koopman(
latent_dim=latent_dim,
)
+ if kind == "parametric":
+ if resolved_aux_dims is not None:
+ msg = (
+ "koopman_auxiliary_hidden_dims requires "
+ "dynamics_mode='continuous' and "
+ "koopman_parameterization='auxiliary_spectral'"
+ )
+ raise ValueError(msg)
+ return _finalize_built_koopman(
+ ParametricKoopmanOperator(
+ latent_dim,
+ num_modes=koopman_num_modes,
+ parameter_dim=koopman_parameter_dim,
+ weight_kind=koopman_weight_kind,
+ init_mode=koopman_init_mode,
+ init_scale=koopman_init_scale,
+ parameterization=koopman_parameterization,
+ max_spectral_radius=koopman_max_spectral_radius,
+ control_dim=control_dim,
+ control_mode=control_mode,
+ bilinear_rank=bilinear_rank,
+ ),
+ "parametric",
+ dynamics_mode=dynamics_mode,
+ latent_dim=latent_dim,
+ )
+
if kind == "hodge":
if resolved_aux_dims is not None:
msg = (
diff --git a/src/koopman_graph/model/inference.py b/src/koopman_graph/model/inference.py
index 6d58a7e..4df124b 100644
--- a/src/koopman_graph/model/inference.py
+++ b/src/koopman_graph/model/inference.py
@@ -7,12 +7,18 @@
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
+from typing import TYPE_CHECKING
import torch
from torch import Tensor, nn
from torch_geometric.data import Data, HeteroData
-from koopman_graph.data import SnapshotSequence
+from koopman_graph.data import (
+ GraphSnapshotSequence,
+ HeteroGraphSnapshotSequence,
+ SnapshotSequence,
+ resolve_sequence,
+)
from koopman_graph.graph_utils import (
autoregressive_latent_rollout,
hold_last_topology_at,
@@ -20,7 +26,12 @@
snapshot_hyperedge_index,
snapshot_hyperedge_weight,
)
-from koopman_graph.metrics import EvaluationResult, evaluate_forecast
+from koopman_graph.metrics import (
+ EvaluationResult,
+ _dense_pernode_discrete_k,
+ _encode_invariance_latents,
+ evaluate_forecast,
+)
from koopman_graph.operators import (
ContinuousGraphKoopmanOperator,
ContinuousHeteroGraphKoopmanOperator,
@@ -39,6 +50,9 @@
from .timing import resolve_time_increments, validate_uniform_discrete_increments
from .validation import validate_controls
+if TYPE_CHECKING:
+ from koopman_graph.identification import SubspaceInvarianceReport
+
EncodeRolloutOriginFn = Callable[
...,
tuple[Tensor, Tensor, Tensor | None],
@@ -304,6 +318,9 @@ def latent_decode_rollout(
history: Sequence[Data] | None = None,
hyperedge_index: Tensor | None = None,
hyperedge_weight: Tensor | None = None,
+ topology_policy: str = "auto",
+ topology_model: nn.Module | None = None,
+ parameters: Sequence[Tensor] | None = None,
) -> list[tuple[Tensor, Tensor, Tensor | None]]:
"""Autoregressively advance latent state and decode for multiple steps.
@@ -332,6 +349,16 @@ def latent_decode_rollout(
Static hyperedge incidence for hypergraph operators. When omitted and
``x_or_data`` is a ``Data`` snapshot, incidence is read from the
snapshot fields.
+ topology_policy : {"auto", "recursive", "hold_last"}, optional
+ Rollout topology policy. ``auto`` uses predicted
+ :math:`\\hat A_{t+1}` when ``topology_model`` has a recursive
+ graph-state head; otherwise hold-last. Oracle
+ ``future_topologies`` still wins.
+ topology_model : nn.Module or None, optional
+ Model exposing ``predicted_topology`` / ``graph_dynamics``.
+ ``None`` keeps hold-last.
+ parameters : sequence of Tensor or None, optional
+ Per-step regime coordinates for parametric interpolants.
Returns
-------
@@ -352,6 +379,9 @@ def latent_decode_rollout(
if step_deltas is not None and len(step_deltas) != steps:
msg = f"expected {steps} step_deltas for rollout, got {len(step_deltas)}"
raise ValueError(msg)
+ if parameters is not None and len(parameters) != steps:
+ msg = f"expected {steps} parameters for rollout, got {len(parameters)}"
+ raise ValueError(msg)
z, edge_index, edge_weight = encode_rollout_origin(
x_or_data,
@@ -386,18 +416,32 @@ def latent_decode_rollout(
)
control_at = None if controls is None else (lambda step: controls[step])
+ parameters_at = None if parameters is None else (lambda step: parameters[step])
delta_t_at = None if step_deltas is None else (lambda step: step_deltas[step])
+ if topology_model is None:
+ topology_at = hold_last_topology_at(
+ edge_index,
+ edge_weight,
+ future_topologies,
+ )
+ else:
+ from koopman_graph.nn.predicted_topology import resolve_rollout_topology_at
+
+ topology_at = resolve_rollout_topology_at(
+ topology_model,
+ edge_index,
+ edge_weight,
+ future_topologies,
+ topology_policy,
+ )
return autoregressive_latent_rollout(
koopman,
decoder,
z,
steps=steps,
- topology_at=hold_last_topology_at(
- edge_index,
- edge_weight,
- future_topologies,
- ),
+ topology_at=topology_at,
control_at=control_at,
+ parameters_at=parameters_at,
delta_t_at=delta_t_at,
presence_at=presence_at,
initial_features=initial_features,
@@ -421,6 +465,8 @@ def predict_snapshots(
future_topologies: Sequence[Data] | None = None,
future_presence: Tensor | Sequence[Tensor] | None = None,
history: Sequence[Data] | None = None,
+ topology_policy: str = "auto",
+ parameters: Sequence[Tensor] | None = None,
) -> list[Data]:
"""Run an eval-mode discrete-step rollout and pack ``Data`` snapshots.
@@ -448,6 +494,10 @@ def predict_snapshots(
See the function signature / summary for ``future_topologies``.
future_presence : Tensor | Sequence[Tensor] | None
Optional per-step presence schedule for the inactive-node hold policy.
+ topology_policy : {"auto", "recursive", "hold_last"}, optional
+ Forwarded to ``rollout_fn``.
+ parameters : sequence of Tensor or None, optional
+ Per-step regime coordinates forwarded to ``rollout_fn``.
Returns
-------
@@ -467,6 +517,8 @@ def predict_snapshots(
future_topologies=future_topologies,
future_presence=future_presence,
history=history,
+ topology_policy=topology_policy,
+ parameters=parameters,
)
finally:
model.train(was_training)
@@ -487,6 +539,7 @@ def predict_at_snapshots(
controls: Sequence[Tensor] | None = None,
future_topologies: Sequence[Data] | None = None,
future_presence: Tensor | Sequence[Tensor] | None = None,
+ topology_policy: str = "auto",
) -> list[Data]:
"""Forecast snapshots at arbitrary query times / step deltas.
@@ -518,6 +571,8 @@ def predict_at_snapshots(
See the function signature / summary for ``controls``.
future_presence : Tensor | Sequence[Tensor] | None
Optional per-step presence schedule for the inactive-node hold policy.
+ topology_policy : {"auto", "recursive", "hold_last"}, optional
+ Forwarded to ``rollout_fn``.
Returns
-------
@@ -547,6 +602,7 @@ def predict_at_snapshots(
future_topologies=future_topologies,
future_presence=future_presence,
step_deltas=increments,
+ topology_policy=topology_policy,
)
finally:
model.train(was_training)
@@ -560,6 +616,8 @@ def evaluate_sequence(
horizons: Sequence[int] = (3, 6, 12),
start_indices: Sequence[int] | None = None,
include_resdmd: bool = False,
+ include_invariance: bool = False,
+ topology_policy: str = "auto",
) -> EvaluationResult:
"""Evaluate multi-horizon forecast accuracy on a snapshot sequence.
@@ -576,6 +634,10 @@ def evaluate_sequence(
Forecast-origin indices. When ``None``, uses every valid origin.
include_resdmd : bool, optional
Forwarded to :func:`~koopman_graph.metrics.evaluate_forecast`.
+ include_invariance : bool, optional
+ Forwarded to :func:`~koopman_graph.metrics.evaluate_forecast`.
+ topology_policy : {"auto", "recursive", "hold_last"}, optional
+ Forwarded to :func:`~koopman_graph.metrics.evaluate_forecast`.
Returns
-------
@@ -588,4 +650,70 @@ def evaluate_sequence(
horizons=horizons,
start_indices=start_indices,
include_resdmd=include_resdmd,
+ include_invariance=include_invariance,
+ topology_policy=topology_policy,
)
+
+
+def sequence_subspace_invariance(
+ model: object,
+ sequence: SnapshotSequence | Sequence[Data] | Sequence[HeteroData],
+ *,
+ held_out: bool = True,
+) -> SubspaceInvarianceReport:
+ """Encode snapshots and report finite-sample subspace leakage.
+
+ Lazy-imports :mod:`koopman_graph.identification` so
+ ``import koopman_graph.model`` does not load that package.
+
+ Parameters
+ ----------
+ model
+ Homogeneous discrete per-node :class:`~koopman_graph.model.GraphKoopmanModel`.
+ sequence : GraphSnapshotSequence or sequence of Data
+ Snapshots to encode. Hetero sequences raise.
+ held_out : bool, optional
+ Use the last half of the time axis for both the projector and the
+ expectation. Default is ``True``.
+
+ Returns
+ -------
+ SubspaceInvarianceReport
+ Leakage :math:`\\eta`, sample count, rank, and ``held_out``.
+
+ Raises
+ ------
+ ValueError
+ If the sequence is hetero or the operator is not a dense discrete
+ per-node map.
+ TypeError
+ If ``held_out`` is not a ``bool``.
+ """
+ if isinstance(sequence, HeteroGraphSnapshotSequence):
+ msg = "subspace invariance does not support HeteroGraphSnapshotSequence"
+ raise ValueError(msg)
+ if not isinstance(sequence, GraphSnapshotSequence):
+ if (
+ isinstance(sequence, Sequence)
+ and not isinstance(sequence, (str, bytes))
+ and sequence
+ and isinstance(sequence[0], HeteroData)
+ ):
+ msg = "subspace invariance does not support HeteroData sequences"
+ raise ValueError(msg)
+ sequence = resolve_sequence(sequence) # type: ignore[arg-type]
+ matrix = _dense_pernode_discrete_k(model) # type: ignore[arg-type]
+ was_training = bool(getattr(model, "training", False))
+ eval_fn = getattr(model, "eval", None)
+ train_fn = getattr(model, "train", None)
+ if eval_fn is not None:
+ eval_fn()
+ try:
+ with torch.no_grad():
+ encodings = _encode_invariance_latents(model, sequence) # type: ignore[arg-type]
+ finally:
+ if train_fn is not None:
+ train_fn(was_training)
+ from koopman_graph.identification import subspace_invariance_report
+
+ return subspace_invariance_report(encodings, matrix, held_out=held_out)
diff --git a/src/koopman_graph/model/online_adaptation.py b/src/koopman_graph/model/online_adaptation.py
index ded9231..24492ed 100644
--- a/src/koopman_graph/model/online_adaptation.py
+++ b/src/koopman_graph/model/online_adaptation.py
@@ -14,7 +14,7 @@
from torch import Tensor, nn
from torch_geometric.data import Data
-from koopman_graph.adaptation import AdaptationStepResult, RecursiveKoopmanAdapter
+from koopman_graph.adaptation.rls import AdaptationStepResult, RecursiveKoopmanAdapter
from koopman_graph.operators.contract import KoopmanOperatorContract
from koopman_graph.protocols import DynamicsMode
diff --git a/src/koopman_graph/model/timing.py b/src/koopman_graph/model/timing.py
index 7751aa4..8d8d491 100644
--- a/src/koopman_graph/model/timing.py
+++ b/src/koopman_graph/model/timing.py
@@ -81,7 +81,10 @@ def validate_uniform_discrete_increments(
Raises
------
ValueError
- If any increment differs from ``time_step``.
+ If any increment differs from ``time_step``. Discrete models
+ reject gaps. Use ``dynamics_mode='continuous'`` for irregular
+ sampling, or supply derivatives to generator EDMD; irregular
+ timestamps do not create :math:`L`.
"""
tolerance = max(1e-6, 1e-4 * time_step)
for value in increments:
diff --git a/src/koopman_graph/mpc/__init__.py b/src/koopman_graph/mpc/__init__.py
index 48f5d69..d27b148 100644
--- a/src/koopman_graph/mpc/__init__.py
+++ b/src/koopman_graph/mpc/__init__.py
@@ -9,6 +9,13 @@
``qp``
Condensed QP assembly and OSQP solve helpers (call-site import of the
``[mpc]`` extra).
+``tube``
+ :class:`~koopman_graph.mpc.TubeKoopmanMPC` residual-tube tightening
+ from conformal or ensemble radii on **additive discrete** plants;
+ :class:`~koopman_graph.mpc.TubeMPCReport` records violation rate,
+ feasibility rate, and cost. Not a chance-constraint solver and not
+ a recursive-feasibility / Lyapunov certificate
+ (``Zhang2022TubeMPC``).
Power-user module: import as ``koopman_graph.mpc``. Types are intentionally
omitted from root ``koopman_graph.__all__`` (see architecture docs).
@@ -20,16 +27,29 @@
accepts a calibrated :class:`~koopman_graph.uq.ConformalKoopmanUQ` and
shrinks output boxes by per-horizon half-widths (inputs unchanged;
margins are calibrated, not a formal closed-loop guarantee).
+:class:`~koopman_graph.mpc.TubeKoopmanMPC` is additive-only and reports
+constraint-violation rate rather than tracking MSE alone.
References
----------
Korda, M. and Mezić, I. (2018). Linear predictors for nonlinear dynamical
systems: Koopman operator meets model predictive control. *Automatica*, 93,
149–160.
+Zhang, X., Pan, W., Scattolini, R., Yu, S., and Xu, X. (2022). Robust
+tube-based model predictive control with Koopman operators.
+*Automatica*, 137, 110114.
"""
from koopman_graph.mpc.controller import KoopmanMPC
+from koopman_graph.mpc.tube import (
+ TubeKoopmanMPC,
+ TubeMPCReport,
+ ensemble_residual_radii,
+)
__all__ = [
"KoopmanMPC",
+ "TubeKoopmanMPC",
+ "TubeMPCReport",
+ "ensemble_residual_radii",
]
diff --git a/src/koopman_graph/mpc/tube.py b/src/koopman_graph/mpc/tube.py
new file mode 100644
index 0000000..49967af
--- /dev/null
+++ b/src/koopman_graph/mpc/tube.py
@@ -0,0 +1,831 @@
+"""Residual-tube tightening for additive discrete Koopman-MPC.
+
+:class:`TubeKoopmanMPC` shrinks nominal output boxes by conformal
+quantiles or ensemble residual radii, then solves the same condensed
+QP as :class:`~koopman_graph.mpc.KoopmanMPC`. Closed-loop
+:meth:`~TubeKoopmanMPC.evaluate` reports constraint-violation rate,
+feasibility rate, and quadratic stage cost against the **nominal**
+boxes.
+
+This is a residual-tube helper after Zhang, Pan, Scattolini, Yu, and
+Xu, *Automatica* 137:110114 (2022), doi:10.1016/j.automatica.2021.110114
+(``Zhang2022TubeMPC``). Local decoder linearization remains. The report
+is not a recursive-feasibility or Lyapunov closed-loop certificate, and
+the helper is not a chance-constraint solver. Zhang et al. prove
+closed-loop robustness for an r-KMPC scheme with an offline nonlinear
+ancillary law; this MVP does not implement that ancillary controller
+or those proofs.
+
+Additive discrete per-node operators only. Bilinear, networked, and
+continuous plants are refused. Named chance-constraint language
+requires :class:`~koopman_graph.uq.JointCoverageSpec` with the shipped
+``per_node_marginal`` / ``block="none"`` pair.
+
+This module must not import :mod:`koopman_graph.identification`.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+import numpy as np
+import torch
+from numpy.typing import NDArray
+from torch import Tensor
+from torch_geometric.data import Data
+
+from koopman_graph.model import GraphKoopmanModel
+from koopman_graph.mpc.controller import (
+ KoopmanMPC,
+ ReferenceLike,
+ _as_numpy,
+ _conformal_stage_margins,
+ _decoder_jacobian,
+ _mean_latent,
+ _resolve_reference,
+ _validate_constraint_tightening,
+ _validate_mpc_model,
+)
+from koopman_graph.mpc.qp import assemble_condensed_mpc, solve_dense_qp
+from koopman_graph.uq.common import PredictionInterval, snapshot_with_features
+from koopman_graph.uq.coverage import JointCoverageSpec, require_shipped_coverage
+
+PlantFn = Callable[[Data, Tensor], Data]
+
+__all__ = [
+ "TubeKoopmanMPC",
+ "TubeMPCReport",
+ "ensemble_residual_radii",
+]
+
+
+def ensemble_residual_radii(interval: PredictionInterval) -> Tensor:
+ """Pool per-step ensemble half-widths from a prediction interval.
+
+ Parameters
+ ----------
+ interval : PredictionInterval
+ Homogeneous mean / lower / upper bands. Each step uses
+ ``(upper - lower) / 2``; the scalar radius is the maximum over
+ nodes and features (same Minkowski radius as an aggregate
+ conformal quantile).
+
+ Returns
+ -------
+ Tensor
+ Non-negative radii with shape ``(H,)`` for ``H`` forecast steps.
+
+ Raises
+ ------
+ TypeError
+ If a band lacks homogeneous ``x`` node features.
+ ValueError
+ If the interval is empty or a half-width is negative.
+ """
+ if len(interval.lower) < 1 or len(interval.upper) < 1:
+ msg = "PredictionInterval must contain at least one forecast step"
+ raise ValueError(msg)
+ if len(interval.lower) != len(interval.upper):
+ msg = (
+ "PredictionInterval lower/upper lengths differ: "
+ f"{len(interval.lower)} vs {len(interval.upper)}"
+ )
+ raise ValueError(msg)
+ radii: list[Tensor] = []
+ for lower, upper in zip(interval.lower, interval.upper, strict=True):
+ lower_x = getattr(lower, "x", None)
+ upper_x = getattr(upper, "x", None)
+ if lower_x is None or upper_x is None:
+ msg = (
+ "ensemble residual radii require homogeneous node features "
+ "on interval.lower / interval.upper"
+ )
+ raise TypeError(msg)
+ half = 0.5 * (upper_x - lower_x)
+ if bool(torch.any(half < -1e-12)):
+ msg = "ensemble interval half-widths must be non-negative"
+ raise ValueError(msg)
+ radii.append(half.reshape(-1).max().clamp(min=0.0))
+ return torch.stack(radii)
+
+
+def _horizon_radii(
+ residual_source: object,
+ *,
+ horizon: int,
+) -> NDArray[np.float64]:
+ """Coerce ensemble radii to a length-``H`` non-negative vector.
+
+ Parameters
+ ----------
+ residual_source : Tensor or ndarray
+ Scalar, ``(H,)``, or ``(H, F)`` half-widths. A trailing feature
+ axis is reduced by a max so the tube is a scalar Minkowski
+ radius per stage.
+ horizon : int
+ MPC horizon ``H``.
+
+ Returns
+ -------
+ ndarray
+ Radii with shape ``(H,)``.
+
+ Raises
+ ------
+ ValueError
+ If the shape is too short, has the wrong rank, or is negative.
+ """
+ arr = np.asarray(residual_source, dtype=np.float64)
+ if arr.ndim == 0:
+ radii = np.full((horizon,), float(arr), dtype=np.float64)
+ elif arr.ndim == 1:
+ if arr.shape[0] < horizon:
+ msg = (
+ f"ensemble residual radii must cover horizon={horizon}, "
+ f"got shape {arr.shape}"
+ )
+ raise ValueError(msg)
+ radii = arr[:horizon].astype(np.float64, copy=True)
+ elif arr.ndim == 2:
+ if arr.shape[0] < horizon:
+ msg = (
+ f"ensemble residual radii must cover horizon={horizon}, "
+ f"got shape {arr.shape}"
+ )
+ raise ValueError(msg)
+ radii = arr[:horizon].max(axis=1).astype(np.float64, copy=True)
+ else:
+ msg = (
+ "ensemble residual radii must be a scalar, shape (H,), or "
+ f"(H, F); got shape {arr.shape}"
+ )
+ raise ValueError(msg)
+ if np.any(radii < -1e-12):
+ msg = "ensemble residual radii must be non-negative"
+ raise ValueError(msg)
+ return np.maximum(radii, 0.0)
+
+
+def _stage_margins_from_radii(
+ radii: NDArray[np.floating],
+ *,
+ horizon: int,
+) -> NDArray[np.float64]:
+ """Map per-horizon radii to MPC stages ``h = 0..H``.
+
+ Parameters
+ ----------
+ radii : ndarray
+ Non-negative half-widths with shape ``(H,)``.
+ horizon : int
+ MPC horizon ``H``.
+
+ Returns
+ -------
+ ndarray
+ Margins with shape ``(H + 1,)``. Stage ``0`` is ``0``; stages
+ ``1..H`` use ``radii[h - 1]``.
+ """
+ vec = np.asarray(radii, dtype=np.float64).reshape(-1)
+ margins = np.zeros(horizon + 1, dtype=np.float64)
+ margins[1:] = vec[:horizon]
+ return margins
+
+
+def _resolve_stage_margins(
+ residual_source: object,
+ *,
+ model: GraphKoopmanModel,
+ horizon: int,
+ y_min: NDArray[np.float64],
+ y_max: NDArray[np.float64],
+) -> NDArray[np.float64]:
+ """Build per-stage output margins from conformal or ensemble residuals.
+
+ Parameters
+ ----------
+ residual_source : ConformalKoopmanUQ or PredictionInterval or array
+ Calibrated conformal wrapper on ``model``, a homogeneous
+ prediction interval, or explicit radii.
+ model : GraphKoopmanModel
+ Plant model passed to :class:`TubeKoopmanMPC`.
+ horizon : int
+ MPC horizon ``H``.
+ y_min, y_max : ndarray
+ Nominal output boxes (required so margins have something to
+ shrink).
+
+ Returns
+ -------
+ ndarray
+ Non-negative margins with shape ``(H + 1,)``.
+
+ Raises
+ ------
+ TypeError
+ If ``residual_source`` is not a supported residual object.
+ """
+ # Import at call time so mpc ↔ conformal stays a soft peer link.
+ from koopman_graph.uq import ConformalKoopmanUQ
+
+ if isinstance(residual_source, ConformalKoopmanUQ):
+ tightening = _validate_constraint_tightening(
+ residual_source,
+ model=model,
+ horizon=horizon,
+ y_min=y_min,
+ y_max=y_max,
+ )
+ assert tightening is not None
+ return _conformal_stage_margins(tightening, horizon=horizon)
+ if isinstance(residual_source, PredictionInterval):
+ radii = ensemble_residual_radii(residual_source)
+ return _stage_margins_from_radii(
+ _horizon_radii(radii, horizon=horizon),
+ horizon=horizon,
+ )
+ if isinstance(residual_source, Tensor | np.ndarray):
+ return _stage_margins_from_radii(
+ _horizon_radii(residual_source, horizon=horizon),
+ horizon=horizon,
+ )
+ msg = (
+ "residual_source must be a calibrated ConformalKoopmanUQ, "
+ "a PredictionInterval, or non-negative residual radii, "
+ f"got {type(residual_source).__name__}"
+ )
+ raise TypeError(msg)
+
+
+def _mean_decoded_numpy(model: GraphKoopmanModel, graph: Data) -> NDArray[np.float64]:
+ """Decode a snapshot and reduce node features to a mean output.
+
+ Parameters
+ ----------
+ model : GraphKoopmanModel
+ Fitted model whose decoder maps latents to features.
+ graph : Data
+ Current snapshot.
+
+ Returns
+ -------
+ ndarray
+ Mean decoded features with shape ``(F,)``.
+ """
+ with torch.no_grad():
+ z = model.encode(graph)
+ decoded = model.decoder(
+ z,
+ graph.edge_index,
+ getattr(graph, "edge_weight", None),
+ )
+ return decoded.mean(dim=0).detach().cpu().numpy().astype(np.float64)
+
+
+def _outside_nominal_box(
+ y: NDArray[np.floating],
+ y_min: NDArray[np.floating],
+ y_max: NDArray[np.floating],
+ *,
+ atol: float = 1e-6,
+) -> bool:
+ """Return whether any coordinate leaves the nominal output box.
+
+ Parameters
+ ----------
+ y : ndarray
+ Mean decoded output with shape ``(F,)``.
+ y_min, y_max : ndarray
+ Nominal box bounds with shape ``(F,)``.
+ atol : float, optional
+ Absolute slack for floating-point comparisons.
+
+ Returns
+ -------
+ bool
+ ``True`` when any coordinate is outside ``[y_min, y_max]``.
+ """
+ vec = np.asarray(y, dtype=np.float64).reshape(-1)
+ lo = np.asarray(y_min, dtype=np.float64).reshape(-1)
+ hi = np.asarray(y_max, dtype=np.float64).reshape(-1)
+ return bool(np.any(vec < lo - atol) or np.any(vec > hi + atol))
+
+
+def _quadratic_stage_cost(
+ y: NDArray[np.floating],
+ reference: NDArray[np.floating],
+ u: NDArray[np.floating],
+ q_cost: NDArray[np.floating],
+ r_cost: NDArray[np.floating],
+) -> float:
+ """Scalar tracking-plus-input cost for one applied stage.
+
+ Parameters
+ ----------
+ y : ndarray
+ Mean decoded output with shape ``(F,)``.
+ reference : ndarray
+ One-step output target with shape ``(F,)``.
+ u : ndarray
+ Applied control with shape ``(C,)``.
+ q_cost, r_cost : ndarray
+ PSD output and input weights.
+
+ Returns
+ -------
+ float
+ ``(y - r)^T Q (y - r) + u^T R u``.
+ """
+ err = np.asarray(y, dtype=np.float64).reshape(-1) - np.asarray(
+ reference, dtype=np.float64
+ ).reshape(-1)
+ ctrl = np.asarray(u, dtype=np.float64).reshape(-1)
+ q = np.asarray(q_cost, dtype=np.float64)
+ r = np.asarray(r_cost, dtype=np.float64)
+ return float(err @ q @ err + ctrl @ r @ ctrl)
+
+
+def _fallback_control(controller: KoopmanMPC) -> Tensor:
+ """Zero input clipped to the declared control box.
+
+ Parameters
+ ----------
+ controller : KoopmanMPC
+ Inner additive plant used for bound shapes.
+
+ Returns
+ -------
+ Tensor
+ Control with shape ``(control_dim,)``.
+ """
+ control_dim = controller._operator.control_dim
+ first = np.zeros(control_dim, dtype=np.float64)
+ if controller.u_min is not None:
+ first = np.maximum(first, controller.u_min)
+ if controller.u_max is not None:
+ first = np.minimum(first, controller.u_max)
+ return torch.tensor(first, dtype=torch.float32)
+
+
+def _default_model_plant(
+ model: GraphKoopmanModel,
+ graph: Data,
+ control: Tensor,
+) -> Data:
+ """Advance the fitted discrete plant by one controlled step.
+
+ Parameters
+ ----------
+ model : GraphKoopmanModel
+ Discrete controlled model.
+ graph : Data
+ Current snapshot.
+ control : Tensor
+ Global control with shape ``(control_dim,)``.
+
+ Returns
+ -------
+ Data
+ Decoded snapshot after ``koopman.advance``.
+ """
+ with torch.no_grad():
+ z = model.encode(graph)
+ z_next = model.koopman.advance(
+ z,
+ control=control,
+ edge_index=graph.edge_index,
+ edge_weight=getattr(graph, "edge_weight", None),
+ )
+ decoded = model.decoder(
+ z_next,
+ graph.edge_index,
+ getattr(graph, "edge_weight", None),
+ )
+ return snapshot_with_features(graph, decoded)
+
+
+@dataclass(frozen=True)
+class TubeMPCReport:
+ """Closed-loop residual-tube evaluation against nominal boxes.
+
+ Attributes
+ ----------
+ violation_rate : float
+ Fraction of steps whose mean decoded output leaves the
+ **nominal** ``y_min`` / ``y_max`` box (any coordinate). This is
+ a constraint-violation rate, not tracking MSE.
+ feasibility_rate : float
+ Fraction of steps whose tightened QP returned a solution.
+ cost : float
+ Mean quadratic stage cost on feasible steps. ``nan`` when no
+ step was feasible.
+ n_steps : int
+ Closed-loop length.
+ n_feasible : int
+ Number of successful QP solves.
+ n_violations : int
+ Number of nominal-box violations.
+ coverage : JointCoverageSpec
+ Named estimand attached to the residual source. Shipped MVP is
+ ``per_node_marginal`` / ``block="none"``.
+ """
+
+ violation_rate: float
+ feasibility_rate: float
+ cost: float
+ n_steps: int
+ n_feasible: int
+ n_violations: int
+ coverage: JointCoverageSpec
+
+ def __post_init__(self) -> None:
+ """Validate rate / count consistency.
+
+ Raises
+ ------
+ ValueError
+ If counts or rates are inconsistent.
+ """
+ if self.n_steps < 1:
+ msg = f"n_steps must be >= 1, got {self.n_steps}"
+ raise ValueError(msg)
+ if self.n_feasible < 0 or self.n_feasible > self.n_steps:
+ msg = f"n_feasible must lie in [0, {self.n_steps}], got {self.n_feasible}"
+ raise ValueError(msg)
+ if self.n_violations < 0 or self.n_violations > self.n_steps:
+ msg = (
+ f"n_violations must lie in [0, {self.n_steps}], got {self.n_violations}"
+ )
+ raise ValueError(msg)
+ expected_feas = self.n_feasible / self.n_steps
+ expected_viol = self.n_violations / self.n_steps
+ if abs(self.feasibility_rate - expected_feas) > 1e-12:
+ msg = "feasibility_rate must equal n_feasible / n_steps"
+ raise ValueError(msg)
+ if abs(self.violation_rate - expected_viol) > 1e-12:
+ msg = "violation_rate must equal n_violations / n_steps"
+ raise ValueError(msg)
+
+
+class TubeKoopmanMPC:
+ """Tube-tightened additive discrete Koopman-MPC.
+
+ Residual radii come from a calibrated
+ :class:`~koopman_graph.uq.ConformalKoopmanUQ`, a homogeneous
+ :class:`~koopman_graph.uq.PredictionInterval`, or an explicit
+ ``(H,)`` / ``(H, F)`` tensor. The inner QP uses the eroded boxes;
+ :meth:`evaluate` scores the **nominal** boxes.
+
+ This is not Zhang et al. r-KMPC (offline nonlinear feedback plus a
+ closed-loop robustness proof; ``Zhang2022TubeMPC``). Local
+ linearization honesty is unchanged. Simultaneous / event coverage
+ targets raise.
+
+ Parameters
+ ----------
+ model : GraphKoopmanModel
+ Fitted model with a discrete per-node additive
+ ``KoopmanOperator``.
+ horizon : int
+ Prediction horizon ``H >= 1``.
+ Q, R : Tensor or ndarray
+ PSD stage output and input cost matrices.
+ residual_source : ConformalKoopmanUQ or PredictionInterval or array
+ Calibrated conformal wrapper, ensemble interval, or explicit
+ non-negative residual radii.
+ y_min, y_max : Tensor or ndarray
+ Nominal output boxes. Both are required so violations have a
+ declared set and the tube has a set to erode.
+ Qf : Tensor or ndarray or None, optional
+ Terminal output cost (defaults to ``Q``).
+ u_min, u_max : Tensor or ndarray or None, optional
+ Box bounds on each control ``u_h``.
+ coverage : JointCoverageSpec or None, optional
+ Named chance-constraint estimand. Default is
+ ``per_node_marginal`` / ``block="none"``. Other targets raise.
+
+ References
+ ----------
+ Zhang, X., Pan, W., Scattolini, R., Yu, S., and Xu, X. (2022).
+ Robust tube-based model predictive control with Koopman operators.
+ *Automatica*, 137, 110114.
+ https://doi.org/10.1016/j.automatica.2021.110114
+ (``Zhang2022TubeMPC``; lineage for residual tubes — not a
+ certificate that this helper inherits their closed-loop proofs.)
+ """
+
+ def __init__(
+ self,
+ model: GraphKoopmanModel,
+ horizon: int,
+ Q: Tensor | NDArray[np.floating],
+ R: Tensor | NDArray[np.floating],
+ *,
+ residual_source: object,
+ y_min: Tensor | NDArray[np.floating],
+ y_max: Tensor | NDArray[np.floating],
+ Qf: Tensor | NDArray[np.floating] | None = None,
+ u_min: Tensor | NDArray[np.floating] | None = None,
+ u_max: Tensor | NDArray[np.floating] | None = None,
+ coverage: JointCoverageSpec | None = None,
+ ) -> None:
+ """Initialize residual-tube MPC from an additive discrete plant.
+
+ Parameters
+ ----------
+ model, horizon, Q, R, residual_source, y_min, y_max, Qf, u_min,
+ u_max, coverage
+ See the class docstring.
+ """
+ operator = _validate_mpc_model(model)
+ if operator.control_mode != "additive":
+ msg = (
+ "TubeKoopmanMPC requires control_mode='additive' "
+ f"(got {operator.control_mode!r}); bilinear sequential "
+ "linearization is refused"
+ )
+ raise ValueError(msg)
+ self.coverage = require_shipped_coverage(
+ JointCoverageSpec() if coverage is None else coverage
+ )
+ self._controller = KoopmanMPC(
+ model,
+ horizon,
+ Q,
+ R,
+ Qf=Qf,
+ u_min=u_min,
+ u_max=u_max,
+ y_min=y_min,
+ y_max=y_max,
+ )
+ if self._controller.y_min is None or self._controller.y_max is None:
+ msg = "TubeKoopmanMPC requires y_min and y_max"
+ raise ValueError(msg)
+ self._stage_margins = _resolve_stage_margins(
+ residual_source,
+ model=model,
+ horizon=self._controller.horizon,
+ y_min=self._controller.y_min,
+ y_max=self._controller.y_max,
+ )
+
+ @property
+ def model(self) -> GraphKoopmanModel:
+ """Fitted plant composed by the inner additive controller.
+
+ Returns
+ -------
+ GraphKoopmanModel
+ The model passed at construction.
+ """
+ return self._controller.model
+
+ @property
+ def horizon(self) -> int:
+ """Prediction horizon ``H``.
+
+ Returns
+ -------
+ int
+ Horizon used by the inner QP.
+ """
+ return self._controller.horizon
+
+ def solve(
+ self,
+ current_graph: Data,
+ reference: ReferenceLike,
+ ) -> Tensor:
+ """Compute the first receding-horizon control on the tightened boxes.
+
+ Parameters
+ ----------
+ current_graph : Data
+ Current snapshot (features + topology).
+ reference : Tensor or sequence
+ Constant ``(F,)`` reference or trajectory ``(H+1, F)``.
+
+ Returns
+ -------
+ Tensor
+ Control action with shape ``(control_dim,)``.
+
+ Raises
+ ------
+ ImportError
+ If OSQP (``[mpc]``) is not installed.
+ RuntimeError
+ If the tightened QP is infeasible.
+ """
+ controller = self._controller
+ with torch.no_grad():
+ z = controller.model.encode(current_graph)
+ mean_z = _mean_latent(z)
+ edges = current_graph.edge_index
+ weights = getattr(current_graph, "edge_weight", None)
+ c_mat = _decoder_jacobian(
+ controller.model,
+ mean_z,
+ num_nodes=z.shape[0],
+ edge_index=edges,
+ edge_weight=weights,
+ )
+ a_mat, b_mat = controller._plant_matrices()
+ refs = _resolve_reference(
+ reference, horizon=controller.horizon, out_dim=c_mat.shape[0]
+ )
+ p_mat, q_vec, a_ineq, l_vec, u_vec = assemble_condensed_mpc(
+ a_mat=a_mat,
+ b_mat=b_mat,
+ c_mat=c_mat,
+ x0=mean_z.detach().cpu().numpy().astype(np.float64),
+ references=refs,
+ q_cost=controller.Q,
+ r_cost=controller.R,
+ qf_cost=controller.Qf,
+ u_min=controller.u_min,
+ u_max=controller.u_max,
+ y_min=controller.y_min,
+ y_max=controller.y_max,
+ stage_margins=self._stage_margins,
+ )
+ u_star = solve_dense_qp(p_mat, q_vec, a_ineq, l_vec, u_vec)
+ first = u_star[: controller._operator.control_dim].copy()
+ if controller.u_min is not None:
+ first = np.maximum(first, controller.u_min)
+ if controller.u_max is not None:
+ first = np.minimum(first, controller.u_max)
+ return torch.tensor(first, dtype=torch.float32)
+
+ def rollout(
+ self,
+ initial_graph: Data,
+ reference: ReferenceLike,
+ steps: int,
+ *,
+ plant: PlantFn | None = None,
+ ) -> list[Data]:
+ """Closed-loop receding-horizon simulation on a discrete plant.
+
+ Infeasible QP solves apply a clipped-zero fallback control so
+ the episode continues. Prefer :meth:`evaluate` when the
+ constraint-violation rate is the quantity of interest.
+
+ Parameters
+ ----------
+ initial_graph : Data
+ Episode origin.
+ reference : Tensor or sequence
+ Tracking reference (constant or trajectory).
+ steps : int
+ Number of closed-loop steps.
+ plant : callable or None, optional
+ ``plant(graph, u) -> next_graph``. Default advances the
+ fitted model.
+
+ Returns
+ -------
+ list of Data
+ Decoded snapshots after each applied control.
+
+ Raises
+ ------
+ ValueError
+ If ``steps < 1``.
+ """
+ report_plant = plant
+ snapshots, _report = self._closed_loop(
+ initial_graph,
+ reference,
+ steps,
+ plant=report_plant,
+ )
+ return snapshots
+
+ def evaluate(
+ self,
+ initial_graph: Data,
+ reference: ReferenceLike,
+ steps: int,
+ *,
+ plant: PlantFn | None = None,
+ ) -> TubeMPCReport:
+ """Closed-loop violation, feasibility, and cost on nominal boxes.
+
+ Parameters
+ ----------
+ initial_graph : Data
+ Episode origin.
+ reference : Tensor or sequence
+ Tracking reference (constant or trajectory).
+ steps : int
+ Number of closed-loop steps.
+ plant : callable or None, optional
+ ``plant(graph, u) -> next_graph``. Default advances the
+ fitted model. Use a mismatched plant when the residual
+ tube is meant to absorb a declared disturbance.
+
+ Returns
+ -------
+ TubeMPCReport
+ Constraint-violation rate, feasibility rate, and mean
+ feasible-stage cost. Not tracking MSE alone, and not a
+ recursive-feasibility certificate.
+
+ Raises
+ ------
+ ValueError
+ If ``steps < 1``.
+ """
+ _snapshots, report = self._closed_loop(
+ initial_graph,
+ reference,
+ steps,
+ plant=plant,
+ )
+ return report
+
+ def _closed_loop(
+ self,
+ initial_graph: Data,
+ reference: ReferenceLike,
+ steps: int,
+ *,
+ plant: PlantFn | None,
+ ) -> tuple[list[Data], TubeMPCReport]:
+ """Run one closed-loop episode and accumulate the tube report.
+
+ Parameters
+ ----------
+ initial_graph : Data
+ Episode origin.
+ reference : Tensor or sequence
+ Tracking reference (constant or trajectory).
+ steps : int
+ Number of closed-loop steps.
+ plant : callable or None
+ Optional plant override.
+
+ Returns
+ -------
+ tuple
+ Decoded snapshots and the :class:`TubeMPCReport`.
+
+ Raises
+ ------
+ ValueError
+ If ``steps < 1``.
+ """
+ if steps < 1:
+ msg = f"steps must be >= 1, got {steps}"
+ raise ValueError(msg)
+ controller = self._controller
+ assert controller.y_min is not None
+ assert controller.y_max is not None
+ out_dim = int(controller.y_min.shape[0])
+ refs = _resolve_reference(
+ reference, horizon=controller.horizon, out_dim=out_dim
+ )
+ current = initial_graph
+ outputs: list[Data] = []
+ n_feasible = 0
+ n_violations = 0
+ cost_sum = 0.0
+ for _ in range(steps):
+ try:
+ action = self.solve(current, reference)
+ except RuntimeError:
+ action = _fallback_control(controller)
+ feasible = False
+ else:
+ feasible = True
+ n_feasible += 1
+ if plant is None:
+ current = _default_model_plant(controller.model, current, action)
+ else:
+ current = plant(current, action)
+ outputs.append(current)
+ y = _mean_decoded_numpy(controller.model, current)
+ if _outside_nominal_box(y, controller.y_min, controller.y_max):
+ n_violations += 1
+ if feasible:
+ cost_sum += _quadratic_stage_cost(
+ y,
+ refs[1],
+ _as_numpy(action),
+ controller.Q,
+ controller.R,
+ )
+ mean_cost = float("nan") if n_feasible == 0 else cost_sum / float(n_feasible)
+ report = TubeMPCReport(
+ violation_rate=n_violations / steps,
+ feasibility_rate=n_feasible / steps,
+ cost=mean_cost,
+ n_steps=steps,
+ n_feasible=n_feasible,
+ n_violations=n_violations,
+ coverage=self.coverage,
+ )
+ return outputs, report
diff --git a/src/koopman_graph/nn/__init__.py b/src/koopman_graph/nn/__init__.py
index dab06c9..46a2983 100644
--- a/src/koopman_graph/nn/__init__.py
+++ b/src/koopman_graph/nn/__init__.py
@@ -18,6 +18,12 @@
:class:`~koopman_graph.nn.decoder.SAGEDecoder` /
:class:`~koopman_graph.nn.decoder.DiffConvDecoder` /
:class:`~koopman_graph.nn.decoder.GraphTransformerDecoder`.
+``constraint_decoders``
+ :class:`~koopman_graph.nn.MassConservingDecoder` /
+ :class:`~koopman_graph.nn.PositivityDecoder` /
+ :class:`~koopman_graph.nn.LinearConservingDecoder`
+ (opt-in decoded-space simplex / positivity / linear conservation;
+ not a factory kind; must not import ``model``).
``hypergraph``
:class:`~koopman_graph.nn.hypergraph.HypergraphEncoder` /
:class:`~koopman_graph.nn.hypergraph.HypergraphDecoder`.
@@ -34,7 +40,11 @@
Hodge helpers ``B_k`` / ``L_k`` for ``k ∈ {0, 1, 2, 3}``, plus
:class:`~koopman_graph.nn.cell_complex.CellComplexGNNEncoder` /
:class:`~koopman_graph.nn.cell_complex.CellComplexGNNDecoder`
- (factory ``encoder="cell_complex"``).
+ (factory ``encoder="cell_complex"``). Order-2 teaching
+ (:func:`~koopman_graph.nn.order2_cochain_teaching`) binds
+ :class:`~koopman_graph.operators.CochainKoopmanOperator` to a
+ filled triangle. Degree 3 is the teaching ceiling — **not**
+ TopologicX parity.
``equivariant``
:class:`~koopman_graph.nn.equivariant.InvariantGeometryEncoder`
(Tier A invariant distance/angle features from ``Data.pos``) and optional
@@ -47,8 +57,13 @@
(mesh-index Fourier lift; shared ``K`` across discretizations is a
teaching MVP).
``predicted_topology``
+ :class:`~koopman_graph.nn.predicted_topology.SparseCandidateTopologyHead`
+ (default graph-state path; at most ``candidate_k`` destinations per
+ node), power-user
:class:`~koopman_graph.nn.predicted_topology.PredictedTopologyHead`
- (next-step edge logits; distinct from static AdaptiveAdjacency).
+ (dense :math:`N\\times N` logits with an :math:`N` ceiling), and
+ :class:`~koopman_graph.nn.predicted_topology.PresenceHead`. Distinct
+ from static AdaptiveAdjacency.
``heterogeneous``
:class:`~koopman_graph.nn.heterogeneous.RelGraphEncoder` /
:class:`~koopman_graph.nn.heterogeneous.RelGraphDecoder`
@@ -58,8 +73,19 @@
:class:`~koopman_graph.nn.heterogeneous.HGTDecoder` (typed PyG
``HGTConv`` peers; not required for hetero support).
``delay``
- :class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder` Hankel wrapper and
- delay-window helpers.
+ :class:`~koopman_graph.nn.delay.DelayEmbeddingEncoder` Takens-style
+ channel stacking (not
+ :class:`~koopman_graph.baselines.HankelDMDBaseline` /
+ :class:`~koopman_graph.baselines.HAVOKBaseline`) and delay-window
+ helpers.
+``receptive_field``
+ Encoder vs discrete-graph-operator hop check
+ (:func:`~koopman_graph.nn.receptive_field.check_encoder_operator_receptive_field`;
+ warn-only; not in root ``__all__``).
+``separable``
+ :class:`~koopman_graph.nn.SeparableDictionaryEncoder` /
+ :class:`~koopman_graph.nn.SeparableDictionaryDecoder` node-wise
+ lifts (homomorphism precondition; not a GNN).
``adaptive_topology``
:class:`~koopman_graph.nn.adaptive_topology.AdaptiveAdjacency` self-adaptive
pairwise adjacency (Graph WaveNet construction; power-user).
@@ -79,11 +105,22 @@
CellComplex,
CellComplexGNNDecoder,
CellComplexGNNEncoder,
+ Order2CochainTeaching,
bind_cell_complex_decoder,
+ bind_cochain_operator,
boundary_incidence_b2,
boundary_operator,
+ cell_complex_boundary_nilpotency,
hodge_laplacian,
hodge_laplacian_matvec,
+ order2_cochain_teaching,
+ teaching_order2_triangle,
+ teaching_order3_tetrahedron,
+)
+from koopman_graph.nn.constraint_decoders import (
+ LinearConservingDecoder,
+ MassConservingDecoder,
+ PositivityDecoder,
)
from koopman_graph.nn.decoder import (
DiffConvDecoder,
@@ -136,7 +173,26 @@
bind_hypergraph_decoder,
)
from koopman_graph.nn.neural_operator import FourierNeuralOperatorEncoder
-from koopman_graph.nn.predicted_topology import PredictedTopologyHead
+from koopman_graph.nn.predicted_topology import (
+ DEFAULT_CANDIDATE_K,
+ DENSE_TOPOLOGY_MAX_NODES,
+ PredictedTopologyHead,
+ PresenceHead,
+ SparseCandidateTopologyHead,
+ TopologyPolicy,
+ build_candidate_index,
+ build_supervision_index,
+)
+from koopman_graph.nn.receptive_field import (
+ ReceptiveFieldMismatchWarning,
+ ReceptiveFieldReport,
+ check_encoder_operator_receptive_field,
+)
+from koopman_graph.nn.separable import (
+ SeparableDictionaryDecoder,
+ SeparableDictionaryEncoder,
+ is_separable_dictionary,
+)
from koopman_graph.nn.sheaf import (
SheafGNNDecoder,
SheafGNNEncoder,
@@ -155,7 +211,9 @@
"CellComplex",
"CellComplexGNNDecoder",
"CellComplexGNNEncoder",
+ "DEFAULT_CANDIDATE_K",
"DEFAULT_TOPOLOGY_EMBEDDING_DIM",
+ "DENSE_TOPOLOGY_MAX_NODES",
"DelayEmbeddingEncoder",
"DiffConvDecoder",
"DiffConvEncoder",
@@ -174,26 +232,46 @@
"HypergraphDecoder",
"HypergraphEncoder",
"InvariantGeometryEncoder",
+ "LinearConservingDecoder",
"MAX_CELL_COMPLEX_DEGREE",
+ "MassConservingDecoder",
+ "Order2CochainTeaching",
+ "PositivityDecoder",
+ "PresenceHead",
"PredictedTopologyHead",
+ "ReceptiveFieldMismatchWarning",
+ "ReceptiveFieldReport",
"RelGraphConv",
"RelGraphDecoder",
"RelGraphEncoder",
"SAGEDecoder",
"SAGEEncoder",
+ "SeparableDictionaryDecoder",
+ "SeparableDictionaryEncoder",
"SheafGNNDecoder",
"SheafGNNEncoder",
"SimplicialDecoder",
"SimplicialEncoder",
+ "SparseCandidateTopologyHead",
+ "TopologyPolicy",
"bind_cell_complex_decoder",
+ "bind_cochain_operator",
"bind_hypergraph_decoder",
"bind_sheaf_decoder",
"bind_simplicial_decoder",
"boundary_incidence_b2",
"boundary_operator",
+ "cell_complex_boundary_nilpotency",
+ "order2_cochain_teaching",
+ "teaching_order2_triangle",
+ "teaching_order3_tetrahedron",
+ "build_candidate_index",
+ "build_supervision_index",
+ "check_encoder_operator_receptive_field",
"hodge_laplacian",
"hodge_laplacian_matvec",
"invariant_geometry_features",
+ "is_separable_dictionary",
"build_diff_convs",
"build_gat_convs",
"build_gcn_convs",
diff --git a/src/koopman_graph/nn/cell_complex.py b/src/koopman_graph/nn/cell_complex.py
index 88075a9..11d19cf 100644
--- a/src/koopman_graph/nn/cell_complex.py
+++ b/src/koopman_graph/nn/cell_complex.py
@@ -1,12 +1,20 @@
"""Cell-complex boundary operators and Hodge Laplacians (teaching MVP).
-Supports oriented 0-, 1-, and 2-cells. Higher-dimensional cells
-(``k > 2``) are refused. Boundary ``B_1`` reuses
-:func:`~koopman_graph.observables.boundary_incidence_b1`; the combinatorial
-graph Laplacian on 0-cells reuses
-:func:`~koopman_graph.observables.simplicial_one_laplacian_matvec` (the 0.10
-simplicial helper named ``L_1 = B_1 B_1^T`` on node features — Hodge degree
-``L_0`` here).
+Supports oriented 0-, 1-, and 2-cells plus optional tetrahedral
+3-cells. Degrees above :data:`MAX_CELL_COMPLEX_DEGREE` (3) are
+refused. That ceiling is the in-repo TDL teaching depth — **not**
+TopologicX / TDA ecosystem parity (``TopoX2024``). Boundary ``B_1``
+reuses :func:`~koopman_graph.observables.boundary_incidence_b1`; the
+combinatorial graph Laplacian on 0-cells reuses
+:func:`~koopman_graph.observables.simplicial_one_laplacian_matvec`
+(the 0.10 simplicial helper named ``L_1 = B_1 B_1^T`` on node
+features — Hodge degree ``L_0`` here).
+
+:func:`order2_cochain_teaching` binds
+:class:`~koopman_graph.operators.CochainKoopmanOperator` to a filled
+triangle. The operator advances :math:`k\\le 1`; face latents may be
+stored. Sheaf restriction maps stay learned-optional on the sheaf
+peers (default diagonal).
Orientation convention
----------------------
@@ -18,6 +26,9 @@
the boundary cycle ``(a → b) + (b → c) + (c → a)``. Each induced edge
contributes ``+1`` / ``-1`` according to whether it agrees with the stored
1-cell orientation.
+* **3-cells.** Optional ``tetra_index`` columns ``(a, b, c, d)`` induce
+ the four triangular faces of a tetrahedron. This is the teaching
+ ceiling, not a general 3-complex library.
Factory ``encoder="cell_complex"`` builds matched
:class:`CellComplexGNNEncoder` / :class:`CellComplexGNNDecoder` peers that
@@ -31,6 +42,7 @@
from collections.abc import Callable
from dataclasses import dataclass
+from typing import TYPE_CHECKING
import torch
from torch import Tensor, nn
@@ -47,6 +59,12 @@
simplicial_one_laplacian_matvec,
)
+if TYPE_CHECKING:
+ from koopman_graph.operators.cochain import (
+ BoundaryNilpotencyReport,
+ CochainKoopmanOperator,
+ )
+
DecoderFn = Callable[[Tensor, Tensor, Tensor | None], Tensor]
# Teaching MVP: 0-/1-/2-cells, optional 3-cells via tetra_index.
@@ -77,7 +95,7 @@ def _validate_degree(k: int) -> None:
@dataclass
class CellComplex:
- """Oriented cell complex with 0-/1-/2-cells.
+ """Oriented cell complex with 0-/1-/2-cells and optional 3-cells.
Parameters
----------
@@ -88,6 +106,9 @@ class CellComplex:
face_index : Tensor or None, optional
Ordered triangular 2-cells with shape ``(3, num_faces)``, or
``None`` / empty for a pure 1-skeleton.
+ tetra_index : Tensor or None, optional
+ Ordered tetrahedral 3-cells with shape ``(4, num_tets)``. The
+ teaching ceiling is :data:`MAX_CELL_COMPLEX_DEGREE`.
"""
num_nodes: int
@@ -289,7 +310,8 @@ def boundary_operator(complex_: CellComplex, k: int) -> Tensor:
Oriented cell complex.
k : int
Boundary degree. ``k = 0`` returns an empty ``(0, n_0)`` matrix
- (no ``(-1)``-cells). ``k ∈ {1, 2}`` return ``B_1`` / ``B_2``.
+ (no ``(-1)``-cells). ``k ∈ {1, 2, 3}`` return ``B_1`` /
+ ``B_2`` / ``B_3``. ``k > 3`` is refused.
Returns
-------
@@ -923,3 +945,226 @@ def bind_cell_complex_decoder(
faces = _require_face_index(face_index, num_nodes=num_nodes)
CellComplex(num_nodes=num_nodes, edge_index=edges, face_index=faces)
return lambda z, _edge_index, _edge_weight: decoder(z, edges, None)
+
+
+@dataclass(frozen=True)
+class Order2CochainTeaching:
+ """Filled triangle bound to a :math:`k\\le 1` cochain operator.
+
+ Attributes
+ ----------
+ complex : CellComplex
+ Oriented 2-simplex (3 nodes, 3 edges, 1 face).
+ operator : CochainKoopmanOperator
+ Bound to the 1-skeleton. Not a factory kind; ``k=2`` is not
+ evolved.
+ nilpotency : BoundaryNilpotencyReport
+ :math:`B_1 B_2\\approx 0` on this triangle
+ (``Lim2020Hodge``).
+ max_cell_degree : int
+ Always :data:`MAX_CELL_COMPLEX_DEGREE` (3). Documents the
+ teaching ceiling, not ecosystem parity (``TopoX2024``).
+ """
+
+ complex: CellComplex
+ operator: CochainKoopmanOperator
+ nilpotency: BoundaryNilpotencyReport
+ max_cell_degree: int
+
+ def __post_init__(self) -> None:
+ """Validate the documented cell-complex ceiling.
+
+ Raises
+ ------
+ ValueError
+ If ``max_cell_degree`` is not the teaching ceiling.
+ """
+ if int(self.max_cell_degree) != int(MAX_CELL_COMPLEX_DEGREE):
+ msg = (
+ "max_cell_degree must equal MAX_CELL_COMPLEX_DEGREE "
+ f"{MAX_CELL_COMPLEX_DEGREE}, got {self.max_cell_degree}"
+ )
+ raise ValueError(msg)
+
+
+def teaching_order2_triangle() -> CellComplex:
+ """Return the oriented filled triangle used as the order-2 path.
+
+ Returns
+ -------
+ CellComplex
+ Three 0-cells, three 1-cells, and one 2-cell. Combinatorial
+ incidence only — not a sheaf or TopologicX complex
+ (``Lim2020Hodge``, ``TopoX2024``).
+ """
+ edge_index = torch.tensor([[0, 1, 0], [1, 2, 2]], dtype=torch.long)
+ face_index = torch.tensor([[0], [1], [2]], dtype=torch.long)
+ return CellComplex(num_nodes=3, edge_index=edge_index, face_index=face_index)
+
+
+def teaching_order3_tetrahedron() -> CellComplex:
+ """Return a filled tetrahedron at the cell-complex degree ceiling.
+
+ Returns
+ -------
+ CellComplex
+ Four 0-cells, six 1-cells, four 2-cells, and one 3-cell.
+ :data:`MAX_CELL_COMPLEX_DEGREE` is 3. This is not a general
+ 3-complex library (``TopoX2024``).
+ """
+ edge_index = torch.tensor(
+ [[0, 1, 0, 0, 1, 2], [1, 2, 2, 3, 3, 3]],
+ dtype=torch.long,
+ )
+ face_index = torch.tensor(
+ [[0, 0, 0, 1], [1, 1, 2, 2], [2, 3, 3, 3]],
+ dtype=torch.long,
+ )
+ tetra_index = torch.tensor([[0], [1], [2], [3]], dtype=torch.long)
+ return CellComplex(
+ num_nodes=4,
+ edge_index=edge_index,
+ face_index=face_index,
+ tetra_index=tetra_index,
+ )
+
+
+def bind_cochain_operator(
+ complex_: CellComplex,
+ *,
+ latent_dim: int,
+ use_cross_degree: bool = False,
+) -> CochainKoopmanOperator:
+ """Bind a :math:`k\\le 1` cochain operator to a cell-complex 1-skeleton.
+
+ Face and tetra incidences stay on the complex for nilpotency
+ checks. The operator does not evolve :math:`k=2`. This hook lives
+ in ``nn`` because :mod:`koopman_graph.operators.cochain` must not
+ import ``nn``.
+
+ Parameters
+ ----------
+ complex_ : CellComplex
+ Oriented complex. ``edge_index`` and ``num_nodes`` bind
+ :math:`B_1`.
+ latent_dim : int
+ Shared node / edge feature width.
+ use_cross_degree : bool, optional
+ Enable incidence cross terms. Default ``False``.
+
+ Returns
+ -------
+ CochainKoopmanOperator
+ Operator on the 1-skeleton. Not a factory kind.
+
+ Raises
+ ------
+ TypeError
+ If ``complex_`` is not a :class:`CellComplex`.
+ ValueError
+ If ``latent_dim`` or the 1-skeleton is invalid.
+ """
+ if not isinstance(complex_, CellComplex):
+ msg = f"complex_ must be a CellComplex, got {type(complex_).__name__}"
+ raise TypeError(msg)
+ from koopman_graph.operators.cochain import CochainKoopmanOperator
+
+ return CochainKoopmanOperator(
+ int(latent_dim),
+ complex_.edge_index,
+ num_nodes=int(complex_.num_nodes),
+ use_cross_degree=use_cross_degree,
+ )
+
+
+def cell_complex_boundary_nilpotency(
+ complex_: CellComplex,
+ *,
+ atol: float | None = None,
+) -> BoundaryNilpotencyReport:
+ """Score :math:`B_1 B_2 \\approx 0` on a cell complex.
+
+ This is the cochain-operator hook for order-2 incidence. It does
+ not score :math:`B_2 B_3`; tetrahedra are ceiling fixtures only.
+
+ Parameters
+ ----------
+ complex_ : CellComplex
+ Oriented complex with at least one 2-cell.
+ atol : float or None, optional
+ Absolute entrywise tolerance. Default is the teaching
+ triangle floor ``1e-6``.
+
+ Returns
+ -------
+ BoundaryNilpotencyReport
+ Product, max absolute entry, and flag.
+
+ Raises
+ ------
+ TypeError
+ If ``complex_`` is not a :class:`CellComplex`.
+ ValueError
+ If the complex has no 2-cells.
+ """
+ if not isinstance(complex_, CellComplex):
+ msg = f"complex_ must be a CellComplex, got {type(complex_).__name__}"
+ raise TypeError(msg)
+ if int(complex_.num_faces) < 1:
+ raise ValueError(
+ "cell_complex_boundary_nilpotency requires at least one 2-cell"
+ )
+ from koopman_graph.operators.cochain import (
+ DEFAULT_NILPOTENCY_ATOL,
+ boundary_nilpotency,
+ )
+
+ tolerance = DEFAULT_NILPOTENCY_ATOL if atol is None else float(atol)
+ return boundary_nilpotency(
+ boundary_operator(complex_, 1),
+ boundary_operator(complex_, 2),
+ atol=tolerance,
+ )
+
+
+def order2_cochain_teaching(
+ *,
+ latent_dim: int = 2,
+ use_cross_degree: bool = False,
+) -> Order2CochainTeaching:
+ """Build the order-2 teaching path and bind a cochain operator.
+
+ The filled triangle satisfies :math:`B_1 B_2\\approx 0` at atol
+ ``1e-6``. The bound operator advances node and edge latents only.
+ Sheaf restriction maps are not required. Cell-complex degree 3
+ remains the teaching ceiling (``Lim2020Hodge``, ``TopoX2024``).
+
+ Parameters
+ ----------
+ latent_dim : int, optional
+ Shared node / edge width. Default ``2``.
+ use_cross_degree : bool, optional
+ Enable incidence cross terms. Default ``False``.
+
+ Returns
+ -------
+ Order2CochainTeaching
+ Triangle, bound operator, nilpotency report, and ceiling.
+
+ Raises
+ ------
+ ValueError
+ If ``latent_dim`` is invalid.
+ """
+ complex_ = teaching_order2_triangle()
+ operator = bind_cochain_operator(
+ complex_,
+ latent_dim=latent_dim,
+ use_cross_degree=use_cross_degree,
+ )
+ return Order2CochainTeaching(
+ complex=complex_,
+ operator=operator,
+ nilpotency=cell_complex_boundary_nilpotency(complex_),
+ max_cell_degree=MAX_CELL_COMPLEX_DEGREE,
+ )
diff --git a/src/koopman_graph/nn/constraint_decoders.py b/src/koopman_graph/nn/constraint_decoders.py
new file mode 100644
index 0000000..de95c44
--- /dev/null
+++ b/src/koopman_graph/nn/constraint_decoders.py
@@ -0,0 +1,595 @@
+"""Opt-in decoded-space constraint heads.
+
+:class:`MassConservingDecoder` projects named channels onto
+:math:`\\{x:\\mathbf{1}^{\\top}x=M\\}` by softmax (simplex) or affine
+correction. :class:`PositivityDecoder` applies ``softplus`` or ``exp``
+to named channels. :class:`LinearConservingDecoder` is the min-norm
+affine projector onto :math:`\\{x:Cx=c_0\\}` for a fixed node count.
+
+These heads act **after** an inner decoder. Latent symplectic
+:math:`K` alone does not conserve decoded mass
+(``Greydanus2019HNN``). IEEE-118 trajectories remain Laplacian
+diffusion — constraint heads do not create AC power-flow consistency.
+
+This module must not import :mod:`koopman_graph.model`.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import Literal
+
+import torch
+from torch import Tensor, nn
+
+DEFAULT_CONSERVATION_ATOL = 1e-6
+MassMethod = Literal["softmax", "affine"]
+PositivityMethod = Literal["softplus", "exp"]
+
+__all__ = [
+ "DEFAULT_CONSERVATION_ATOL",
+ "LinearConservingDecoder",
+ "MassConservingDecoder",
+ "MassMethod",
+ "PositivityDecoder",
+ "PositivityMethod",
+ "project_linear_conservation",
+]
+
+
+def _as_channel_index(channels: Sequence[int], *, n_features: int) -> tuple[int, ...]:
+ """Validate named feature-column indices.
+
+ Parameters
+ ----------
+ channels : sequence of int
+ Feature columns to constrain.
+ n_features : int
+ Trailing decoded width.
+
+ Returns
+ -------
+ tuple[int, ...]
+ Coerced channel indices.
+
+ Raises
+ ------
+ ValueError
+ If a channel is repeated or out of range.
+ """
+ if len(channels) < 1:
+ raise ValueError("channels must be a non-empty sequence of ints")
+ resolved: list[int] = []
+ seen: set[int] = set()
+ for raw in channels:
+ if isinstance(raw, bool) or not isinstance(raw, int):
+ raise ValueError(f"channel indices must be ints, got {raw!r}")
+ if int(raw) < 0 or int(raw) >= int(n_features):
+ msg = (
+ f"channel {raw} is outside [0, {n_features}), "
+ f"got channels={tuple(channels)}"
+ )
+ raise ValueError(msg)
+ if int(raw) in seen:
+ raise ValueError(f"channels must be unique, got {tuple(channels)}")
+ seen.add(int(raw))
+ resolved.append(int(raw))
+ return tuple(resolved)
+
+
+def _require_decoded_table(name: str, value: Tensor) -> None:
+ """Refuse empty, non-real, or non-finite decoded tables.
+
+ Parameters
+ ----------
+ name : str
+ Field name for the error message.
+ value : Tensor
+ Candidate ``(N, d)`` table.
+
+ Raises
+ ------
+ TypeError
+ If ``value`` is not a tensor.
+ ValueError
+ If rank, dtype, or finiteness is invalid.
+ """
+ if not isinstance(value, Tensor):
+ raise TypeError(f"{name} must be a Tensor, got {type(value).__name__}")
+ if value.is_complex():
+ raise ValueError(f"{name} must be real")
+ if not value.is_floating_point():
+ raise ValueError(f"{name} must be a floating-point tensor")
+ if value.ndim != 2 or int(value.shape[0]) < 1 or int(value.shape[1]) < 1:
+ raise ValueError(
+ f"{name} must have shape (n_nodes, n_features) with both "
+ f"axes >= 1, got {tuple(value.shape)}"
+ )
+ if not bool(torch.isfinite(value).all().item()):
+ raise ValueError(f"{name} must be finite")
+
+
+def _call_inner(
+ decoder: nn.Module,
+ z: Tensor,
+ edge_index: Tensor,
+ edge_weight: Tensor | None,
+) -> Tensor:
+ """Decode latents with the inner module.
+
+ Parameters
+ ----------
+ decoder : nn.Module
+ Inner decoder ``(z, edge_index, edge_weight=None) -> (N, d)``.
+ z : Tensor
+ Latents ``(N, latent_dim)``.
+ edge_index : Tensor
+ COO edges ``(2, E)``.
+ edge_weight : Tensor or None
+ Optional scalar edge weights.
+
+ Returns
+ -------
+ Tensor
+ Decoded table ``(N, d)``.
+ """
+ decoded = decoder(z, edge_index, edge_weight)
+ _require_decoded_table("decoded", decoded)
+ return decoded
+
+
+def project_linear_conservation(
+ values: Tensor,
+ constraint: Tensor,
+ target: Tensor,
+) -> Tensor:
+ """Min-norm affine correction onto :math:`\\{x:Cx=c_0\\}`.
+
+ Parameters
+ ----------
+ values : Tensor
+ Unconstrained 1-D field ``(N,)``.
+ constraint : Tensor
+ Constraint matrix ``C`` with shape ``(n_eq, N)``.
+ target : Tensor
+ Target ``c_0`` with shape ``(n_eq,)``.
+
+ Returns
+ -------
+ Tensor
+ Projected field ``(N,)`` in the dtype of ``values``.
+
+ Raises
+ ------
+ ValueError
+ If shapes are incompatible, values are non-finite, or
+ ``constraint`` is row-rank deficient.
+ """
+ _require_decoded_table("values", values.unsqueeze(-1))
+ if constraint.ndim != 2 or int(constraint.shape[1]) != int(values.shape[0]):
+ msg = (
+ "constraint must have shape (n_eq, n_nodes), "
+ f"got {tuple(constraint.shape)} for n_nodes={int(values.shape[0])}"
+ )
+ raise ValueError(msg)
+ if target.ndim != 1 or int(target.shape[0]) != int(constraint.shape[0]):
+ msg = (
+ "target must have shape (n_eq,), "
+ f"got {tuple(target.shape)} for n_eq={int(constraint.shape[0])}"
+ )
+ raise ValueError(msg)
+ if int(constraint.shape[0]) < 1:
+ raise ValueError("constraint must have at least one equation")
+ if not bool(torch.isfinite(constraint).all().item()) or not bool(
+ torch.isfinite(target).all().item()
+ ):
+ raise ValueError("constraint and target must be finite")
+ working = values.detach().to(dtype=torch.float64)
+ matrix = constraint.to(dtype=torch.float64, device=working.device)
+ rhs = target.to(dtype=torch.float64, device=working.device)
+ residual = matrix @ working - rhs
+ # Min-norm correction C^+ r via the Gram system (C C^T) λ = r,
+ # x ← x − C^T λ. ``lstsq`` on the fat (n_eq < N) factor is
+ # driver-dependent: CPU GELS on Linux can leave a large residual
+ # on later rows while the first equation looks exact.
+ gram = matrix @ matrix.T
+ try:
+ multiplier = torch.linalg.solve(gram, residual)
+ except torch.linalg.LinAlgError as exc:
+ msg = (
+ "constraint must have full row rank so Cx = c0 is a "
+ "well-posed affine section, "
+ f"got C with shape {tuple(matrix.shape)}"
+ )
+ raise ValueError(msg) from exc
+ delta = matrix.T @ multiplier
+ return (working - delta).to(dtype=values.dtype)
+
+
+class _ConstraintHead(nn.Module):
+ """Shared inner-decoder wrapper.
+
+ Parameters
+ ----------
+ decoder : nn.Module
+ Inner decode map.
+ channels : sequence of int
+ Feature columns to constrain. Validated on the first forward.
+ """
+
+ def __init__(self, decoder: nn.Module, channels: Sequence[int]) -> None:
+ """Store the inner decoder and channel request.
+
+ Parameters
+ ----------
+ decoder : nn.Module
+ Inner decoder.
+ channels : sequence of int
+ Named feature columns.
+
+ Raises
+ ------
+ TypeError
+ If ``decoder`` is not a module.
+ ValueError
+ If ``channels`` is empty.
+ """
+ super().__init__()
+ if not isinstance(decoder, nn.Module):
+ raise TypeError(
+ f"decoder must be an nn.Module, got {type(decoder).__name__}"
+ )
+ if len(channels) < 1:
+ raise ValueError("channels must be a non-empty sequence of ints")
+ self.decoder = decoder
+ self._requested_channels = tuple(channels)
+
+ def _resolved_channels(self, decoded: Tensor) -> tuple[int, ...]:
+ """Validate channels against a decoded table.
+
+ Parameters
+ ----------
+ decoded : Tensor
+ Inner decoder output ``(N, d)``.
+
+ Returns
+ -------
+ tuple[int, ...]
+ Channel indices.
+ """
+ return _as_channel_index(
+ self._requested_channels,
+ n_features=int(decoded.shape[1]),
+ )
+
+
+class MassConservingDecoder(_ConstraintHead):
+ """Project named decoded channels onto a constant node-sum.
+
+ Softmax maps each named column to the simplex scaled by ``mass``.
+ Affine subtracts the min-norm correction so
+ :math:`\\mathbf{1}^{\\top}x=M`. Other columns are copied.
+
+ Parameters
+ ----------
+ decoder : nn.Module
+ Inner decoder ``(z, edge_index, edge_weight=None) -> (N, d)``.
+ channels : sequence of int
+ Feature columns to conserve.
+ mass : float
+ Target sum :math:`M` (same units as the named channel).
+ method : {"softmax", "affine"}, optional
+ Simplex or affine projector. Default ``"affine"``.
+ """
+
+ def __init__(
+ self,
+ decoder: nn.Module,
+ *,
+ channels: Sequence[int],
+ mass: float,
+ method: MassMethod = "affine",
+ ) -> None:
+ """Bind an inner decoder and a scalar mass target.
+
+ Parameters
+ ----------
+ decoder : nn.Module
+ Inner decoder.
+ channels : sequence of int
+ Named feature columns.
+ mass : float
+ Target node-sum.
+ method : {"softmax", "affine"}, optional
+ Projection kind.
+
+ Raises
+ ------
+ ValueError
+ If ``mass`` or ``method`` is invalid.
+ """
+ super().__init__(decoder, channels)
+ if not isinstance(mass, int | float) or isinstance(mass, bool):
+ raise ValueError(f"mass must be a finite float, got {mass!r}")
+ if not torch.isfinite(torch.tensor(float(mass))):
+ raise ValueError(f"mass must be finite, got {mass!r}")
+ if method not in {"softmax", "affine"}:
+ raise ValueError(f"method must be 'softmax' or 'affine', got {method!r}")
+ if method == "softmax" and float(mass) <= 0.0:
+ raise ValueError(f"softmax mass must be > 0, got {mass}")
+ self.mass = float(mass)
+ self.method: MassMethod = method
+
+ def project(self, decoded: Tensor) -> Tensor:
+ """Apply the mass map to an already-decoded table.
+
+ Parameters
+ ----------
+ decoded : Tensor
+ Unconstrained reconstruction ``(N, d)``.
+
+ Returns
+ -------
+ Tensor
+ Projected table, same shape.
+ """
+ _require_decoded_table("decoded", decoded)
+ channels = self._resolved_channels(decoded)
+ out = decoded.clone()
+ ones = torch.ones(
+ 1,
+ int(decoded.shape[0]),
+ dtype=torch.float64,
+ device=decoded.device,
+ )
+ target = torch.tensor([self.mass], dtype=torch.float64, device=decoded.device)
+ for channel in channels:
+ column = out[:, channel]
+ if self.method == "softmax":
+ weights = torch.softmax(column.to(dtype=torch.float64), dim=0)
+ out[:, channel] = (weights * self.mass).to(dtype=decoded.dtype)
+ else:
+ out[:, channel] = project_linear_conservation(column, ones, target)
+ return out
+
+ def forward(
+ self,
+ z: Tensor,
+ edge_index: Tensor,
+ edge_weight: Tensor | None = None,
+ ) -> Tensor:
+ """Decode then project named channels.
+
+ Parameters
+ ----------
+ z : Tensor
+ Latents ``(N, latent_dim)``.
+ edge_index : Tensor
+ COO edges.
+ edge_weight : Tensor or None, optional
+ Optional edge weights.
+
+ Returns
+ -------
+ Tensor
+ Constrained reconstruction ``(N, d)``.
+ """
+ return self.project(_call_inner(self.decoder, z, edge_index, edge_weight))
+
+
+class PositivityDecoder(_ConstraintHead):
+ """Map named decoded channels through ``softplus`` or ``exp``.
+
+ Other columns are copied. This does not conserve mass.
+
+ Parameters
+ ----------
+ decoder : nn.Module
+ Inner decoder.
+ channels : sequence of int
+ Feature columns forced non-negative (``softplus``) or strictly
+ positive (``exp``).
+ method : {"softplus", "exp"}, optional
+ Pointwise map. Default ``"softplus"``.
+ """
+
+ def __init__(
+ self,
+ decoder: nn.Module,
+ *,
+ channels: Sequence[int],
+ method: PositivityMethod = "softplus",
+ ) -> None:
+ """Bind an inner decoder and a positivity map.
+
+ Parameters
+ ----------
+ decoder : nn.Module
+ Inner decoder.
+ channels : sequence of int
+ Named feature columns.
+ method : {"softplus", "exp"}, optional
+ Pointwise map.
+
+ Raises
+ ------
+ ValueError
+ If ``method`` is invalid.
+ """
+ super().__init__(decoder, channels)
+ if method not in {"softplus", "exp"}:
+ raise ValueError(f"method must be 'softplus' or 'exp', got {method!r}")
+ self.method: PositivityMethod = method
+
+ def project(self, decoded: Tensor) -> Tensor:
+ """Apply the positivity map to an already-decoded table.
+
+ Parameters
+ ----------
+ decoded : Tensor
+ Unconstrained reconstruction ``(N, d)``.
+
+ Returns
+ -------
+ Tensor
+ Projected table, same shape.
+ """
+ _require_decoded_table("decoded", decoded)
+ channels = self._resolved_channels(decoded)
+ out = decoded.clone()
+ for channel in channels:
+ column = out[:, channel]
+ if self.method == "exp":
+ out[:, channel] = torch.exp(column)
+ else:
+ out[:, channel] = torch.nn.functional.softplus(column)
+ return out
+
+ def forward(
+ self,
+ z: Tensor,
+ edge_index: Tensor,
+ edge_weight: Tensor | None = None,
+ ) -> Tensor:
+ """Decode then apply the positivity map.
+
+ Parameters
+ ----------
+ z : Tensor
+ Latents ``(N, latent_dim)``.
+ edge_index : Tensor
+ COO edges.
+ edge_weight : Tensor or None, optional
+ Optional edge weights.
+
+ Returns
+ -------
+ Tensor
+ Constrained reconstruction ``(N, d)``.
+ """
+ return self.project(_call_inner(self.decoder, z, edge_index, edge_weight))
+
+
+class LinearConservingDecoder(_ConstraintHead):
+ """Min-norm affine projector onto :math:`\\{x:Cx=c_0\\}` per named channel.
+
+ ``constraint`` has shape ``(n_eq, N)`` and is fixed at construction.
+ Changing node count without rebuilding the head raises.
+
+ Parameters
+ ----------
+ decoder : nn.Module
+ Inner decoder.
+ channels : sequence of int
+ Feature columns that each receive the same ``C``.
+ constraint : Tensor
+ ``C`` with shape ``(n_eq, n_nodes)``.
+ target : Tensor
+ ``c_0`` with shape ``(n_eq,)``.
+ """
+
+ def __init__(
+ self,
+ decoder: nn.Module,
+ *,
+ channels: Sequence[int],
+ constraint: Tensor,
+ target: Tensor,
+ ) -> None:
+ """Bind an inner decoder and a linear conservation law.
+
+ Parameters
+ ----------
+ decoder : nn.Module
+ Inner decoder.
+ channels : sequence of int
+ Named feature columns.
+ constraint : Tensor
+ ``C`` with shape ``(n_eq, n_nodes)``.
+ target : Tensor
+ ``c_0`` with shape ``(n_eq,)``.
+
+ Raises
+ ------
+ ValueError
+ If ``constraint`` / ``target`` shapes are invalid.
+ """
+ super().__init__(decoder, channels)
+ if not isinstance(constraint, Tensor) or not isinstance(target, Tensor):
+ raise TypeError("constraint and target must be tensors")
+ if constraint.ndim != 2 or int(constraint.shape[0]) < 1:
+ raise ValueError(
+ "constraint must have shape (n_eq, n_nodes) with n_eq >= 1, "
+ f"got {tuple(constraint.shape)}"
+ )
+ if target.ndim != 1 or int(target.shape[0]) != int(constraint.shape[0]):
+ raise ValueError(
+ "target must have shape (n_eq,), "
+ f"got {tuple(target.shape)} for n_eq={int(constraint.shape[0])}"
+ )
+ if not bool(torch.isfinite(constraint).all().item()) or not bool(
+ torch.isfinite(target).all().item()
+ ):
+ raise ValueError("constraint and target must be finite")
+ self.num_nodes = int(constraint.shape[1])
+ self.register_buffer("constraint", constraint.detach().to(dtype=torch.float64))
+ self.register_buffer("target", target.detach().to(dtype=torch.float64))
+
+ def project(self, decoded: Tensor) -> Tensor:
+ """Apply :math:`Cx=c_0` to named columns.
+
+ Parameters
+ ----------
+ decoded : Tensor
+ Unconstrained reconstruction ``(N, d)``.
+
+ Returns
+ -------
+ Tensor
+ Projected table, same shape.
+
+ Raises
+ ------
+ ValueError
+ If the node count does not match ``constraint``.
+ """
+ _require_decoded_table("decoded", decoded)
+ if int(decoded.shape[0]) != self.num_nodes:
+ msg = (
+ "decoded node count must equal constraint columns "
+ f"{self.num_nodes}, got {int(decoded.shape[0])}"
+ )
+ raise ValueError(msg)
+ channels = self._resolved_channels(decoded)
+ out = decoded.clone()
+ for channel in channels:
+ out[:, channel] = project_linear_conservation(
+ out[:, channel],
+ self.constraint,
+ self.target,
+ )
+ return out
+
+ def forward(
+ self,
+ z: Tensor,
+ edge_index: Tensor,
+ edge_weight: Tensor | None = None,
+ ) -> Tensor:
+ """Decode then apply the linear conservation law.
+
+ Parameters
+ ----------
+ z : Tensor
+ Latents ``(N, latent_dim)``.
+ edge_index : Tensor
+ COO edges.
+ edge_weight : Tensor or None, optional
+ Optional edge weights.
+
+ Returns
+ -------
+ Tensor
+ Constrained reconstruction ``(N, d)``.
+ """
+ return self.project(_call_inner(self.decoder, z, edge_index, edge_weight))
diff --git a/src/koopman_graph/nn/delay.py b/src/koopman_graph/nn/delay.py
index c24c519..61dbc97 100644
--- a/src/koopman_graph/nn/delay.py
+++ b/src/koopman_graph/nn/delay.py
@@ -1,8 +1,12 @@
"""Delay-coordinate encoder wrapper for partial observability.
Stacks a short history of node features into encoder channels (Takens-style
-delay coordinates). Related Hankel delay matrices appear in HAVOK / Hankel-DMD;
-this module does not implement those full algorithms.
+delay coordinates). Distinct from
+:class:`~koopman_graph.baselines.HankelDMDBaseline` and
+:class:`~koopman_graph.baselines.HAVOKBaseline`, which fit delay-row
+operators on flattened snapshots rather than wrapping a GNN, and distinct
+from :class:`~koopman_graph.analysis.FiniteMemoryKoopman` (convolution
+taps are not a Mori–Zwanzig decomposition).
Composition style (see architecture docs): callers size the base encoder with
``in_channels = n_delays * feature_dim`` and wrap it explicitly, or pass
@@ -81,7 +85,9 @@ class DelayEmbeddingEncoder(nn.Module):
Stacks the last ``n_delays`` per-node feature vectors along the channel
axis before calling ``base_encoder``. The base encoder must already be
sized with ``in_channels = n_delays * feature_dim`` (composition; this
- wrapper does not rebuild layers).
+ wrapper does not rebuild layers). This is not
+ :class:`~koopman_graph.baselines.HankelDMDBaseline` or
+ :class:`~koopman_graph.baselines.HAVOKBaseline`.
Attributes
----------
@@ -131,6 +137,30 @@ def __init__(self, base_encoder: nn.Module, n_delays: int = 3) -> None:
if hasattr(base_encoder, attr):
setattr(self, attr, getattr(base_encoder, attr))
+ def receptive_field_hops(self) -> int:
+ """Return the spatial hop radius of the wrapped base encoder.
+
+ Delay stacking is temporal and does not add graph hops.
+
+ Returns
+ -------
+ int
+ Base encoder hop radius.
+
+ Raises
+ ------
+ AttributeError
+ If the base encoder does not expose a hop radius.
+ """
+ getter = getattr(self.base_encoder, "receptive_field_hops", None)
+ if callable(getter):
+ return int(getter())
+ num_layers = getattr(self.base_encoder, "num_layers", None)
+ if isinstance(num_layers, int) and not isinstance(num_layers, bool):
+ return int(num_layers)
+ msg = "DelayEmbeddingEncoder.base_encoder does not expose a hop radius"
+ raise AttributeError(msg)
+
def forward(
self,
x_or_data: Tensor | Data,
diff --git a/src/koopman_graph/nn/encoder.py b/src/koopman_graph/nn/encoder.py
index 4243516..88ef8eb 100644
--- a/src/koopman_graph/nn/encoder.py
+++ b/src/koopman_graph/nn/encoder.py
@@ -346,6 +346,19 @@ def clear_support_cache(self) -> None:
"""
clear_diff_conv_support_caches(self.convs)
+ def receptive_field_hops(self) -> int:
+ """Return spatial hops mixed by the DiffConv stack.
+
+ Each layer expands by ``diffusion_steps``, so the radius is
+ ``num_layers * diffusion_steps``.
+
+ Returns
+ -------
+ int
+ Encoder hop radius.
+ """
+ return int(self.num_layers) * int(self.diffusion_steps)
+
class GraphTransformerEncoder(BaseGNNModule):
"""Graph Transformer encoder for longer-range spatial attention.
diff --git a/src/koopman_graph/nn/gnn.py b/src/koopman_graph/nn/gnn.py
index a215b27..c0acdad 100644
--- a/src/koopman_graph/nn/gnn.py
+++ b/src/koopman_graph/nn/gnn.py
@@ -886,3 +886,15 @@ def forward(
if layer_idx < len(self.convs) - 1:
x = self.activation(x)
return x
+
+ def receptive_field_hops(self) -> int:
+ """Return the message-passing hop radius (one hop per layer).
+
+ DiffConv stacks override this by multiplying ``diffusion_steps``.
+
+ Returns
+ -------
+ int
+ ``num_layers``.
+ """
+ return int(self.num_layers)
diff --git a/src/koopman_graph/nn/predicted_topology.py b/src/koopman_graph/nn/predicted_topology.py
index 23a88e4..b2b98a1 100644
--- a/src/koopman_graph/nn/predicted_topology.py
+++ b/src/koopman_graph/nn/predicted_topology.py
@@ -1,17 +1,171 @@
-"""Opt-in next-step topology head (link formation / dissolution).
+"""Opt-in next-step topology heads (link formation / dissolution).
Distinct from :class:`~koopman_graph.nn.AdaptiveAdjacency`, which is a
-static Graph WaveNet self-adaptive adjacency.
+static Graph WaveNet self-adaptive adjacency. The default graph-state
+path is :class:`SparseCandidateTopologyHead` (at most ``candidate_k``
+destinations per node). :class:`PredictedTopologyHead` remains the
+power-user dense :math:`N\\times N` MLP behind ``dense_mlp``, with an
+:math:`N` ceiling.
"""
from __future__ import annotations
+from collections.abc import Callable, Sequence
+from typing import Literal
+
import torch
from torch import Tensor, nn
+from torch_geometric.data import Data
+
+from koopman_graph.graph_utils.propagation import hold_last_topology_at
+
+DENSE_TOPOLOGY_MAX_NODES = 64
+DEFAULT_TOPOLOGY_HIDDEN_DIM = 32
+DEFAULT_CANDIDATE_K = 8
+TopologyPolicy = Literal["auto", "recursive", "hold_last"]
+TOPOLOGY_POLICIES: frozenset[str] = frozenset({"auto", "recursive", "hold_last"})
+
+__all__ = [
+ "DEFAULT_CANDIDATE_K",
+ "DEFAULT_TOPOLOGY_HIDDEN_DIM",
+ "DENSE_TOPOLOGY_MAX_NODES",
+ "TOPOLOGY_POLICIES",
+ "PresenceHead",
+ "PredictedTopologyHead",
+ "SparseCandidateTopologyHead",
+ "TopologyPolicy",
+ "build_candidate_index",
+ "build_supervision_index",
+ "candidate_edge_labels",
+ "decode_weighted_topology",
+ "make_recursive_topology_at",
+ "recursive_training_enabled",
+ "resolve_rollout_topology_at",
+ "resolve_topology_policy",
+]
+
+
+def _pair_mlp(latent_dim: int, hidden_dim: int) -> nn.Sequential:
+ """Return the shared two-layer pair scorer.
+
+ Parameters
+ ----------
+ latent_dim : int
+ Node latent width.
+ hidden_dim : int
+ Hidden width.
+
+ Returns
+ -------
+ nn.Sequential
+ ``(2 d) → hidden → 1`` MLP.
+ """
+ return nn.Sequential(
+ nn.Linear(2 * latent_dim, hidden_dim),
+ nn.ReLU(),
+ nn.Linear(hidden_dim, 1),
+ )
+
+
+def build_candidate_index(
+ num_nodes: int,
+ candidate_k: int,
+ edge_index: Tensor | None = None,
+ *,
+ generator: torch.Generator | None = None,
+ device: torch.device | str | None = None,
+) -> Tensor:
+ """Build a COO candidate set with at most ``candidate_k`` destinations each.
+
+ Existing ``edge_index`` destinations (excluding self-loops) are kept
+ first, then filled with random other nodes. The helper never
+ materializes an :math:`N\\times N` adjacency.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count :math:`N`.
+ candidate_k : int
+ Maximum destinations per source (positive).
+ edge_index : Tensor or None, optional
+ Optional COO ``(2, E)`` of current edges.
+ generator : torch.Generator or None, optional
+ RNG for fill samples. ``None`` uses the global generator.
+ device : torch.device or str or None, optional
+ Output device. Defaults to ``edge_index.device`` when given.
+
+ Returns
+ -------
+ Tensor
+ COO index ``(2, E)`` with ``E <= N * min(k, N-1)``.
+
+ Raises
+ ------
+ ValueError
+ If ``num_nodes < 2`` or ``candidate_k < 1``.
+ """
+ if int(num_nodes) < 2:
+ msg = f"num_nodes must be at least 2, got {num_nodes}"
+ raise ValueError(msg)
+ if int(candidate_k) < 1:
+ msg = f"candidate_k must be positive, got {candidate_k}"
+ raise ValueError(msg)
+ resolved_device: torch.device
+ if device is not None:
+ resolved_device = torch.device(device)
+ elif edge_index is not None:
+ resolved_device = edge_index.device
+ else:
+ resolved_device = torch.device("cpu")
+ cap = min(int(candidate_k), int(num_nodes) - 1)
+ src_parts: list[Tensor] = []
+ dst_parts: list[Tensor] = []
+ existing: dict[int, list[int]] = {index: [] for index in range(int(num_nodes))}
+ if edge_index is not None:
+ if edge_index.ndim != 2 or int(edge_index.shape[0]) != 2:
+ msg = f"edge_index must have shape (2, E), got {tuple(edge_index.shape)}"
+ raise ValueError(msg)
+ for source, dest in zip(
+ edge_index[0].tolist(), edge_index[1].tolist(), strict=True
+ ):
+ src_i = int(source)
+ dst_i = int(dest)
+ if src_i == dst_i:
+ continue
+ if 0 <= src_i < int(num_nodes) and 0 <= dst_i < int(num_nodes):
+ bucket = existing[src_i]
+ if dst_i not in bucket and len(bucket) < cap:
+ bucket.append(dst_i)
+ for source in range(int(num_nodes)):
+ chosen = list(existing[source])
+ if len(chosen) < cap:
+ banned = {source, *chosen}
+ pool = [node for node in range(int(num_nodes)) if node not in banned]
+ need = cap - len(chosen)
+ if pool:
+ order = torch.randperm(
+ len(pool), device=resolved_device, generator=generator
+ )
+ for offset in order.tolist()[:need]:
+ chosen.append(pool[int(offset)])
+ if not chosen:
+ continue
+ src_parts.append(
+ torch.full((len(chosen),), source, dtype=torch.long, device=resolved_device)
+ )
+ dst_parts.append(torch.tensor(chosen, dtype=torch.long, device=resolved_device))
+ if not src_parts:
+ msg = "build_candidate_index produced no candidate edges"
+ raise ValueError(msg)
+ return torch.stack([torch.cat(src_parts), torch.cat(dst_parts)], dim=0)
class PredictedTopologyHead(nn.Module):
- """Predict pairwise edge logits from node latents.
+ """Predict dense pairwise edge logits from node latents.
+
+ Power-user ``dense_mlp`` path. Prefer
+ :class:`SparseCandidateTopologyHead` for graph-state closure.
+ Distinct from :class:`~koopman_graph.nn.AdaptiveAdjacency`.
Parameters
----------
@@ -19,9 +173,18 @@ class PredictedTopologyHead(nn.Module):
Node latent width.
hidden_dim : int, optional
MLP hidden width. Default is 32.
+ max_nodes : int, optional
+ Dense :math:`N\\times N` ceiling. Default
+ :data:`DENSE_TOPOLOGY_MAX_NODES` (64). Keyword-only.
"""
- def __init__(self, latent_dim: int, hidden_dim: int = 32) -> None:
+ def __init__(
+ self,
+ latent_dim: int,
+ hidden_dim: int = DEFAULT_TOPOLOGY_HIDDEN_DIM,
+ *,
+ max_nodes: int = DENSE_TOPOLOGY_MAX_NODES,
+ ) -> None:
"""Initialize the pairwise MLP.
Parameters
@@ -30,16 +193,19 @@ def __init__(self, latent_dim: int, hidden_dim: int = 32) -> None:
Node latent width.
hidden_dim : int, optional
MLP hidden width.
+ max_nodes : int, optional
+ Dense node-count ceiling.
"""
super().__init__()
if latent_dim < 1:
raise ValueError(f"latent_dim must be positive, got {latent_dim}")
+ if hidden_dim < 1:
+ raise ValueError(f"hidden_dim must be positive, got {hidden_dim}")
+ if max_nodes < 2:
+ raise ValueError(f"max_nodes must be at least 2, got {max_nodes}")
self.latent_dim = int(latent_dim)
- self.mlp = nn.Sequential(
- nn.Linear(2 * latent_dim, hidden_dim),
- nn.ReLU(),
- nn.Linear(hidden_dim, 1),
- )
+ self.max_nodes = int(max_nodes)
+ self.mlp = _pair_mlp(int(latent_dim), int(hidden_dim))
def pairwise_logits(self, z: Tensor) -> Tensor:
"""Return dense ``(N, N)`` logits.
@@ -53,10 +219,23 @@ def pairwise_logits(self, z: Tensor) -> Tensor:
-------
Tensor
Dense logits ``(N, N)``.
+
+ Raises
+ ------
+ ValueError
+ If ``z`` is not rank-2 or :math:`N` exceeds ``max_nodes``.
"""
if z.ndim != 2:
raise ValueError(f"z must have shape (N, d), got {tuple(z.shape)}")
num_nodes = z.shape[0]
+ if int(num_nodes) > self.max_nodes:
+ msg = (
+ "PredictedTopologyHead dense path refuses N="
+ f"{int(num_nodes)} > max_nodes={self.max_nodes}. "
+ "Use SparseCandidateTopologyHead / topology_head="
+ "'sparse_candidate'."
+ )
+ raise ValueError(msg)
left = z.unsqueeze(1).expand(num_nodes, num_nodes, self.latent_dim)
right = z.unsqueeze(0).expand(num_nodes, num_nodes, self.latent_dim)
pairs = torch.cat([left, right], dim=-1)
@@ -117,3 +296,629 @@ def forward(self, z: Tensor) -> Tensor:
Dense logits.
"""
return self.pairwise_logits(z)
+
+
+class SparseCandidateTopologyHead(nn.Module):
+ """Score edge logits only on a sparse candidate COO index.
+
+ Default graph-state topology head. Logits have shape ``(E,)`` with
+ ``E <= N * candidate_k``, not dense ``(N, N)``.
+
+ Parameters
+ ----------
+ latent_dim : int
+ Node latent width.
+ hidden_dim : int, optional
+ MLP hidden width. Default is 32.
+ candidate_k : int, optional
+ Maximum destinations per source when building candidates.
+ Default is 8.
+ """
+
+ def __init__(
+ self,
+ latent_dim: int,
+ hidden_dim: int = DEFAULT_TOPOLOGY_HIDDEN_DIM,
+ *,
+ candidate_k: int = DEFAULT_CANDIDATE_K,
+ ) -> None:
+ """Initialize the sparse pair scorer.
+
+ Parameters
+ ----------
+ latent_dim : int
+ Node latent width.
+ hidden_dim : int, optional
+ MLP hidden width.
+ candidate_k : int, optional
+ Candidate cap per source.
+ """
+ super().__init__()
+ if latent_dim < 1:
+ raise ValueError(f"latent_dim must be positive, got {latent_dim}")
+ if hidden_dim < 1:
+ raise ValueError(f"hidden_dim must be positive, got {hidden_dim}")
+ if candidate_k < 1:
+ raise ValueError(f"candidate_k must be positive, got {candidate_k}")
+ self.latent_dim = int(latent_dim)
+ self.candidate_k = int(candidate_k)
+ self.mlp = _pair_mlp(int(latent_dim), int(hidden_dim))
+
+ def pair_logits(self, z: Tensor, candidate_index: Tensor) -> Tensor:
+ """Return one logit per candidate edge.
+
+ Parameters
+ ----------
+ z : Tensor
+ Node latents ``(N, d)``.
+ candidate_index : Tensor
+ COO ``(2, E)``.
+
+ Returns
+ -------
+ Tensor
+ Logits ``(E,)``.
+
+ Raises
+ ------
+ ValueError
+ If ranks are wrong, ``E`` exceeds ``N (N-1)``, or
+ indices are out of range.
+ """
+ if z.ndim != 2:
+ raise ValueError(f"z must have shape (N, d), got {tuple(z.shape)}")
+ if candidate_index.ndim != 2 or int(candidate_index.shape[0]) != 2:
+ msg = (
+ "candidate_index must have shape (2, E), "
+ f"got {tuple(candidate_index.shape)}"
+ )
+ raise ValueError(msg)
+ num_nodes = int(z.shape[0])
+ num_edges = int(candidate_index.shape[1])
+ max_edges = num_nodes * max(num_nodes - 1, 0)
+ if num_edges > max_edges:
+ msg = f"candidate_index has E={num_edges} > N*(N-1)={max_edges}"
+ raise ValueError(msg)
+ src = candidate_index[0]
+ dst = candidate_index[1]
+ if src.numel() and (
+ int(src.min()) < 0
+ or int(dst.min()) < 0
+ or int(src.max()) >= num_nodes
+ or int(dst.max()) >= num_nodes
+ ):
+ msg = "candidate_index contains node ids outside [0, N)"
+ raise ValueError(msg)
+ pairs = torch.cat([z[src], z[dst]], dim=-1)
+ return self.mlp(pairs).squeeze(-1)
+
+ def edge_index(
+ self,
+ z: Tensor,
+ candidate_index: Tensor,
+ *,
+ threshold: float = 0.0,
+ ) -> Tensor:
+ """Keep candidate edges whose logits exceed ``threshold``.
+
+ Parameters
+ ----------
+ z : Tensor
+ Node latents.
+ candidate_index : Tensor
+ COO candidates ``(2, E)``.
+ threshold : float, optional
+ Logit threshold. Default ``0.0``.
+
+ Returns
+ -------
+ Tensor
+ Filtered COO ``(2, E_keep)``. If none pass, returns a
+ cycle of self-avoiding fallback edges.
+ """
+ logits = self.pair_logits(z, candidate_index)
+ keep = logits > threshold
+ if int(keep.sum()) == 0:
+ num_nodes = int(z.shape[0])
+ eye = torch.arange(num_nodes, device=z.device)
+ return torch.stack([eye, (eye + 1) % num_nodes], dim=0)
+ return candidate_index[:, keep]
+
+ def forward(self, z: Tensor, candidate_index: Tensor) -> Tensor:
+ """Return sparse candidate logits.
+
+ Parameters
+ ----------
+ z : Tensor
+ Node latents.
+ candidate_index : Tensor
+ COO candidates.
+
+ Returns
+ -------
+ Tensor
+ Logits ``(E,)``.
+ """
+ return self.pair_logits(z, candidate_index)
+
+
+class PresenceHead(nn.Module):
+ """Linear per-node presence logit from node latents.
+
+ Used when :class:`~koopman_graph.data.GraphDynamicsConfig` is attached.
+ BCE-with-logits targets the next-step presence mask when the sequence
+ carries masks; otherwise the term is skipped.
+
+ Parameters
+ ----------
+ latent_dim : int
+ Node latent width.
+ """
+
+ def __init__(self, latent_dim: int) -> None:
+ """Initialize the linear presence scorer.
+
+ Parameters
+ ----------
+ latent_dim : int
+ Node latent width.
+ """
+ super().__init__()
+ if latent_dim < 1:
+ raise ValueError(f"latent_dim must be positive, got {latent_dim}")
+ self.latent_dim = int(latent_dim)
+ self.linear = nn.Linear(int(latent_dim), 1)
+
+ def forward(self, z: Tensor) -> Tensor:
+ """Return one logit per node.
+
+ Parameters
+ ----------
+ z : Tensor
+ Node latents ``(N, d)``.
+
+ Returns
+ -------
+ Tensor
+ Logits ``(N,)``.
+ """
+ if z.ndim != 2:
+ raise ValueError(f"z must have shape (N, d), got {tuple(z.shape)}")
+ if int(z.shape[1]) != self.latent_dim:
+ msg = f"z latent width must be {self.latent_dim}, got {int(z.shape[1])}"
+ raise ValueError(msg)
+ return self.linear(z).squeeze(-1)
+
+
+def _destination_buckets(
+ num_nodes: int,
+ edge_index: Tensor | None,
+) -> dict[int, list[int]]:
+ """Collect unique off-diagonal destinations per source.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count :math:`N`.
+ edge_index : Tensor or None
+ Optional COO ``(2, E)``.
+
+ Returns
+ -------
+ dict of int to list of int
+ Destinations keyed by source, without self-loops.
+ """
+ buckets: dict[int, list[int]] = {index: [] for index in range(int(num_nodes))}
+ if edge_index is None:
+ return buckets
+ if edge_index.ndim != 2 or int(edge_index.shape[0]) != 2:
+ msg = f"edge_index must have shape (2, E), got {tuple(edge_index.shape)}"
+ raise ValueError(msg)
+ for source, dest in zip(
+ edge_index[0].tolist(), edge_index[1].tolist(), strict=True
+ ):
+ src_i = int(source)
+ dst_i = int(dest)
+ if src_i == dst_i:
+ continue
+ if 0 <= src_i < int(num_nodes) and 0 <= dst_i < int(num_nodes):
+ bucket = buckets[src_i]
+ if dst_i not in bucket:
+ bucket.append(dst_i)
+ return buckets
+
+
+def _coo_from_buckets(
+ buckets: dict[int, list[int]],
+ *,
+ device: torch.device,
+) -> Tensor:
+ """Stack per-source destination lists into a COO index.
+
+ Parameters
+ ----------
+ buckets : dict of int to list of int
+ Destinations keyed by source.
+ device : torch.device
+ Output device.
+
+ Returns
+ -------
+ Tensor
+ COO ``(2, E)``.
+
+ Raises
+ ------
+ ValueError
+ If every bucket is empty.
+ """
+ src_parts: list[Tensor] = []
+ dst_parts: list[Tensor] = []
+ for source, chosen in buckets.items():
+ if not chosen:
+ continue
+ src_parts.append(
+ torch.full((len(chosen),), source, dtype=torch.long, device=device)
+ )
+ dst_parts.append(torch.tensor(chosen, dtype=torch.long, device=device))
+ if not src_parts:
+ msg = "candidate construction produced no edges"
+ raise ValueError(msg)
+ return torch.stack([torch.cat(src_parts), torch.cat(dst_parts)], dim=0)
+
+
+def build_supervision_index(
+ num_nodes: int,
+ candidate_k: int,
+ current_edge_index: Tensor | None,
+ next_edge_index: Tensor,
+ *,
+ generator: torch.Generator | None = None,
+ device: torch.device | str | None = None,
+) -> Tensor:
+ """Build a COO supervision set from current edges union true next edges.
+
+ Existing current and true-next destinations (excluding self-loops) are
+ kept first. Random fill then raises each source to ``candidate_k``
+ destinations when the union is smaller. If the union already exceeds
+ ``candidate_k``, the union is kept and fill is skipped so formation
+ positives that fit in the union are not dropped. ``candidate_k`` remains
+ an inference construction cap.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count :math:`N`.
+ candidate_k : int
+ Inference construction cap per source (positive).
+ current_edge_index : Tensor or None
+ COO ``(2, E)`` of the source snapshot.
+ next_edge_index : Tensor
+ COO ``(2, E')`` of the true next snapshot.
+ generator : torch.Generator or None, optional
+ RNG for fill samples. ``None`` uses the global generator.
+ device : torch.device or str or None, optional
+ Output device. Defaults to ``next_edge_index.device``.
+
+ Returns
+ -------
+ Tensor
+ COO index ``(2, E)``. ``E`` may exceed ``N k`` when the union
+ is larger than the inference cap.
+
+ Raises
+ ------
+ ValueError
+ If ``num_nodes < 2`` or ``candidate_k < 1``.
+ """
+ if int(num_nodes) < 2:
+ msg = f"num_nodes must be at least 2, got {num_nodes}"
+ raise ValueError(msg)
+ if int(candidate_k) < 1:
+ msg = f"candidate_k must be positive, got {candidate_k}"
+ raise ValueError(msg)
+ resolved_device: torch.device
+ if device is not None:
+ resolved_device = torch.device(device)
+ else:
+ resolved_device = next_edge_index.device
+ cap = min(int(candidate_k), int(num_nodes) - 1)
+ buckets = _destination_buckets(int(num_nodes), current_edge_index)
+ next_buckets = _destination_buckets(int(num_nodes), next_edge_index)
+ for source, extras in next_buckets.items():
+ bucket = buckets[source]
+ for dest in extras:
+ if dest not in bucket:
+ bucket.append(dest)
+ for source in range(int(num_nodes)):
+ chosen = buckets[source]
+ if len(chosen) >= cap:
+ continue
+ banned = {source, *chosen}
+ pool = [node for node in range(int(num_nodes)) if node not in banned]
+ need = cap - len(chosen)
+ if not pool:
+ continue
+ order = torch.randperm(len(pool), device=resolved_device, generator=generator)
+ for offset in order.tolist()[:need]:
+ chosen.append(pool[int(offset)])
+ return _coo_from_buckets(buckets, device=resolved_device)
+
+
+def candidate_edge_labels(
+ candidate_index: Tensor,
+ true_next_index: Tensor,
+ num_nodes: int,
+) -> Tensor:
+ """Return 0/1 labels for whether each candidate appears in the next graph.
+
+ Parameters
+ ----------
+ candidate_index : Tensor
+ COO candidates ``(2, E)``.
+ true_next_index : Tensor
+ True next-step COO ``(2, E')``.
+ num_nodes : int
+ Node count used to hash pairs.
+
+ Returns
+ -------
+ Tensor
+ Float labels ``(E,)`` in ``{0, 1}``.
+ """
+ if candidate_index.ndim != 2 or int(candidate_index.shape[0]) != 2:
+ msg = (
+ "candidate_index must have shape (2, E), "
+ f"got {tuple(candidate_index.shape)}"
+ )
+ raise ValueError(msg)
+ if true_next_index.ndim != 2 or int(true_next_index.shape[0]) != 2:
+ msg = (
+ "true_next_index must have shape (2, E), "
+ f"got {tuple(true_next_index.shape)}"
+ )
+ raise ValueError(msg)
+ num_edges = int(candidate_index.shape[1])
+ if num_edges == 0:
+ return torch.zeros(0, device=candidate_index.device, dtype=torch.float32)
+ width = int(num_nodes)
+ cand_hash = candidate_index[0] * width + candidate_index[1]
+ if int(true_next_index.shape[1]) == 0:
+ return torch.zeros(
+ num_edges,
+ device=candidate_index.device,
+ dtype=torch.float32,
+ )
+ true_hash = true_next_index[0] * width + true_next_index[1]
+ return torch.isin(cand_hash, true_hash).to(dtype=torch.float32)
+
+
+def dense_offdiag_index(num_nodes: int, *, device: torch.device) -> Tensor:
+ """Return COO of all off-diagonal ordered pairs.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count :math:`N` (at least 2).
+ device : torch.device
+ Output device.
+
+ Returns
+ -------
+ Tensor
+ COO ``(2, N(N-1))``.
+ """
+ n_nodes = int(num_nodes)
+ mask = ~torch.eye(n_nodes, dtype=torch.bool, device=device)
+ src, dst = mask.nonzero(as_tuple=True)
+ return torch.stack([src, dst], dim=0)
+
+
+def decode_weighted_topology(
+ head: PredictedTopologyHead | SparseCandidateTopologyHead,
+ z: Tensor,
+ current_edge_index: Tensor | None,
+) -> tuple[Tensor, Tensor]:
+ """Score :math:`g_\\phi(z)` and return sigmoid weights on a COO index.
+
+ Sparse heads use :func:`build_candidate_index` from ``current_edge_index``.
+ Dense heads emit all off-diagonal pairs. Weights are ``sigmoid(logits)``
+ so the operator step cannot collapse to an empty graph.
+
+ Parameters
+ ----------
+ head : PredictedTopologyHead or SparseCandidateTopologyHead
+ Attached topology head.
+ z : Tensor
+ Node latents ``(N, d)``.
+ current_edge_index : Tensor or None
+ Current COO used to seed sparse candidates.
+
+ Returns
+ -------
+ edge_index : Tensor
+ COO ``(2, E)``.
+ edge_weight : Tensor
+ Sigmoid scores ``(E,)``.
+ """
+ if isinstance(head, SparseCandidateTopologyHead):
+ candidates = build_candidate_index(
+ int(z.shape[0]),
+ head.candidate_k,
+ current_edge_index,
+ device=z.device,
+ )
+ logits = head.pair_logits(z, candidates)
+ return candidates, torch.sigmoid(logits)
+ if isinstance(head, PredictedTopologyHead):
+ logits = head.pairwise_logits(z)
+ index = dense_offdiag_index(int(z.shape[0]), device=z.device)
+ weights = torch.sigmoid(logits[index[0], index[1]])
+ return index, weights
+ msg = (
+ "decode_weighted_topology expects PredictedTopologyHead or "
+ f"SparseCandidateTopologyHead, got {type(head).__name__}"
+ )
+ raise TypeError(msg)
+
+
+def make_recursive_topology_at(
+ head: PredictedTopologyHead | SparseCandidateTopologyHead,
+ origin_index: Tensor,
+) -> Callable[[int, Tensor], tuple[Tensor, Tensor | None]]:
+ """Build ``topology_at(step, latent)`` from :math:`g_\\phi(z_t)`.
+
+ Each call scores the current latent on candidates seeded by the
+ previously emitted COO (origin edges at step 0).
+
+ Parameters
+ ----------
+ head : PredictedTopologyHead or SparseCandidateTopologyHead
+ Topology head.
+ origin_index : Tensor
+ COO of the rollout origin, used as the first candidate seed.
+
+ Returns
+ -------
+ callable
+ ``topology_at(step, latent) -> (edge_index, edge_weight)``.
+ """
+ current_index = origin_index
+
+ def topology_at(step: int, latent: Tensor) -> tuple[Tensor, Tensor | None]:
+ """Return predicted weighted topology for one rollout step.
+
+ Parameters
+ ----------
+ step : int
+ Zero-based rollout step (unused; the latent carries state).
+ latent : Tensor
+ Current node latents.
+
+ Returns
+ -------
+ tuple[Tensor, Tensor or None]
+ Predicted COO and sigmoid weights.
+ """
+ nonlocal current_index
+ del step
+ predicted_index, predicted_weight = decode_weighted_topology(
+ head,
+ latent,
+ current_index,
+ )
+ current_index = predicted_index
+ return predicted_index, predicted_weight
+
+ return topology_at
+
+
+def recursive_training_enabled(model: object) -> bool:
+ """Return whether predicted topology should enter the operator step.
+
+ Parameters
+ ----------
+ model : object
+ Model exposing ``graph_dynamics`` and ``predicted_topology``.
+
+ Returns
+ -------
+ bool
+ ``True`` when a head is attached and ``recursive_training`` is
+ ``True``.
+ """
+ config = getattr(model, "graph_dynamics", None)
+ head = getattr(model, "predicted_topology", None)
+ return config is not None and head is not None and bool(config.recursive_training)
+
+
+def resolve_topology_policy(
+ model: object,
+ topology_policy: TopologyPolicy | str = "auto",
+) -> Literal["recursive", "hold_last"]:
+ """Resolve ``auto`` / ``recursive`` / ``hold_last`` to a concrete policy.
+
+ Parameters
+ ----------
+ model : object
+ Model exposing ``graph_dynamics`` and ``predicted_topology``.
+ topology_policy : {"auto", "recursive", "hold_last"}, optional
+ Requested policy. ``auto`` is recursive when
+ :func:`recursive_training_enabled` is true, else hold-last.
+
+ Returns
+ -------
+ {"recursive", "hold_last"}
+ Concrete rollout policy.
+
+ Raises
+ ------
+ ValueError
+ If ``topology_policy`` is unknown, or ``recursive`` is requested
+ without a topology head.
+ """
+ if topology_policy not in TOPOLOGY_POLICIES:
+ allowed = ", ".join(sorted(TOPOLOGY_POLICIES))
+ msg = f"topology_policy must be one of {{{allowed}}}; got {topology_policy!r}"
+ raise ValueError(msg)
+ if topology_policy == "hold_last":
+ return "hold_last"
+ head = getattr(model, "predicted_topology", None)
+ if topology_policy == "recursive":
+ if head is None:
+ msg = "topology_policy='recursive' requires a predicted topology head"
+ raise ValueError(msg)
+ return "recursive"
+ if recursive_training_enabled(model):
+ return "recursive"
+ return "hold_last"
+
+
+def resolve_rollout_topology_at(
+ model: object,
+ origin_index: Tensor,
+ origin_weight: Tensor | None,
+ future_topologies: Sequence[Data] | None = None,
+ topology_policy: TopologyPolicy | str = "auto",
+) -> Callable[..., tuple[Tensor, Tensor | None]]:
+ """Select hold-last, oracle-future, or recursive predicted topology.
+
+ Oracle ``future_topologies`` always wins. Otherwise the resolved
+ policy chooses :func:`~koopman_graph.graph_utils.hold_last_topology_at`
+ or :func:`make_recursive_topology_at`.
+
+ Parameters
+ ----------
+ model : object
+ Model exposing ``predicted_topology`` / ``graph_dynamics``.
+ origin_index : Tensor
+ Origin COO.
+ origin_weight : Tensor or None
+ Origin edge weights (hold-last / oracle only).
+ future_topologies : sequence of Data or None, optional
+ Oracle future graphs. When set, hold-last-with-futures is used.
+ topology_policy : {"auto", "recursive", "hold_last"}, optional
+ Requested policy. Default ``"auto"``.
+
+ Returns
+ -------
+ callable
+ ``topology_at`` accepted by
+ :func:`~koopman_graph.graph_utils.autoregressive_latent_rollout`.
+ """
+ if future_topologies is not None:
+ return hold_last_topology_at(origin_index, origin_weight, future_topologies)
+ policy = resolve_topology_policy(model, topology_policy)
+ if policy == "hold_last":
+ return hold_last_topology_at(origin_index, origin_weight, None)
+ head = getattr(model, "predicted_topology", None)
+ if head is None:
+ msg = "recursive topology requires a predicted topology head"
+ raise ValueError(msg)
+ if not isinstance(head, (PredictedTopologyHead, SparseCandidateTopologyHead)):
+ msg = (
+ "predicted_topology must be PredictedTopologyHead or "
+ f"SparseCandidateTopologyHead, got {type(head).__name__}"
+ )
+ raise TypeError(msg)
+ return make_recursive_topology_at(head, origin_index)
diff --git a/src/koopman_graph/nn/receptive_field.py b/src/koopman_graph/nn/receptive_field.py
new file mode 100644
index 0000000..ce85527
--- /dev/null
+++ b/src/koopman_graph/nn/receptive_field.py
@@ -0,0 +1,204 @@
+"""Encoder vs discrete-graph-operator receptive-field check.
+
+Compares spatial hop radii. Encoder neighborhood mixing does **not**
+compensate for a one-hop (or shorter) Koopman factor. The helper warns
+and never fails the call. Operators without
+``receptive_field_hops`` (per-node, continuous graph, hetero,
+hypergraph) are skipped. Decoder hops are ignored.
+
+``nn`` duck-types the operator and must **not** import
+:mod:`koopman_graph.operators`.
+"""
+
+from __future__ import annotations
+
+import warnings
+from dataclasses import dataclass
+
+from torch import nn
+
+__all__ = [
+ "ReceptiveFieldMismatchWarning",
+ "ReceptiveFieldReport",
+ "check_encoder_operator_receptive_field",
+]
+
+
+class ReceptiveFieldMismatchWarning(UserWarning):
+ """Encoder hops exceed the discrete graph operator hop degree.
+
+ Emitted by :func:`check_encoder_operator_receptive_field` and
+ :meth:`~koopman_graph.model.GraphKoopmanModel.fit`. Training still
+ proceeds. Encoder neighborhood mixing does not compensate for a
+ one-hop Koopman factor.
+
+ Notes
+ -----
+ Filter with ``warnings.filterwarnings`` on this class. Operators
+ without ``receptive_field_hops`` do not emit the warning.
+ """
+
+
+@dataclass(frozen=True)
+class ReceptiveFieldReport:
+ """Hop counts from :func:`check_encoder_operator_receptive_field`.
+
+ Attributes
+ ----------
+ encoder_hops : int or None
+ Spatial encoder radius, or ``None`` when unknown.
+ operator_hops : int or None
+ Discrete graph operator radius ``P``, or ``None`` when the
+ operator does not expose ``receptive_field_hops``.
+ warned : bool
+ ``True`` when a :class:`ReceptiveFieldMismatchWarning` was emitted.
+ """
+
+ encoder_hops: int | None
+ operator_hops: int | None
+ warned: bool
+
+
+def _as_hop_count(value: object) -> int | None:
+ """Return a non-negative hop integer, or ``None`` if unusable.
+
+ Parameters
+ ----------
+ value : object
+ Candidate hop count.
+
+ Returns
+ -------
+ int or None
+ ``value`` when it is a non-negative ``int`` (not ``bool``).
+ """
+ if isinstance(value, bool) or not isinstance(value, int):
+ return None
+ if value < 0:
+ return None
+ return value
+
+
+def _encoder_hops(encoder: nn.Module) -> int | None:
+ """Infer spatial hops from an encoder (unwrap delay wrappers).
+
+ Parameters
+ ----------
+ encoder : nn.Module
+ Topology-aware encoder or delay wrapper.
+
+ Returns
+ -------
+ int or None
+ Hop radius, or ``None`` when the encoder has no graph radius.
+ """
+ current: nn.Module = encoder
+ for _ in range(8):
+ getter = getattr(current, "receptive_field_hops", None)
+ if callable(getter):
+ try:
+ hops = _as_hop_count(getter())
+ except (TypeError, AttributeError, ValueError):
+ hops = None
+ if hops is not None:
+ return hops
+ diffusion = _as_hop_count(getattr(current, "diffusion_steps", None))
+ layers = _as_hop_count(getattr(current, "num_layers", None))
+ if diffusion is not None and layers is not None:
+ return layers * diffusion
+ if layers is not None:
+ return layers
+ base = getattr(current, "base_encoder", None)
+ if isinstance(base, nn.Module):
+ current = base
+ continue
+ return None
+ return None
+
+
+def _operator_hops(operator: nn.Module) -> int | None:
+ """Return ``operator.receptive_field_hops()`` when that method exists.
+
+ Parameters
+ ----------
+ operator : nn.Module
+ Koopman operator. Duck-typed; no operator-package import.
+
+ Returns
+ -------
+ int or None
+ Operator hop degree, or ``None`` to skip the check.
+ """
+ getter = getattr(operator, "receptive_field_hops", None)
+ if not callable(getter):
+ return None
+ try:
+ return _as_hop_count(getter())
+ except (TypeError, AttributeError, ValueError):
+ return None
+
+
+def check_encoder_operator_receptive_field(
+ encoder: nn.Module,
+ operator: nn.Module,
+ *,
+ stacklevel: int = 2,
+) -> ReceptiveFieldReport:
+ """Warn when encoder hops exceed discrete graph operator hops.
+
+ GCN/GAT/SAGE/Transformer stacks count one hop per layer. DiffConv
+ counts ``num_layers * diffusion_steps``. Delay wrappers contribute
+ no spatial hops. Decoder depth is ignored. Missing hop metadata
+ skips the warning (per-node and non-graph operators).
+
+ Parameters
+ ----------
+ encoder : nn.Module
+ Encoder (or delay wrapper around one).
+ operator : nn.Module
+ Koopman operator. Checked only when it exposes
+ ``receptive_field_hops``.
+ stacklevel : int, optional
+ ``warnings.warn`` stacklevel. Default ``2`` points at the
+ caller. ``GraphKoopmanModel.fit`` passes ``3``.
+
+ Returns
+ -------
+ ReceptiveFieldReport
+ Hop counts and whether a warning was emitted.
+
+ Warnings
+ --------
+ ReceptiveFieldMismatchWarning
+ Encoder hops exceed operator hops. Fit still proceeds. Encoder
+ mixing does not compensate for a one-hop Koopman factor.
+ """
+ encoder_hops = _encoder_hops(encoder)
+ operator_hops = _operator_hops(operator)
+ if encoder_hops is None or operator_hops is None:
+ return ReceptiveFieldReport(
+ encoder_hops=encoder_hops,
+ operator_hops=operator_hops,
+ warned=False,
+ )
+ if encoder_hops <= operator_hops:
+ return ReceptiveFieldReport(
+ encoder_hops=encoder_hops,
+ operator_hops=operator_hops,
+ warned=False,
+ )
+ level = 2 if stacklevel < 1 else int(stacklevel)
+ msg = (
+ f"Encoder receptive field is {encoder_hops} hop(s) "
+ f"({type(encoder).__name__}) but the graph operator mixes "
+ f"{operator_hops} hop(s) ({type(operator).__name__}). "
+ "Encoder neighborhood mixing does not compensate for a "
+ "one-hop Koopman factor; raise koopman_filter_degree or "
+ "reduce encoder depth."
+ )
+ warnings.warn(msg, ReceptiveFieldMismatchWarning, stacklevel=level)
+ return ReceptiveFieldReport(
+ encoder_hops=encoder_hops,
+ operator_hops=operator_hops,
+ warned=True,
+ )
diff --git a/src/koopman_graph/nn/separable.py b/src/koopman_graph/nn/separable.py
new file mode 100644
index 0000000..ff642bc
--- /dev/null
+++ b/src/koopman_graph/nn/separable.py
@@ -0,0 +1,354 @@
+"""Node-wise separable dictionary encoder and decoder.
+
+A dictionary is separable when each node's lift depends only on that
+node's features (Peng, Shen & Zhu, arXiv:2606.17797, Def. 2.2;
+``Peng2026KoopmanGKFA``). Neighbor-mixing GNN encoders are not
+separable. These modules ignore ``edge_index`` for mixing. They are
+not a factory ``encoder="separable"`` kind and are not Koopman-GKFA.
+
+References
+----------
+Peng, C., Shen, X. & Zhu, Y. (2026). Koopman lifting with certified
+error bounds for joint inference in nonlinear networks. *arXiv*
+2606.17797. (``Peng2026KoopmanGKFA``). Provisional preprint. Cited
+for the separable-dictionary homomorphism precondition only.
+"""
+
+from __future__ import annotations
+
+from typing import Literal
+
+from torch import Tensor, nn
+from torch_geometric.data import Data
+
+from koopman_graph.nn.gnn import ActivationName, validate_positive_dims
+
+__all__ = [
+ "SeparableDictionaryDecoder",
+ "SeparableDictionaryEncoder",
+ "is_separable_dictionary",
+]
+
+
+def _activation(name: ActivationName) -> nn.Module:
+ """Return the hidden activation module.
+
+ Parameters
+ ----------
+ name : {"relu", "sigmoid", "tanh"}
+ Activation identifier.
+
+ Returns
+ -------
+ nn.Module
+ Pointwise activation.
+ """
+ if name == "relu":
+ return nn.ReLU()
+ if name == "sigmoid":
+ return nn.Sigmoid()
+ return nn.Tanh()
+
+
+def _nodewise_mlp(
+ in_dim: int,
+ hidden_dim: int,
+ out_dim: int,
+ *,
+ num_layers: int,
+ activation: ActivationName,
+) -> nn.Sequential:
+ """Build a per-node MLP that does not mix graph neighbors.
+
+ Parameters
+ ----------
+ in_dim, hidden_dim, out_dim : int
+ Channel widths.
+ num_layers : int
+ Affine layers. ``1`` is a single linear map.
+ activation : {"relu", "sigmoid", "tanh"}
+ Hidden activation (unused when ``num_layers == 1``).
+
+ Returns
+ -------
+ nn.Sequential
+ Row-wise MLP on ``(N, in_dim)``.
+ """
+ if num_layers == 1:
+ return nn.Sequential(nn.Linear(in_dim, out_dim))
+ layers: list[nn.Module] = [
+ nn.Linear(in_dim, hidden_dim),
+ _activation(activation),
+ ]
+ for _ in range(num_layers - 2):
+ layers.append(nn.Linear(hidden_dim, hidden_dim))
+ layers.append(_activation(activation))
+ layers.append(nn.Linear(hidden_dim, out_dim))
+ return nn.Sequential(*layers)
+
+
+def is_separable_dictionary(module: object) -> bool:
+ """Return whether ``module`` is a node-wise separable dictionary.
+
+ Unwraps ``base_encoder`` (delay wrappers). Default GNN encoders are
+ not separable.
+
+ Parameters
+ ----------
+ module : object
+ Encoder module or ``None``.
+
+ Returns
+ -------
+ bool
+ ``True`` when the lift is :class:`SeparableDictionaryEncoder`
+ (or declares ``encoder_kind="separable"``).
+ """
+ current: object | None = module
+ seen: set[int] = set()
+ while current is not None and id(current) not in seen:
+ seen.add(id(current))
+ if isinstance(current, SeparableDictionaryEncoder):
+ return True
+ kind = getattr(current, "encoder_kind", None)
+ if kind == "separable":
+ return True
+ current = getattr(current, "base_encoder", None)
+ return False
+
+
+class SeparableDictionaryEncoder(nn.Module):
+ """Per-node lift :math:`z_i = \\psi(x_i)` (no neighbor mixing).
+
+ ``encoder_kind`` is ``"separable"``. This is the homomorphism
+ precondition in ``Peng2026KoopmanGKFA``, not a GNN encoder and not a
+ certified Koopman-GKFA implementation.
+
+ Attributes
+ ----------
+ encoder_kind : str
+ Always ``"separable"``.
+ in_channels : int
+ Input feature width per node.
+ hidden_channels : int
+ Hidden MLP width.
+ latent_dim : int
+ Output latent width per node.
+ num_layers : int
+ Number of affine layers.
+ """
+
+ encoder_kind: Literal["separable"] = "separable"
+
+ def __init__(
+ self,
+ in_channels: int,
+ hidden_channels: int,
+ latent_dim: int,
+ *,
+ num_layers: int = 2,
+ activation: ActivationName = "relu",
+ ) -> None:
+ """Initialize a node-wise dictionary encoder.
+
+ Parameters
+ ----------
+ in_channels : int
+ Input feature width.
+ hidden_channels : int
+ Hidden width when ``num_layers > 1``.
+ latent_dim : int
+ Latent width.
+ num_layers : int, optional
+ Affine layers. Default ``2``.
+ activation : {"relu", "sigmoid", "tanh"}, optional
+ Hidden activation. Default ``"relu"``.
+
+ Raises
+ ------
+ ValueError
+ If a dimension is not a positive int.
+ """
+ super().__init__()
+ validate_positive_dims(
+ in_channels=in_channels,
+ hidden_channels=hidden_channels,
+ latent_dim=latent_dim,
+ num_layers=num_layers,
+ )
+ self.in_channels = int(in_channels)
+ self.hidden_channels = int(hidden_channels)
+ self.latent_dim = int(latent_dim)
+ self.num_layers = int(num_layers)
+ self.activation_name = activation
+ self.mlp = _nodewise_mlp(
+ self.in_channels,
+ self.hidden_channels,
+ self.latent_dim,
+ num_layers=self.num_layers,
+ activation=activation,
+ )
+
+ def receptive_field_hops(self) -> int:
+ """Return zero graph hops (node-wise lift).
+
+ Returns
+ -------
+ int
+ ``0``.
+ """
+ return 0
+
+ def forward(
+ self,
+ x_or_data: Tensor | Data,
+ edge_index: Tensor | None = None,
+ edge_weight: Tensor | None = None,
+ ) -> Tensor:
+ """Lift each node independently.
+
+ ``edge_index`` / ``edge_weight`` are accepted for encoder API
+ symmetry and are not used.
+
+ Parameters
+ ----------
+ x_or_data : Tensor or Data
+ Node features ``(N, in_channels)`` or a ``Data`` snapshot.
+ edge_index : Tensor or None, optional
+ Ignored.
+ edge_weight : Tensor or None, optional
+ Ignored.
+
+ Returns
+ -------
+ Tensor
+ Latents ``(N, latent_dim)``.
+
+ Raises
+ ------
+ ValueError
+ If feature rank or width is wrong.
+ """
+ del edge_index, edge_weight
+ x = x_or_data.x if isinstance(x_or_data, Data) else x_or_data
+ if x.ndim != 2 or int(x.shape[1]) != self.in_channels:
+ msg = (
+ "SeparableDictionaryEncoder expects features "
+ f"(N, {self.in_channels}), got {tuple(x.shape)}"
+ )
+ raise ValueError(msg)
+ return self.mlp(x)
+
+
+class SeparableDictionaryDecoder(nn.Module):
+ """Per-node map :math:`\\hat x_i = \\psi^{-1}(z_i)` (no neighbor mixing).
+
+ Matching decoder for :class:`SeparableDictionaryEncoder`. Topology
+ arguments are ignored.
+
+ Attributes
+ ----------
+ latent_dim : int
+ Input latent width per node.
+ hidden_channels : int
+ Hidden MLP width.
+ out_channels : int
+ Reconstructed feature width per node.
+ num_layers : int
+ Number of affine layers.
+ """
+
+ def __init__(
+ self,
+ latent_dim: int,
+ hidden_channels: int,
+ out_channels: int,
+ *,
+ num_layers: int = 2,
+ activation: ActivationName = "relu",
+ ) -> None:
+ """Initialize a node-wise dictionary decoder.
+
+ Parameters
+ ----------
+ latent_dim : int
+ Latent width.
+ hidden_channels : int
+ Hidden width when ``num_layers > 1``.
+ out_channels : int
+ Output feature width.
+ num_layers : int, optional
+ Affine layers. Default ``2``.
+ activation : {"relu", "sigmoid", "tanh"}, optional
+ Hidden activation. Default ``"relu"``.
+
+ Raises
+ ------
+ ValueError
+ If a dimension is not a positive int.
+ """
+ super().__init__()
+ validate_positive_dims(
+ latent_dim=latent_dim,
+ hidden_channels=hidden_channels,
+ out_channels=out_channels,
+ num_layers=num_layers,
+ )
+ self.latent_dim = int(latent_dim)
+ self.hidden_channels = int(hidden_channels)
+ self.out_channels = int(out_channels)
+ self.num_layers = int(num_layers)
+ self.activation_name = activation
+ self.mlp = _nodewise_mlp(
+ self.latent_dim,
+ self.hidden_channels,
+ self.out_channels,
+ num_layers=self.num_layers,
+ activation=activation,
+ )
+
+ def receptive_field_hops(self) -> int:
+ """Return zero graph hops (node-wise map).
+
+ Returns
+ -------
+ int
+ ``0``.
+ """
+ return 0
+
+ def forward(
+ self,
+ z: Tensor,
+ edge_index: Tensor | None = None,
+ edge_weight: Tensor | None = None,
+ ) -> Tensor:
+ """Decode each node independently.
+
+ Parameters
+ ----------
+ z : Tensor
+ Latents ``(N, latent_dim)``.
+ edge_index : Tensor or None, optional
+ Ignored.
+ edge_weight : Tensor or None, optional
+ Ignored.
+
+ Returns
+ -------
+ Tensor
+ Features ``(N, out_channels)``.
+
+ Raises
+ ------
+ ValueError
+ If latent rank or width is wrong.
+ """
+ del edge_index, edge_weight
+ if z.ndim != 2 or int(z.shape[1]) != self.latent_dim:
+ msg = (
+ "SeparableDictionaryDecoder expects latents "
+ f"(N, {self.latent_dim}), got {tuple(z.shape)}"
+ )
+ raise ValueError(msg)
+ return self.mlp(z)
diff --git a/src/koopman_graph/operators/__init__.py b/src/koopman_graph/operators/__init__.py
index 01a403a..8eff3ba 100644
--- a/src/koopman_graph/operators/__init__.py
+++ b/src/koopman_graph/operators/__init__.py
@@ -45,7 +45,19 @@
/ structural / propagation peers and thin orchestration.
``graph``
:class:`~koopman_graph.operators.graph.GraphKoopmanOperator` (spatially
- coupled discrete advance).
+ coupled discrete advance; optional monomial ``filter_degree``).
+``polynomial_graph``
+ Dense monomial Kronecker assembly and repeated-adjacency matvec
+ helpers used by the graph operator when ``filter_degree>1`` (not a
+ second public operator class; not in package ``__all__``).
+``linear``
+ :class:`~koopman_graph.operators.LinearOperatorProtocol` plus
+ polynomial-graph and one-tap
+ :class:`~koopman_graph.operators.MatrixFreeGraphLinearOperator`
+ wrappers (off root ``__all__``). Dense assembly is refused above
+ :data:`~koopman_graph.operators.MAX_DENSE_LINEAR_OPERATOR_SIZE`.
+ Trainer DDP does **not** shrink that representation. Exact
+ Kronecker spectrum remains a special case.
``graph_inverse``
Block-diagonal / Jacobi approximate ``inverse_advance`` helpers for
graph / hypergraph ``sparsity="block_diagonal"`` (no ``graph/`` subtree).
@@ -63,14 +75,18 @@
extras; does **not** enable multi-GPU training).
``kronecker_spectrum``
Exact Kronecker-sum spectrum reduction for eligible networked
- operators (``I⊗M_self + Â⊗M_nbr`` for discrete ``K`` or continuous
- generator ``L``). Auto-routed from
+ operators. Discrete graph uses the polynomial pencil
+ :math:`B(\\lambda)=\\sum_k\\lambda^k K_k` (one-tap when ``P=1``);
+ continuous graph stays the one-tap generator
+ ``I⊗L_self + Â⊗L_nbr``. Auto-routed from
:meth:`~koopman_graph.operators.graph.GraphKoopmanOperator.spectrum`
and
:meth:`~koopman_graph.operators.continuous_graph.ContinuousGraphKoopmanOperator.spectrum`
when eligible; also available as a power-user import from
``koopman_graph.operators.kronecker_spectrum`` (not in package
``__all__``). Distinct from Arnoldi ``matrix_free`` surrogates.
+ Dual adjacency raises on the helper; operator ``.spectrum``
+ dense-routes instead.
``global_local``
:class:`~koopman_graph.operators.global_local.GlobalLocalKoopmanOperator`
(discrete global backbone + low-rank local window correction).
@@ -84,19 +100,48 @@
+ ``dynamics_mode="continuous"``).
``switched``
:class:`~koopman_graph.operators.switched.SwitchedKoopmanOperator`
- (finite bank of LTI maps; ``koopman="switched"``).
+ (finite bank of LTI maps; ``koopman="switched"``). Optional
+ per-step ``phase_index`` does not mutate ``mode_index``. Not
+ :math:`K(\\mu)`.
``mixture``
:class:`~koopman_graph.operators.mixture.MixtureKoopmanOperator`
- (softmax mixture of LTI maps; ``koopman="mixture"``).
+ (softmax mixture of LTI maps; ``koopman="mixture"``). Not
+ :math:`K(\\mu)`.
+``parametric``
+ :class:`~koopman_graph.operators.parametric.ParametricKoopmanOperator`
+ (discrete interpolant :math:`K(\\mu)=\\sum_j \\alpha_j(\\mu) K_j`;
+ ``koopman="parametric"``). Distinct from switched / mixture.
+ Leave-one-regime-out helper
+ :func:`~koopman_graph.operators.leave_one_regime_out` is a package
+ export, not in root ``__all__``.
``hodge``
:class:`~koopman_graph.operators.hodge.HodgeKoopmanOperator`
(Laplacian-structured neighbor term; ``koopman="hodge"``).
+``cochain``
+ :class:`~koopman_graph.operators.CochainState` and
+ :class:`~koopman_graph.operators.CochainKoopmanOperator`
+ (degree-specific :math:`k\\le 1` maps on a static signed
+ :math:`B_1`). Not a factory kind. Distinct from
+ ``koopman="hodge"``. Face latents are stored, not evolved.
+ :func:`~koopman_graph.operators.boundary_nilpotency` scores
+ :math:`B_1 B_2\\approx 0` on caller-supplied incidences.
``equivariant``
:class:`~koopman_graph.operators.equivariant.EquivariantKoopmanOperator`
- (scalar plus vector blocks; ``[equivariance]``).
+ (scalar, ``scale * I_3`` vector, and ``scale * I_5`` :math:`l=2`
+ tensor blocks). Not a factory kind. Rotation tests use
+ ``[equivariance]`` / ``e3nn``; the operator leaf does not import
+ ``e3nn``. Not a molecular MD stack.
``graphon``
:func:`~koopman_graph.operators.graphon.sample_graphon_adjacency`
- for transfer experiments at multiple :math:`N`.
+ and :func:`~koopman_graph.operators.graphon.estimate_graphon` for
+ dense teaching kernels at multiple :math:`N`.
+``stochastic``
+ Diagonal process-noise helpers for ``dynamics_mode="stochastic"``.
+``stochastic_sde``
+ :class:`~koopman_graph.operators.DriftDiffusionKoopman` opt-in
+ Euler–Maruyama / Yosida stepper. Not a factory kind. Default
+ ``dynamics_mode="stochastic"`` stays diagonal process noise. Not
+ certified SDE theory.
Prefer ``from koopman_graph import KoopmanOperator, ContinuousKoopmanOperator,
GraphKoopmanOperator, HypergraphKoopmanOperator, GlobalLocalKoopmanOperator,
@@ -111,6 +156,13 @@
normalize_auxiliary_hidden_dims,
spectral_output_dim,
)
+from koopman_graph.operators.cochain import (
+ DEFAULT_NILPOTENCY_ATOL,
+ BoundaryNilpotencyReport,
+ CochainKoopmanOperator,
+ CochainState,
+ boundary_nilpotency,
+)
from koopman_graph.operators.continuous import (
VAN_LOAN_WRITEBACK_ATOL,
ContinuousKoopmanOperator,
@@ -157,13 +209,26 @@
)
from koopman_graph.operators.graph import GraphKoopmanOperator
from koopman_graph.operators.graph_types import GraphAdjacency, GraphSparsity
-from koopman_graph.operators.graphon import sample_graphon_adjacency
+from koopman_graph.operators.graphon import (
+ MAX_GRAPHON_NODES,
+ GraphonEstimate,
+ estimate_graphon,
+ sample_graphon_adjacency,
+)
from koopman_graph.operators.heterogeneous import HeteroGraphKoopmanOperator
from koopman_graph.operators.hodge import HodgeKoopmanOperator
from koopman_graph.operators.hypergraph import (
HypergraphKoopmanOperator,
HypergraphSparsity,
)
+from koopman_graph.operators.linear import (
+ MAX_DENSE_LINEAR_OPERATOR_SIZE,
+ EigResult,
+ LinearOperatorProtocol,
+ MatrixFreeGraphLinearOperator,
+ MemoryEstimate,
+ PolynomialGraphLinearOperator,
+)
from koopman_graph.operators.matrix_free import (
DEFAULT_DISTRIBUTED_EIGREG_NUM_MODES,
DEFAULT_DISTRIBUTED_SPECTRUM_NUM_MODES,
@@ -185,6 +250,11 @@
unflatten_node_latents,
)
from koopman_graph.operators.mixture import MixtureKoopmanOperator
+from koopman_graph.operators.parametric import (
+ LeaveOneRegimeOutReport,
+ ParametricKoopmanOperator,
+ leave_one_regime_out,
+)
from koopman_graph.operators.sparse_backend import sparse_leading_eigenvalues
from koopman_graph.operators.stochastic import (
apply_process_noise,
@@ -192,6 +262,7 @@
diagonal_process_covariance,
maybe_apply_process_noise,
)
+from koopman_graph.operators.stochastic_sde import DriftDiffusionKoopman
from koopman_graph.operators.switched import (
DEFAULT_NUM_MODES,
SwitchedKoopmanOperator,
@@ -199,6 +270,10 @@
__all__ = [
"AuxiliarySpectralNetwork",
+ "BoundaryNilpotencyReport",
+ "CochainKoopmanOperator",
+ "CochainState",
+ "DEFAULT_NILPOTENCY_ATOL",
"ContinuousGraphKoopmanOperator",
"ContinuousGraphSparsity",
"ContinuousHeteroGraphKoopmanOperator",
@@ -215,13 +290,16 @@
"DEFAULT_MATRIX_FREE_SPECTRUM_TOL",
"DEFAULT_NUM_MODES",
"DISSIPATIVE_MIN_EIGENVALUE",
+ "DriftDiffusionKoopman",
"DynamicsMode",
+ "EigResult",
"EquivariantKoopmanOperator",
"GeneratorParameterization",
"GlobalLocalKoopmanOperator",
"GraphAdjacency",
"GraphKoopmanOperator",
"GraphSparsity",
+ "GraphonEstimate",
"HeteroGraphKoopmanOperator",
"HodgeKoopmanOperator",
"HypergraphKoopmanOperator",
@@ -230,10 +308,18 @@
"KoopmanKind",
"KoopmanOperator",
"KoopmanOperatorContract",
+ "LinearOperatorProtocol",
+ "MAX_DENSE_LINEAR_OPERATOR_SIZE",
+ "MatrixFreeGraphLinearOperator",
"MatrixFreeInverseResult",
"MatrixFreeSpectrumResult",
+ "MAX_GRAPHON_NODES",
+ "MemoryEstimate",
"MixtureKoopmanOperator",
+ "ParametricKoopmanOperator",
+ "LeaveOneRegimeOutReport",
"Parameterization",
+ "PolynomialGraphLinearOperator",
"STABILITY_EPS_MARGIN",
"StabilityCertificate",
"SwitchedKoopmanOperator",
@@ -244,14 +330,17 @@
"apply_process_noise",
"assemble_block_diagonal_generator",
"attach_process_noise",
+ "boundary_nilpotency",
"bounded_diagonal",
"build_stability_certificate",
"cayley_orthogonal",
"diagonal_process_covariance",
+ "estimate_graphon",
"flatten_node_latents",
"invert_k_eff_graph",
"invert_k_eff_hetero",
"invert_k_eff_hypergraph",
+ "leave_one_regime_out",
"matrix_log",
"maybe_apply_process_noise",
"normalize_auxiliary_hidden_dims",
diff --git a/src/koopman_graph/operators/cochain.py b/src/koopman_graph/operators/cochain.py
new file mode 100644
index 0000000..6d990f0
--- /dev/null
+++ b/src/koopman_graph/operators/cochain.py
@@ -0,0 +1,433 @@
+"""Degree-specific cochain Koopman maps for k<=1.
+
+:class:`CochainState` holds node (0-cochain) and edge (1-cochain) latents.
+:class:`CochainKoopmanOperator` advances each degree with a feature-axis
+map :math:`K_k` and optional incidence cross-degree terms. Incidence is
+the static signed :math:`B_1` from
+:func:`~koopman_graph.observables.boundary_incidence_b1`.
+
+This is **not** :class:`~koopman_graph.operators.HodgeKoopmanOperator`
+(node Laplacian neighbor term; ``koopman="hodge"``), **not** a factory
+kind, and **not** TopologicX / sheaf parity (``TopoX2024``,
+``Lim2020Hodge``). Face latents may be stored; :math:`k=2` is not
+evolved.
+
+Feature-axis intertwining :math:`d_0(z_0 K_0^{\\top})\\approx
+(d_0 z_0)K_1^{\\top}` with :math:`d_0=B_1^{\\top}` reduces to
+:math:`K_0\\approx K_1` on the image of :math:`d_0`. That residual is
+exposed as a loss. Boundary nilpotency :math:`B_1 B_2\\approx 0` is
+checked on caller-supplied incidences (``Lim2020Hodge``).
+
+This module must not import :mod:`koopman_graph.model`,
+:mod:`koopman_graph.data`, or :mod:`koopman_graph.nn`.
+"""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass
+
+import torch
+from torch import Tensor, nn
+
+from koopman_graph.observables import boundary_incidence_b1
+
+DEFAULT_NILPOTENCY_ATOL = 1e-6
+
+__all__ = [
+ "DEFAULT_NILPOTENCY_ATOL",
+ "BoundaryNilpotencyReport",
+ "CochainKoopmanOperator",
+ "CochainState",
+ "boundary_nilpotency",
+]
+
+
+def _require_feature_matrix(name: str, value: Tensor) -> None:
+ """Refuse empty, non-real, or non-finite feature tables.
+
+ Parameters
+ ----------
+ name : str
+ Field name for the error message.
+ value : Tensor
+ Candidate ``(n_cells, d)`` table.
+
+ Raises
+ ------
+ TypeError
+ If ``value`` is not a tensor.
+ ValueError
+ If rank, dtype, or finiteness is invalid.
+ """
+ if not isinstance(value, Tensor):
+ msg = f"{name} must be a Tensor, got {type(value).__name__}"
+ raise TypeError(msg)
+ if value.is_complex():
+ raise ValueError(f"{name} must be real")
+ if not value.is_floating_point():
+ raise ValueError(f"{name} must be a floating-point tensor")
+ if int(value.ndim) != 2 or int(value.shape[0]) < 0:
+ raise ValueError(
+ f"{name} must have shape (n_cells, d), got {tuple(value.shape)}"
+ )
+ if int(value.shape[1]) < 1:
+ raise ValueError(f"{name} latent width must be >= 1, got {tuple(value.shape)}")
+ if not bool(torch.isfinite(value).all().item()):
+ raise ValueError(f"{name} must be finite")
+
+
+@dataclass(frozen=True)
+class CochainState:
+ """Typed :math:`k`-cochain latents for :math:`k\\in\\{0,1\\}`.
+
+ Attributes
+ ----------
+ node : Tensor
+ 0-cochain latents with shape ``(N, d)``.
+ edge : Tensor
+ 1-cochain latents with shape ``(E, d)``. Same trailing width as
+ ``node``.
+ face : Tensor or None
+ Optional 2-cochain table ``(F, d2)``. Stored only; the operator
+ does not advance :math:`k=2`.
+ """
+
+ node: Tensor
+ edge: Tensor
+ face: Tensor | None = None
+
+ def __post_init__(self) -> None:
+ """Validate node / edge tables and an optional face table.
+
+ Raises
+ ------
+ TypeError
+ If a field is not a tensor.
+ ValueError
+ If shapes, dtypes, or finiteness are invalid.
+ """
+ _require_feature_matrix("node", self.node)
+ _require_feature_matrix("edge", self.edge)
+ if int(self.node.shape[1]) != int(self.edge.shape[1]):
+ msg = (
+ "node and edge must share latent width d, "
+ f"got {tuple(self.node.shape)} and {tuple(self.edge.shape)}"
+ )
+ raise ValueError(msg)
+ if self.face is not None:
+ _require_feature_matrix("face", self.face)
+
+
+@dataclass(frozen=True)
+class BoundaryNilpotencyReport:
+ """Boundary product :math:`B_1 B_2` and a documented tolerance flag.
+
+ Attributes
+ ----------
+ product : Tensor
+ :math:`B_1 B_2` with shape ``(N, F)``. Dimensionless.
+ max_abs : float
+ :math:`\\|B_1 B_2\\|_{\\infty}` over entries.
+ atol : float
+ Absolute tolerance used for ``nilpotent``. Default matches the
+ float32 teaching triangle (``1e-6``).
+ nilpotent : bool
+ ``True`` when ``max_abs <= atol``.
+ """
+
+ product: Tensor
+ max_abs: float
+ atol: float
+ nilpotent: bool
+
+ def __post_init__(self) -> None:
+ """Validate the product table and tolerance.
+
+ Raises
+ ------
+ ValueError
+ If the product is empty or the scalars are invalid.
+ """
+ if self.product.ndim != 2:
+ msg = f"product must have shape (N, F), got {tuple(self.product.shape)}"
+ raise ValueError(msg)
+ if not math.isfinite(self.max_abs) or self.max_abs < 0.0:
+ raise ValueError(
+ f"max_abs must be a finite non-negative float, got {self.max_abs!r}"
+ )
+ if not math.isfinite(self.atol) or self.atol < 0.0:
+ raise ValueError(
+ f"atol must be a finite non-negative float, got {self.atol!r}"
+ )
+
+
+def boundary_nilpotency(
+ b1: Tensor,
+ b2: Tensor,
+ *,
+ atol: float = DEFAULT_NILPOTENCY_ATOL,
+) -> BoundaryNilpotencyReport:
+ """Score :math:`B_1 B_2 \\approx 0` on caller-supplied incidences.
+
+ :math:`B_1` has shape ``(N, E)`` and :math:`B_2` has shape
+ ``(E, F)``. The product is the chain-complex form of
+ :math:`\\partial\\partial=0` (``Lim2020Hodge``). This helper does
+ not build :math:`B_2`; use
+ :func:`~koopman_graph.nn.cell_complex.boundary_incidence_b2` in
+ tests or callers.
+
+ Parameters
+ ----------
+ b1 : Tensor
+ Signed node–edge incidence ``(N, E)``.
+ b2 : Tensor
+ Signed edge–face incidence ``(E, F)``.
+ atol : float, optional
+ Absolute entrywise tolerance. Default is ``1e-6``.
+
+ Returns
+ -------
+ BoundaryNilpotencyReport
+ Product, max absolute entry, and flag.
+
+ Raises
+ ------
+ TypeError
+ If an argument is not a tensor.
+ ValueError
+ If shapes, dtypes, or ``atol`` are invalid.
+ """
+ if not isinstance(b1, Tensor) or not isinstance(b2, Tensor):
+ raise TypeError("b1 and b2 must be tensors")
+ if b1.ndim != 2 or b2.ndim != 2:
+ msg = f"b1 and b2 must be 2-D, got {tuple(b1.shape)} and {tuple(b2.shape)}"
+ raise ValueError(msg)
+ if int(b1.shape[1]) != int(b2.shape[0]):
+ msg = (
+ "b1 columns must match b2 rows (shared E), "
+ f"got {tuple(b1.shape)} and {tuple(b2.shape)}"
+ )
+ raise ValueError(msg)
+ if not bool(torch.isfinite(b1).all().item()) or not bool(
+ torch.isfinite(b2).all().item()
+ ):
+ raise ValueError("b1 and b2 must be finite")
+ if not math.isfinite(float(atol)) or float(atol) < 0.0:
+ raise ValueError(f"atol must be a finite non-negative float, got {atol!r}")
+ product = b1 @ b2
+ max_abs = float(product.abs().max().item()) if int(product.numel()) else 0.0
+ return BoundaryNilpotencyReport(
+ product=product,
+ max_abs=max_abs,
+ atol=float(atol),
+ nilpotent=max_abs <= float(atol),
+ )
+
+
+class CochainKoopmanOperator(nn.Module):
+ """Advance 0- and 1-cochain latents on a static signed incidence.
+
+ Row-vector convention matches discrete operators
+ (:math:`z\\mapsto z K^{\\top}`). Default maps are
+ :math:`\\tfrac12 I`. Optional cross-degree terms use the bound
+ :math:`B_1`:
+
+ .. math::
+
+ z_0^{+} = z_0 K_0^{\\top} + (B_1 z_1) C_{0\\leftarrow 1}^{\\top}
+
+ z_1^{+} = z_1 K_1^{\\top} + (B_1^{\\top} z_0) C_{1\\leftarrow 0}^{\\top}
+
+ Parameters
+ ----------
+ latent_dim : int
+ Shared feature width :math:`d` for node and edge latents.
+ edge_index : Tensor
+ Oriented 1-cells ``(2, E)``. One column per undirected edge.
+ num_nodes : int
+ Number of 0-cells :math:`N`.
+ use_cross_degree : bool, optional
+ If ``True``, apply the incidence cross terms. Default ``False``.
+
+ Notes
+ -----
+ Distinct from ``koopman="hodge"``. Not a factory kind. No checkpoint
+ key. Combinatorial incidence only — not a sheaf Laplacian
+ (``Lim2020Hodge``, ``TopoX2024``).
+
+ References
+ ----------
+ Lim, L.-H. (2020). Hodge Laplacians on graphs. *SIAM Review*
+ 62:685–715. doi:10.1137/18M1223101 (``Lim2020Hodge``).
+ Hajij, M. et al. (2024). TopoX: a suite of Python packages for
+ machine learning on topological domains. arXiv:2402.02441
+ (``TopoX2024``).
+ """
+
+ def __init__(
+ self,
+ latent_dim: int,
+ edge_index: Tensor,
+ *,
+ num_nodes: int,
+ use_cross_degree: bool = False,
+ ) -> None:
+ """Allocate per-degree maps and bind a static :math:`B_1`.
+
+ Parameters
+ ----------
+ latent_dim : int
+ Shared feature width.
+ edge_index : Tensor
+ Oriented edges ``(2, E)``.
+ num_nodes : int
+ Node count.
+ use_cross_degree : bool, optional
+ Enable incidence cross terms. Default ``False``.
+
+ Raises
+ ------
+ ValueError
+ If dimensions or ``edge_index`` are invalid.
+ """
+ super().__init__()
+ if isinstance(latent_dim, bool) or not isinstance(latent_dim, int):
+ raise ValueError(f"latent_dim must be a positive int, got {latent_dim!r}")
+ if int(latent_dim) < 1:
+ raise ValueError(f"latent_dim must be >= 1, got {latent_dim}")
+ if isinstance(num_nodes, bool) or not isinstance(num_nodes, int):
+ raise ValueError(f"num_nodes must be a positive int, got {num_nodes!r}")
+ if int(num_nodes) < 1:
+ raise ValueError(f"num_nodes must be >= 1, got {num_nodes}")
+ if not isinstance(use_cross_degree, bool):
+ msg = f"use_cross_degree must be a bool, got {use_cross_degree!r}"
+ raise ValueError(msg)
+ width = int(latent_dim)
+ nodes = int(num_nodes)
+ incidence = boundary_incidence_b1(edge_index, num_nodes=nodes)
+ self.latent_dim = width
+ self.num_nodes = nodes
+ self.num_edges = int(incidence.shape[1])
+ self.use_cross_degree = bool(use_cross_degree)
+ self.register_buffer("incidence", incidence)
+ self.k_node = nn.Parameter(0.5 * torch.eye(width))
+ self.k_edge = nn.Parameter(0.5 * torch.eye(width))
+ self.c_node_from_edge = nn.Parameter(torch.zeros(width, width))
+ self.c_edge_from_node = nn.Parameter(torch.zeros(width, width))
+
+ def _incidence_for(self, state: CochainState) -> Tensor:
+ """Return :math:`B_1` on the state's device and dtype.
+
+ Parameters
+ ----------
+ state : CochainState
+ Current cochains.
+
+ Returns
+ -------
+ Tensor
+ Incidence ``(N, E)``.
+
+ Raises
+ ------
+ ValueError
+ If cell counts do not match the bound incidence.
+ """
+ if int(state.node.shape[0]) != self.num_nodes:
+ msg = (
+ "state.node rows must equal num_nodes "
+ f"{self.num_nodes}, got {tuple(state.node.shape)}"
+ )
+ raise ValueError(msg)
+ if int(state.edge.shape[0]) != self.num_edges:
+ msg = (
+ "state.edge rows must equal num_edges "
+ f"{self.num_edges}, got {tuple(state.edge.shape)}"
+ )
+ raise ValueError(msg)
+ if int(state.node.shape[1]) != self.latent_dim:
+ msg = (
+ "state latent width must equal latent_dim "
+ f"{self.latent_dim}, got {tuple(state.node.shape)}"
+ )
+ raise ValueError(msg)
+ return self.incidence.to(dtype=state.node.dtype, device=state.node.device)
+
+ def commuting_residual(self, state: CochainState) -> Tensor:
+ """Return :math:`d_0(z_0 K_0^{\\top})-(d_0 z_0)K_1^{\\top}`.
+
+ :math:`d_0=B_1^{\\top}` acts on cells; :math:`K_k` acts on
+ features. The residual vanishes when :math:`K_0=K_1`.
+
+ Parameters
+ ----------
+ state : CochainState
+ Current 0-cochain used to form :math:`d_0 z_0`.
+
+ Returns
+ -------
+ Tensor
+ Residual with shape ``(E, d)`` in latent units.
+ """
+ incidence = self._incidence_for(state)
+ coboundary = incidence.transpose(0, 1) @ state.node
+ left = incidence.transpose(0, 1) @ (state.node @ self.k_node.transpose(-1, -2))
+ right = coboundary @ self.k_edge.transpose(-1, -2)
+ return left - right
+
+ def commuting_loss(self, state: CochainState) -> Tensor:
+ """Mean squared intertwining residual.
+
+ Parameters
+ ----------
+ state : CochainState
+ Current 0-cochain.
+
+ Returns
+ -------
+ Tensor
+ Scalar mean square of :meth:`commuting_residual`.
+ """
+ residual = self.commuting_residual(state)
+ return residual.square().mean()
+
+ def advance(self, state: CochainState) -> CochainState:
+ """Apply one per-degree step on the bound incidence.
+
+ Parameters
+ ----------
+ state : CochainState
+ Current node / edge latents. ``face`` is copied through.
+
+ Returns
+ -------
+ CochainState
+ Next 0- and 1-cochains. Face latents are unchanged.
+ """
+ incidence = self._incidence_for(state)
+ node_next = state.node @ self.k_node.transpose(-1, -2)
+ edge_next = state.edge @ self.k_edge.transpose(-1, -2)
+ if self.use_cross_degree:
+ node_next = node_next + (
+ incidence @ state.edge
+ ) @ self.c_node_from_edge.transpose(-1, -2)
+ edge_next = edge_next + (
+ incidence.transpose(0, 1) @ state.node
+ ) @ self.c_edge_from_node.transpose(-1, -2)
+ return CochainState(node=node_next, edge=edge_next, face=state.face)
+
+ def forward(self, state: CochainState) -> CochainState:
+ """Alias of :meth:`advance`.
+
+ Parameters
+ ----------
+ state : CochainState
+ Current cochains.
+
+ Returns
+ -------
+ CochainState
+ Next cochains.
+ """
+ return self.advance(state)
diff --git a/src/koopman_graph/operators/continuous_van_loan.py b/src/koopman_graph/operators/continuous_van_loan.py
index 74682ea..8cdd2d2 100644
--- a/src/koopman_graph/operators/continuous_van_loan.py
+++ b/src/koopman_graph/operators/continuous_van_loan.py
@@ -10,16 +10,106 @@
from __future__ import annotations
+import math
+from typing import Literal
+
import torch
from torch import Tensor
+from koopman_graph.spectrum_types import (
+ DefectiveSpectrumError,
+ _column_normalized_condition,
+)
+
# Default absolute tolerance for Van Loan discrete↔generator round-trips in tests
# and documented adaptation fidelity checks (float32 matrix-exp / logm residual).
VAN_LOAN_WRITEBACK_ATOL = 1e-5
+DefectiveLogMode = Literal["error", "schur"]
+
+_DEFECTIVE_LOG_MESSAGE = (
+ "Eigenvector matrix is singular (defective or numerically "
+ "non-diagonalizable). matrix_log does not invert V; pass "
+ "defective='schur' for a SciPy Schur-Pade logarithm (CPU), "
+ "or use a Schur invariant subspace. This is not a claim that "
+ "defectivity marks every critical regime."
+)
-def matrix_log(matrix: Tensor) -> Tensor:
- """Return the principal matrix logarithm via complex eigendecomposition.
+
+def _resolve_log_rcond(matrix: Tensor, rcond: float | None) -> float:
+ """Return a positive reciprocal-condition cutoff for the eigenbasis.
+
+ Parameters
+ ----------
+ matrix : Tensor
+ Square matrix whose logarithm is requested.
+ rcond : float or None
+ Caller cutoff, or ``None`` for ``n * eps`` of the real dtype.
+
+ Returns
+ -------
+ float
+ Positive cutoff. The eigendecomposition path treats
+ :math:`\\kappa(V) > 1/\\texttt{rcond}` as defective.
+
+ Raises
+ ------
+ ValueError
+ If ``rcond`` is set but is not a finite value > 0.
+ """
+ if rcond is None:
+ real_dtype = matrix.real.dtype if matrix.is_complex() else matrix.dtype
+ return float(matrix.shape[0]) * float(torch.finfo(real_dtype).eps)
+ value = float(rcond)
+ if not math.isfinite(value) or value <= 0.0:
+ msg = f"rcond must be a finite value > 0 when set, got {rcond}"
+ raise ValueError(msg)
+ return value
+
+
+def _matrix_log_schur(matrix: Tensor) -> Tensor:
+ """Principal matrix logarithm via SciPy Schur-Pade ``logm`` (CPU).
+
+ Parameters
+ ----------
+ matrix : Tensor
+ Square matrix with shape ``(d, d)``.
+
+ Returns
+ -------
+ Tensor
+ Matrix logarithm on ``matrix.device``. Real inputs return the
+ real part, matching the eigendecomposition path.
+
+ Raises
+ ------
+ ImportError
+ If SciPy is not installed.
+ """
+ try:
+ from scipy.linalg import logm
+ except ImportError as exc:
+ msg = (
+ "matrix_log(..., defective='schur') requires SciPy. "
+ "Install with: pip install scipy or pip install "
+ "'koopman-graph[dev]' or 'koopman-graph[mpc]'"
+ )
+ raise ImportError(msg) from exc
+
+ array = matrix.detach().cpu().numpy()
+ logged = torch.as_tensor(logm(array), device=matrix.device)
+ if matrix.is_complex():
+ return logged.to(dtype=matrix.dtype)
+ return logged.real.to(dtype=matrix.dtype)
+
+
+def matrix_log(
+ matrix: Tensor,
+ *,
+ defective: DefectiveLogMode = "error",
+ rcond: float | None = None,
+) -> Tensor:
+ """Return the principal matrix logarithm of a square matrix.
For diagonalizable ``M = V \\operatorname{diag}(\\lambda) V^{-1}``,
@@ -31,12 +121,19 @@ def matrix_log(matrix: Tensor) -> Tensor:
``result.real`` (callers should keep spectra away from the negative-real
branch cut when a real logarithm is required).
+ When the eigenbasis is numerically singular, the default
+ ``defective="error"`` raises
+ :class:`~koopman_graph.spectrum_types.DefectiveSpectrumError` rather
+ than inverting :math:`V`. Opt-in ``defective="schur"`` uses SciPy
+ ``logm`` (Al-Mohy–Higham inverse scaling and squaring / Padé on a
+ Schur form; CPU NumPy round-trip). That path is **not** a claim that
+ defectivity marks every critical regime.
+
Limitations
-----------
- - Non-diagonalizable matrices are not handled (Jordan blocks need a
- different formula).
- Eigenvalues on or near the negative real axis can make the principal
log complex; discarding the imaginary part is then approximate.
+ - ``defective="schur"`` requires SciPy and runs on CPU.
- Used by Van Loan inversion and continuous RLS write-back; prefer
well-conditioned generators with moderate ``Δt``.
@@ -44,16 +141,57 @@ def matrix_log(matrix: Tensor) -> Tensor:
----------
matrix : Tensor
Square matrix with shape ``(d, d)``.
+ defective : {"error", "schur"}, optional
+ ``"error"`` (default) raises when :math:`V` is singular beyond
+ ``rcond``. ``"schur"`` delegates to SciPy ``logm``.
+ rcond : float or None, optional
+ Reciprocal-condition cutoff. ``None`` (default) uses
+ ``n * eps`` of the real working dtype. Ignored when
+ ``defective="schur"``.
Returns
-------
Tensor
Matrix logarithm. Real for real ``matrix`` when the imaginary part
of the eigendecomposition path is negligible.
+
+ Raises
+ ------
+ ValueError
+ If ``defective`` is not ``"error"`` or ``"schur"``, or if
+ ``rcond`` is set but is not a finite value > 0.
+ DefectiveSpectrumError
+ If ``defective="error"`` and the eigenbasis is singular or
+ ill-conditioned beyond ``rcond``.
+ ImportError
+ If ``defective="schur"`` and SciPy is not installed.
+
+ References
+ ----------
+ Al-Mohy, A. H. and Higham, N. J. (2012). Improved inverse scaling
+ and squaring algorithms for the matrix logarithm. *SIAM Journal on
+ Scientific Computing*, 34(4), C153–C169.
+ https://doi.org/10.1137/110852553
+ (``AlMohyHigham2012``; SciPy ``logm`` implements this family of
+ methods. This helper does not re-implement the Padé coefficients.)
"""
+ if defective == "schur":
+ return _matrix_log_schur(matrix)
+ if defective != "error":
+ msg = f"defective must be 'error' or 'schur', got {defective!r}"
+ raise ValueError(msg)
+
eigenvalues, eigenvectors = torch.linalg.eig(matrix)
+ rcond_value = _resolve_log_rcond(matrix, rcond)
+ kappa = _column_normalized_condition(eigenvectors)
+ if (not math.isfinite(kappa)) or kappa > (1.0 / rcond_value):
+ raise DefectiveSpectrumError(_DEFECTIVE_LOG_MESSAGE)
+ try:
+ inverse = torch.linalg.inv(eigenvectors)
+ except torch.linalg.LinAlgError as exc:
+ raise DefectiveSpectrumError(_DEFECTIVE_LOG_MESSAGE) from exc
log_eigenvalues = torch.log(eigenvalues)
- result = eigenvectors @ torch.diag(log_eigenvalues) @ torch.linalg.inv(eigenvectors)
+ result = eigenvectors @ torch.diag(log_eigenvalues) @ inverse
if matrix.is_complex():
return result
return result.real
@@ -124,26 +262,34 @@ def van_loan_generator_from_discrete(
Parameters
----------
-
discrete_k : Tensor
- See the function signature / summary for ``discrete_k``.
+ Discrete Koopman block ``K = exp(L · Δt)``.
discrete_b : Tensor
- See the function signature / summary for ``discrete_b``.
- delta_t : float | Tensor
- See the function signature / summary for ``delta_t``.
+ Discrete control block in the library row convention.
+ delta_t : float or Tensor
+ Integration interval.
Returns
-------
-
tuple[Tensor, Tensor]
Continuous generator ``L`` and control ``B``.
+ Raises
+ ------
+ ValueError
+ If ``delta_t`` is not positive.
+ DefectiveSpectrumError
+ If the Van Loan block is non-diagonalizable under the default
+ ``matrix_log(..., defective="error")``.
+
Notes
-----
-
Round-trip fidelity is typically within :data:`VAN_LOAN_WRITEBACK_ATOL`
for moderate ``Δt`` when ``K(Δt)`` stays away from matrix-logarithm branch
- cuts. Large or highly oscillatory intervals can degrade recovery."""
+ cuts. Large or highly oscillatory intervals can degrade recovery.
+ ``matrix_log`` uses ``defective="error"`` by default: Jordan /
+ singular eigenbases raise rather than silently inverting :math:`V`.
+ """
latent_dim = discrete_k.shape[0]
control_dim = discrete_b.shape[0]
delta = float(torch.as_tensor(delta_t).item())
diff --git a/src/koopman_graph/operators/contract.py b/src/koopman_graph/operators/contract.py
index 8ca7e98..e4e4a19 100644
--- a/src/koopman_graph/operators/contract.py
+++ b/src/koopman_graph/operators/contract.py
@@ -35,6 +35,7 @@
"continuous_graph",
"switched",
"mixture",
+ "parametric",
"hodge",
]
@@ -53,6 +54,10 @@ class StabilityCertificate:
topology-aware ``stability_certificate(...)`` on graph / hetero
operators.
+ This is **not** a finite-horizon bound on :math:`\\|K^k\\|`. Non-normal
+ transients can exceed the spectral-radius envelope; see
+ :class:`~koopman_graph.spectrum_types.SpectralDiagnostics`.
+
Public result types in this package are frozen dataclasses with attribute
access (not mapping/dict styles).
diff --git a/src/koopman_graph/operators/equivariant.py b/src/koopman_graph/operators/equivariant.py
index 0e48244..8bed347 100644
--- a/src/koopman_graph/operators/equivariant.py
+++ b/src/koopman_graph/operators/equivariant.py
@@ -1,8 +1,16 @@
-r"""Block-equivariant latent Koopman operator (scalar + vector channels).
+r"""Block-equivariant latent Koopman operator (scalar / vector / tensor).
-Vector blocks are learnable multiples of the identity (the unique linear
-equivariant maps on \(\mathbb{R}^3\) under SO(3)). Optional ``[equivariance]``
-path; not a full steerable-generator library.
+Vector blocks are learnable multiples of :math:`I_3` (the unique linear
+equivariant maps on \(\mathbb{R}^3\) under SO(3)). One additional irrep
+is shipped: :math:`l=2` tensor blocks are multiples of :math:`I_5`
+(:math:`2l+1=5`). Mixing across irreps is refused (Schur). Default
+encoders may still project to invariant scalars. Optional
+``[equivariance]`` extra is required only for the rotation test and
+:class:`~koopman_graph.nn.E3EquivariantEncoder` — this module does not
+import ``e3nn``.
+
+Not a factory kind. Not a molecular MD production stack
+(``Thomas2018TFN``, ``Geiger2022e3nn``).
"""
from __future__ import annotations
@@ -14,29 +22,43 @@
from koopman_graph.operators.discrete import KoopmanOperator
from koopman_graph.operators.discrete_propagation import advance_step
+VECTOR_IRREP_DIM = 3
+TENSOR_IRREP_L = 2
+TENSOR_IRREP_DIM = 5
+
+__all__ = [
+ "TENSOR_IRREP_DIM",
+ "TENSOR_IRREP_L",
+ "VECTOR_IRREP_DIM",
+ "EquivariantKoopmanOperator",
+]
+
class EquivariantKoopmanOperator(nn.Module):
- """Block-diagonal scalar/vector latent operator.
+ """Block-diagonal scalar / vector / :math:`l=2` tensor operator.
Parameters
----------
n_scalars : int
Number of invariant scalar latent channels.
n_vectors : int, optional
- Number of 3-vector channels. Default is 0 (scalars only).
+ Number of 3-vector channels. Default is 0.
+ n_tensors : int, optional
+ Number of :math:`l=2` channels (width 5 each). Default is 0.
"""
def __init__(
self,
n_scalars: int,
n_vectors: int = 0,
+ n_tensors: int = 0,
*,
init_mode: InitMode = "identity_noise",
init_scale: float = 1e-2,
parameterization: Parameterization = "dense",
max_spectral_radius: float = 0.99,
) -> None:
- """Initialize scalar and vector Koopman blocks.
+ """Initialize scalar, vector, and optional tensor Koopman blocks.
Parameters
----------
@@ -44,6 +66,8 @@ def __init__(
Number of invariant scalar latent channels.
n_vectors : int, optional
Number of 3-vector channels.
+ n_tensors : int, optional
+ Number of :math:`l=2` tensor channels.
init_mode : InitMode, optional
Initialization for the scalar block.
init_scale : float, optional
@@ -52,16 +76,32 @@ def __init__(
Parameterization of the scalar block.
max_spectral_radius : float, optional
Spectral-radius bound for the scalar block.
+
+ Raises
+ ------
+ ValueError
+ If a count is negative or every count is zero.
"""
super().__init__()
- if n_scalars < 0 or n_vectors < 0:
- msg = "n_scalars and n_vectors must be non-negative"
+ if isinstance(n_scalars, bool) or isinstance(n_vectors, bool):
+ raise ValueError("n_scalars and n_vectors must be non-negative ints")
+ if isinstance(n_tensors, bool):
+ raise ValueError("n_tensors must be a non-negative int")
+ if n_scalars < 0 or n_vectors < 0 or n_tensors < 0:
+ msg = "n_scalars, n_vectors, and n_tensors must be non-negative"
raise ValueError(msg)
- if n_scalars == 0 and n_vectors == 0:
- raise ValueError("at least one scalar or vector channel is required")
+ if n_scalars == 0 and n_vectors == 0 and n_tensors == 0:
+ raise ValueError(
+ "at least one scalar, vector, or tensor channel is required"
+ )
self.n_scalars = int(n_scalars)
self.n_vectors = int(n_vectors)
- self.latent_dim = self.n_scalars + 3 * self.n_vectors
+ self.n_tensors = int(n_tensors)
+ self.latent_dim = (
+ self.n_scalars
+ + VECTOR_IRREP_DIM * self.n_vectors
+ + TENSOR_IRREP_DIM * self.n_tensors
+ )
self.control_dim = 0
self.control_mode = "additive"
self.parameterization = parameterization
@@ -78,6 +118,23 @@ def __init__(
else None
)
self.vector_scales = nn.Parameter(torch.ones(self.n_vectors))
+ self.tensor_scales = nn.Parameter(torch.ones(self.n_tensors))
+
+ def _block_device_dtype(self) -> tuple[torch.device, torch.dtype]:
+ """Device and dtype for assembled identity blocks.
+
+ Returns
+ -------
+ tuple[torch.device, torch.dtype]
+ Device and floating dtype of a live parameter.
+ """
+ if self.n_vectors > 0:
+ return self.vector_scales.device, self.vector_scales.dtype
+ if self.n_tensors > 0:
+ return self.tensor_scales.device, self.tensor_scales.dtype
+ assert self.scalar is not None
+ matrix = self.scalar.matrix
+ return matrix.device, matrix.dtype
@property
def matrix(self) -> Tensor:
@@ -86,27 +143,34 @@ def matrix(self) -> Tensor:
Returns
-------
Tensor
- Scalar block plus ``scale * I_3`` vector blocks.
+ Scalar block plus ``scale * I_3`` vector blocks and
+ ``scale * I_5`` :math:`l=2` tensor blocks.
"""
blocks: list[Tensor] = []
- device = self.vector_scales.device
- dtype = self.vector_scales.dtype
+ device, dtype = self._block_device_dtype()
if self.scalar is not None:
blocks.append(self.scalar.matrix)
- eye3 = torch.eye(3, device=device, dtype=dtype)
+ eye3 = torch.eye(VECTOR_IRREP_DIM, device=device, dtype=dtype)
for scale in self.vector_scales:
blocks.append(scale * eye3)
+ eye5 = torch.eye(TENSOR_IRREP_DIM, device=device, dtype=dtype)
+ for scale in self.tensor_scales:
+ blocks.append(scale * eye5)
return torch.block_diag(*blocks) if blocks else torch.empty(0, 0)
def bound_metric(self) -> Tensor:
- """Max of scalar bound and absolute vector scales.
+ """Max of scalar bound and absolute vector / tensor scales.
Returns
-------
Tensor
Scalar bound.
"""
- parts: list[Tensor] = [self.vector_scales.abs().max()]
+ parts: list[Tensor] = []
+ if self.n_vectors > 0:
+ parts.append(self.vector_scales.abs().max())
+ if self.n_tensors > 0:
+ parts.append(self.tensor_scales.abs().max())
if self.scalar is not None:
parts.append(self.scalar.bound_metric())
return torch.stack(parts).max()
@@ -120,7 +184,7 @@ def advance(
edge_index: Tensor | None = None,
edge_weight: Tensor | None = None,
) -> Tensor:
- """Advance scalar and vector blocks independently.
+ """Advance scalar, vector, and tensor blocks independently.
Parameters
----------
diff --git a/src/koopman_graph/operators/graph.py b/src/koopman_graph/operators/graph.py
index ca635cd..847bc44 100644
--- a/src/koopman_graph/operators/graph.py
+++ b/src/koopman_graph/operators/graph.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Sequence
+from functools import partial
from typing import TYPE_CHECKING
import torch
@@ -33,7 +34,7 @@
)
from koopman_graph.operators.kronecker_spectrum import (
kronecker_sum_spectrum_eligible,
- spectrum_k_eff_kronecker_sum,
+ spectrum_k_eff_kronecker_polynomial,
)
from koopman_graph.operators.matrix_free import (
DEFAULT_DISTRIBUTED_SPECTRUM_NUM_MODES,
@@ -43,6 +44,11 @@
unflatten_node_latents,
)
from koopman_graph.operators.orbit_ties import OrbitTiedSelfMixin
+from koopman_graph.operators.polynomial_graph import (
+ apply_monomial_powers,
+ dense_polynomial_kronecker,
+ validate_filter_degree,
+)
from koopman_graph.spectrum_types import KoopmanSpectrum, compute_spectrum
__all__ = [
@@ -96,7 +102,8 @@ class GraphKoopmanOperator(OrbitTiedSelfMixin, nn.Module):
"""Discrete Koopman step with self and neighbor coupling on the graph.
Advances stacked node latents ``Z ∈ R^{N×d}`` via a linear map whose
- adjacency normalization depends on ``adjacency``:
+ adjacency normalization depends on ``adjacency``. Default
+ ``filter_degree=1`` is the one-tap factorization:
* ``"symmetric"`` (default)::
@@ -118,15 +125,25 @@ class GraphKoopmanOperator(OrbitTiedSelfMixin, nn.Module):
with ``Â_b = D_in^{-1} A^{\\top}``. ``K_fwd`` is an alias of ``K_nbr``.
+ ``filter_degree=0`` applies the self term only (``K_nbr`` is unused).
+ ``filter_degree=P>1`` extends the map by the monomial
+ ``sum_{k=2}^{P} Â^k ⊗ K_k`` (and matching backward powers in dual
+ mode). Extra hop factors (``k ≥ 2``) are globally shared. Dual
+ random-walk remains an extra **direction**, not an extra hop.
+ Eligible ``.spectrum`` uses the Kronecker pencil
+ ``B(λ)=Σ_k λ^k K_k`` for any ``P`` (not a sum of independent factor
+ eigenvalues); dual / orbit paths stay dense.
+
Unlike :class:`KoopmanOperator`, topology enters the **linear** step, so
mid-horizon rewiring changes latent advance (not only decode).
Discrete-time only; for continuous networked generators use
:class:`~koopman_graph.operators.continuous_graph.ContinuousGraphKoopmanOperator`
(``koopman="graph"`` + ``dynamics_mode="continuous"``).
- When neighbor factors are zero, the step reduces exactly to the per-node
- map ``Z @ K_self.T``. Orbit ties (when enabled) apply to ``K_self`` and
- ``K_nbr`` / ``K_fwd``; ``K_bwd`` stays globally shared.
+ When neighbor factors are zero at ``filter_degree=1``, the step reduces
+ exactly to the per-node map ``Z @ K_self.T``. Orbit ties (when enabled)
+ apply to ``K_self`` and ``K_nbr`` / ``K_fwd``; ``K_bwd`` and hop
+ factors with ``k ≥ 2`` stay globally shared.
Attributes
----------
@@ -139,6 +156,9 @@ class GraphKoopmanOperator(OrbitTiedSelfMixin, nn.Module):
factors.
adjacency : {"symmetric", "random_walk", "dual_random_walk"}
Neighbor-coupling normalization (default ``"symmetric"``).
+ filter_degree : int
+ Monomial hop degree ``P`` (default ``1``). ``0`` is self only;
+ ``P>1`` is opt-in.
sparsity : {"dense", "block_diagonal", "distributed"}
Realization mode. ``"dense"`` and ``"block_diagonal"`` share the same
sparse forward matvec; they differ in ``inverse_advance`` (exact
@@ -163,6 +183,7 @@ def __init__(
bilinear_rank: int | None = None,
sparsity: GraphSparsity = "dense",
adjacency: GraphAdjacency = "symmetric",
+ filter_degree: int = 1,
orbit_partition: Sequence[Sequence[int]] | None = None,
auto_orbits: bool = False,
orbit_method: OrbitMethod = "auto",
@@ -199,6 +220,12 @@ def __init__(
adjacency : {"symmetric", "random_walk", "dual_random_walk"}, optional
Neighbor-coupling normalization. Default ``"symmetric"`` preserves
historical undirected behavior bit-for-bit.
+ filter_degree : int, optional
+ Monomial hop degree ``P``. Default ``1`` is the current one-tap
+ map (``I ⊗ K_self + Â ⊗ K_nbr``). ``0`` skips the neighbor
+ term. ``P>1`` allocates extra globally shared factors
+ ``K_2, …, K_P``. Dual mode applies the same powers to the
+ backward shift with a parallel bank.
orbit_partition : sequence of sequence of int or None, optional
Explicit node-orbit partition tying ``K_self`` and ``K_nbr``
across orbit mates. Overrides ``auto_orbits`` when provided.
@@ -217,8 +244,8 @@ def __init__(
Raises
------
ValueError
- If ``sparsity`` / ``adjacency`` are unsupported, or construction
- args are invalid.
+ If ``sparsity`` / ``adjacency`` are unsupported, ``filter_degree``
+ is invalid, or construction args are invalid.
"""
super().__init__()
if sparsity not in {"dense", "block_diagonal", "distributed"}:
@@ -234,6 +261,7 @@ def __init__(
f"{{{accepted}}}, got {adjacency!r}"
)
raise ValueError(msg)
+ filter_degree = validate_filter_degree(filter_degree)
self.latent_dim = latent_dim
self.init_mode = init_mode
@@ -245,6 +273,7 @@ def __init__(
self.bilinear_rank = bilinear_rank
self.sparsity = sparsity
self.adjacency = adjacency
+ self.filter_degree = filter_degree
# Self-term owns the optional control matrix B (and bilinear factors).
self._self = KoopmanOperator(
@@ -277,6 +306,22 @@ def __init__(
)
else:
self._bwd = None
+ extra_hops = max(filter_degree - 1, 0)
+ self._hop_factors: nn.ModuleList | None
+ self._bwd_hop_factors: nn.ModuleList | None
+ if extra_hops == 0:
+ self._hop_factors = None
+ self._bwd_hop_factors = None
+ else:
+ self._hop_factors = nn.ModuleList(
+ [self._make_neighbor_factor() for _ in range(extra_hops)]
+ )
+ if adjacency == "dual_random_walk":
+ self._bwd_hop_factors = nn.ModuleList(
+ [self._make_neighbor_factor() for _ in range(extra_hops)]
+ )
+ else:
+ self._bwd_hop_factors = None
self._reset_neighbor_parameters()
self._init_orbit_config(
orbit_partition=orbit_partition,
@@ -285,6 +330,23 @@ def __init__(
isotypic_symmetry=isotypic_symmetry,
)
+ def _make_neighbor_factor(self) -> KoopmanOperator:
+ """Construct an uncontrolled neighbor-style ``d×d`` factor.
+
+ Returns
+ -------
+ KoopmanOperator
+ Factor sharing the host parameterization, without control.
+ """
+ return KoopmanOperator(
+ self.latent_dim,
+ init_mode="identity",
+ init_scale=self.init_scale,
+ parameterization=self.parameterization,
+ max_spectral_radius=self.max_spectral_radius,
+ control_dim=0,
+ )
+
def _reset_factor_parameters(
self,
module: KoopmanOperator,
@@ -323,6 +385,8 @@ def _reset_neighbor_parameters(self) -> None:
``K_nbr`` / ``K_fwd`` may receive ``init_scale`` noise.
``K_bwd`` (dual mode) is always exactly zero so
``dual_random_walk`` begins equivalent to ``random_walk``.
+ Extra hop factors (``k ≥ 2``) follow the same convention:
+ forward hops near zero (optional noise); backward hops exact zero.
"""
orbit_nbrs = getattr(self, "_orbit_nbrs", None)
modules = list(orbit_nbrs) if orbit_nbrs is not None else [self._nbr]
@@ -330,6 +394,12 @@ def _reset_neighbor_parameters(self) -> None:
self._reset_factor_parameters(module, allow_noise=True)
if self._bwd is not None:
self._reset_factor_parameters(self._bwd, allow_noise=False)
+ if self._hop_factors is not None:
+ for module in self._hop_factors:
+ self._reset_factor_parameters(module, allow_noise=True)
+ if self._bwd_hop_factors is not None:
+ for module in self._bwd_hop_factors:
+ self._reset_factor_parameters(module, allow_noise=False)
def reset_parameters(self) -> None:
"""Reinitialize ``K_self`` / neighbor factors (and control ``B``).
@@ -338,12 +408,18 @@ def reset_parameters(self) -> None:
-----
Delegates to the self/neighbor factor modules, then re-applies the
neighbor initialization conventions (near-zero forward; exact-zero
- backward).
+ backward). Extra hop factors (``k ≥ 2``) are reset the same way.
"""
self.reset_orbit_selves()
self._nbr.reset_parameters()
if self._bwd is not None:
self._bwd.reset_parameters()
+ if self._hop_factors is not None:
+ for module in self._hop_factors:
+ module.reset_parameters()
+ if self._bwd_hop_factors is not None:
+ for module in self._bwd_hop_factors:
+ module.reset_parameters()
self._reset_neighbor_parameters()
@property
@@ -405,6 +481,81 @@ def K_bwd(self) -> Tensor:
raise AttributeError(msg)
return self._bwd.K
+ def receptive_field_hops(self) -> int:
+ """Return the monomial hop degree ``P`` (encoder-matching radius).
+
+ Returns
+ -------
+ int
+ ``filter_degree``. Chebyshev bases are not implemented; the
+ hop radius equals the monomial degree.
+ """
+ return self.filter_degree
+
+ def _kronecker_hop_matrices(self) -> tuple[Tensor, ...]:
+ """Return monomial hop factors ``(K_0, …, K_P)`` for Kronecker spectrum.
+
+ ``P=0`` is the self factor only. ``P>=1`` includes ``K_nbr`` as
+ :math:`K_1`. Extra hops ``k>=2`` follow :meth:`_extra_hop_matrices`.
+
+ Returns
+ -------
+ tuple of Tensor
+ Square ``(d, d)`` factors of length ``filter_degree + 1``.
+ """
+ hops: list[Tensor] = [self.K_self]
+ if self.filter_degree >= 1:
+ hops.append(self.K_nbr)
+ if self.filter_degree >= 2:
+ hops.extend(self._extra_hop_matrices())
+ return tuple(hops)
+
+ def _extra_hop_matrices(self) -> tuple[Tensor, ...]:
+ """Return assembled forward hop factors ``K_2, …, K_P``.
+
+ Returns
+ -------
+ tuple of Tensor
+ Empty when ``filter_degree < 2``.
+ """
+ if self._hop_factors is None:
+ return ()
+ return tuple(module.K for module in self._hop_factors)
+
+ def _extra_bwd_hop_matrices(self) -> tuple[Tensor, ...]:
+ """Return assembled backward hop factors for dual mode, ``k ≥ 2``.
+
+ Returns
+ -------
+ tuple of Tensor
+ Empty when dual extra hops are not allocated.
+ """
+ if self._bwd_hop_factors is None:
+ return ()
+ return tuple(module.K for module in self._bwd_hop_factors)
+
+ def _require_unit_filter_degree(self, *, action: str) -> None:
+ """Reject inverse / Arnoldi paths that assume a one-hop factorization.
+
+ Parameters
+ ----------
+ action : str
+ Human-readable path name for the error message.
+
+ Raises
+ ------
+ ValueError
+ If ``filter_degree != 1``.
+ """
+ if self.filter_degree == 1:
+ return
+ msg = (
+ f"{action} requires filter_degree=1 (one-hop factorization); "
+ f"got filter_degree={self.filter_degree}. Use sparsity='dense' "
+ "for inverse when P!=1, or set filter_degree=1."
+ )
+ raise ValueError(msg)
+
@property
def matrix(self) -> Tensor:
"""Self-term matrix (contract surface; topology-coupled spectrum differs).
@@ -437,6 +588,8 @@ def set_dense_matrices(
k_nbr: Tensor,
*,
k_bwd: Tensor | None = None,
+ hop_matrices: Sequence[Tensor] | None = None,
+ bwd_hop_matrices: Sequence[Tensor] | None = None,
control_matrix: Tensor | None = None,
bilinear_matrices: Tensor | None = None,
) -> None:
@@ -451,6 +604,13 @@ def set_dense_matrices(
k_bwd : Tensor or None, optional
Dense backward neighbor matrix when
``adjacency="dual_random_walk"``. Must be omitted otherwise.
+ hop_matrices : sequence of Tensor or None, optional
+ Extra forward factors ``K_2, …, K_P`` when ``filter_degree>=2``.
+ Length must equal ``filter_degree - 1``. ``None`` leaves extra
+ hops unchanged.
+ bwd_hop_matrices : sequence of Tensor or None, optional
+ Extra backward factors when dual and ``filter_degree>=2``.
+ ``None`` leaves them unchanged.
control_matrix : Tensor or None, optional
Control matrix ``B`` when ``control_dim > 0``.
bilinear_matrices : Tensor or None, optional
@@ -461,11 +621,36 @@ def set_dense_matrices(
ValueError
If ``k_bwd`` is set when ``adjacency`` is not
``"dual_random_walk"``. When dual, ``k_bwd=None`` leaves
- ``K_bwd`` unchanged.
+ ``K_bwd`` unchanged. Raised if hop-factor lengths do not match
+ ``filter_degree``.
"""
if k_bwd is not None and self._bwd is None:
msg = "k_bwd is only valid when adjacency='dual_random_walk'"
raise ValueError(msg)
+ extra = 0 if self._hop_factors is None else len(self._hop_factors)
+ if hop_matrices is not None:
+ if extra == 0:
+ msg = "hop_matrices requires filter_degree >= 2"
+ raise ValueError(msg)
+ if len(hop_matrices) != extra:
+ msg = (
+ "hop_matrices must have length "
+ f"{extra} (filter_degree-1), got {len(hop_matrices)}"
+ )
+ raise ValueError(msg)
+ if bwd_hop_matrices is not None:
+ if self._bwd_hop_factors is None:
+ msg = (
+ "bwd_hop_matrices is only valid when "
+ "adjacency='dual_random_walk' and filter_degree >= 2"
+ )
+ raise ValueError(msg)
+ if len(bwd_hop_matrices) != extra:
+ msg = (
+ "bwd_hop_matrices must have length "
+ f"{extra} (filter_degree-1), got {len(bwd_hop_matrices)}"
+ )
+ raise ValueError(msg)
if self._orbit_selves is None:
self._self.set_dense_matrix(
k_self,
@@ -487,6 +672,16 @@ def set_dense_matrices(
if k_bwd is not None:
assert self._bwd is not None
self._bwd.set_dense_matrix(k_bwd, control_matrix=None)
+ if hop_matrices is not None:
+ assert self._hop_factors is not None
+ for module, matrix in zip(self._hop_factors, hop_matrices, strict=True):
+ module.set_dense_matrix(matrix, control_matrix=None)
+ if bwd_hop_matrices is not None:
+ assert self._bwd_hop_factors is not None
+ for module, matrix in zip(
+ self._bwd_hop_factors, bwd_hop_matrices, strict=True
+ ):
+ module.set_dense_matrix(matrix, control_matrix=None)
def bound_metric(self) -> Tensor:
"""Return ``max`` of self / neighbor factor bounds for monitoring.
@@ -507,6 +702,12 @@ def bound_metric(self) -> Tensor:
metric = torch.maximum(self._self.bound_metric(), self._nbr.bound_metric())
if self._bwd is not None:
metric = torch.maximum(metric, self._bwd.bound_metric())
+ if self._hop_factors is not None:
+ for module in self._hop_factors:
+ metric = torch.maximum(metric, module.bound_metric())
+ if self._bwd_hop_factors is not None:
+ for module in self._bwd_hop_factors:
+ metric = torch.maximum(metric, module.bound_metric())
return metric
def spectral_radius(self) -> Tensor:
@@ -753,6 +954,132 @@ def _sparse_neighbor_term(
)
return term + neighbor_bwd @ self.K_bwd.T
+ def _dense_higher_hop_coupling(
+ self,
+ edge_index: Tensor,
+ num_nodes: int,
+ *,
+ edge_weight: Tensor | None,
+ dtype: torch.dtype,
+ ) -> Tensor:
+ """Assemble ``sum_{k>=2} Â^k ⊗ K_k`` (plus dual backward powers).
+
+ Parameters
+ ----------
+ edge_index : Tensor
+ Edge index ``(2, E)``.
+ num_nodes : int
+ Number of nodes ``N``.
+ edge_weight : Tensor or None
+ Optional edge weights ``(E,)``.
+ dtype : torch.dtype
+ Floating dtype for the dense adjacency factors.
+
+ Returns
+ -------
+ Tensor
+ Dense extra-hop coupling with shape ``(N·d, N·d)``.
+ """
+ from koopman_graph.graph_utils.topology import (
+ dense_random_walk_normalized_adjacency,
+ dense_symmetric_normalized_adjacency,
+ )
+
+ hop_matrices = self._extra_hop_matrices()
+ if self.adjacency == "symmetric":
+ adj = dense_symmetric_normalized_adjacency(
+ edge_index,
+ num_nodes,
+ edge_weight=edge_weight,
+ dtype=dtype,
+ )
+ return dense_polynomial_kronecker(adj, hop_matrices, start_power=2)
+
+ adj_fwd = dense_random_walk_normalized_adjacency(
+ edge_index,
+ num_nodes,
+ edge_weight=edge_weight,
+ dtype=dtype,
+ direction="forward",
+ )
+ coupling = dense_polynomial_kronecker(adj_fwd, hop_matrices, start_power=2)
+ if self.adjacency == "random_walk":
+ return coupling
+ adj_bwd = dense_random_walk_normalized_adjacency(
+ edge_index,
+ num_nodes,
+ edge_weight=edge_weight,
+ dtype=dtype,
+ direction="backward",
+ )
+ return coupling + dense_polynomial_kronecker(
+ adj_bwd,
+ self._extra_bwd_hop_matrices(),
+ start_power=2,
+ )
+
+ def _sparse_higher_hop_term(
+ self,
+ z: Tensor,
+ edge_index: Tensor,
+ edge_weight: Tensor | None,
+ ) -> Tensor:
+ """Apply monomial hops ``k >= 2`` via repeated adjacency matvecs.
+
+ Parameters
+ ----------
+ z : Tensor
+ Latent node states ``(N, d)``.
+ edge_index : Tensor
+ Edge index ``(2, E)``.
+ edge_weight : Tensor or None
+ Optional edge weights.
+
+ Returns
+ -------
+ Tensor
+ Extra-hop contribution with the same shape as ``z``.
+ """
+ from koopman_graph.graph_utils.topology import (
+ random_walk_normalized_adjacency_matvec,
+ symmetric_normalized_adjacency_matvec,
+ )
+
+ hop_matrices = self._extra_hop_matrices()
+ num_nodes = z.shape[0]
+ if self.adjacency == "symmetric":
+ matvec = partial(
+ symmetric_normalized_adjacency_matvec,
+ edge_index,
+ edge_weight=edge_weight,
+ num_nodes=num_nodes,
+ )
+ return apply_monomial_powers(z, hop_matrices, matvec, min_power=2)
+
+ matvec_fwd = partial(
+ random_walk_normalized_adjacency_matvec,
+ edge_index,
+ edge_weight=edge_weight,
+ num_nodes=num_nodes,
+ direction="forward",
+ )
+ term = apply_monomial_powers(z, hop_matrices, matvec_fwd, min_power=2)
+ if self.adjacency == "random_walk":
+ return term
+ matvec_bwd = partial(
+ random_walk_normalized_adjacency_matvec,
+ edge_index,
+ edge_weight=edge_weight,
+ num_nodes=num_nodes,
+ direction="backward",
+ )
+ return term + apply_monomial_powers(
+ z,
+ self._extra_bwd_hop_matrices(),
+ matvec_bwd,
+ min_power=2,
+ )
+
def effective_matrix(
self,
edge_index: Tensor,
@@ -799,12 +1126,36 @@ def effective_matrix(
if k_self_blocks is None and k_self is None:
k_self_blocks = self.tied_self_blocks(num_nodes)
self_matrix = self.K_self if k_self is None else k_self
+ if self.filter_degree == 0:
+ if k_self_blocks is None:
+ identity = torch.eye(
+ num_nodes,
+ dtype=self_matrix.dtype,
+ device=self_matrix.device,
+ )
+ return torch.kron(identity, self_matrix)
+ expected = (num_nodes, self.latent_dim, self.latent_dim)
+ if k_self_blocks.shape != expected:
+ msg = (
+ f"k_self_blocks must have shape {expected}, "
+ f"got {tuple(k_self_blocks.shape)}"
+ )
+ raise ValueError(msg)
+ return torch.block_diag(*k_self_blocks.unbind(0))
+
neighbor = self._dense_neighbor_coupling(
edge_index,
num_nodes,
edge_weight=edge_weight,
dtype=self_matrix.dtype,
)
+ if self.filter_degree >= 2:
+ neighbor = neighbor + self._dense_higher_hop_coupling(
+ edge_index,
+ num_nodes,
+ edge_weight=edge_weight,
+ dtype=self_matrix.dtype,
+ )
if k_self_blocks is None:
identity = torch.eye(
num_nodes,
@@ -895,13 +1246,16 @@ def spectrum(
(:func:`~koopman_graph.operators.matrix_free.spectrum_k_eff_graph`):
``num_modes`` largest-modulus Ritz values; eigenvectors are a
placeholder identity of size ``num_modes``, not ambient Ritz
- vectors.
+ vectors. Requires ``filter_degree=1``.
2. Else if Kronecker-sum eligible (shared ``K_self``,
``adjacency`` in ``{"symmetric", "random_walk"}``,
``sparsity`` in ``{"dense", "block_diagonal"}``) —
- exact reduction via
- :func:`~koopman_graph.operators.kronecker_spectrum.spectrum_k_eff_kronecker_sum`
- when the helper succeeds.
+ exact polynomial reduction
+ :math:`B(\\lambda_i)=\\sum_k \\lambda_i^{k} K_{k}` via
+ :func:`~koopman_graph.operators.kronecker_spectrum.spectrum_k_eff_kronecker_polynomial`
+ when the helper succeeds. Dual / unknown adjacency is
+ structurally ineligible and dense-routes here (the helper
+ raises if called directly).
3. Else — dense
:func:`~koopman_graph.spectrum_types.compute_spectrum` on
:meth:`effective_matrix` (``dual_random_walk``, orbit / isotypic
@@ -930,6 +1284,7 @@ def spectrum(
Full ambient spectrum, or distributed leading-modulus surrogate.
"""
if self.sparsity == "distributed":
+ self._require_unit_filter_degree(action="distributed Arnoldi spectrum")
result = spectrum_k_eff_graph(
k_self=self.K_self,
k_nbr=self.K_nbr,
@@ -948,9 +1303,8 @@ def spectrum(
sparsity=self.sparsity,
shared_self=not self.uses_orbit_selves,
):
- kronecker = spectrum_k_eff_kronecker_sum(
- k_self=self.K_self,
- k_nbr=self.K_nbr,
+ kronecker = spectrum_k_eff_kronecker_polynomial(
+ hop_matrices=self._kronecker_hop_matrices(),
edge_index=edge_index,
num_nodes=num_nodes,
adjacency=self.adjacency,
@@ -1004,11 +1358,19 @@ def forward(
raise ValueError(msg)
self.ensure_orbit_binding(z.shape[0], edge_index=edge_index)
- z_next = self.apply_tied_self(z) + self._sparse_neighbor_term(
- z,
- edge_index,
- edge_weight,
- )
+ z_next = self.apply_tied_self(z)
+ if self.filter_degree >= 1:
+ z_next = z_next + self._sparse_neighbor_term(
+ z,
+ edge_index,
+ edge_weight,
+ )
+ if self.filter_degree >= 2:
+ z_next = z_next + self._sparse_higher_hop_term(
+ z,
+ edge_index,
+ edge_weight,
+ )
if self.control_dim == 0:
if control is not None:
@@ -1084,12 +1446,15 @@ def inverse_advance(
"""Recover previous latents from a networked forward step.
``sparsity="dense"`` inverts the effective ``N·d`` map (exact;
- suitable for modest ``N``). ``sparsity="block_diagonal"`` uses a
+ suitable for modest ``N``), including polynomial
+ ``filter_degree != 1``. ``sparsity="block_diagonal"`` uses a
one-step Jacobi / per-node ``d×d`` approximate inverse (exact when
neighbor factors are zero or the graph has no edges; approximate for
- all three ``adjacency`` modes otherwise). ``sparsity="distributed"``
+ all three ``adjacency`` modes otherwise) and requires
+ ``filter_degree=1``. ``sparsity="distributed"``
uses matrix-free Richardson / Neumann iteration on a shared
- ``K_self`` (orbit / per-node bilinear self blocks are unsupported).
+ ``K_self`` (orbit / per-node bilinear self blocks are unsupported)
+ and requires ``filter_degree=1``.
``inverse_matrix`` is supported only for ``sparsity="dense"``.
For ``control_mode="bilinear"``, global controls fold into a shared
@@ -1198,6 +1563,7 @@ def _bilinear_self_factors() -> tuple[Tensor | None, Tensor | None]:
raise ValueError(msg)
if self.sparsity == "block_diagonal":
+ self._require_unit_filter_degree(action="block_diagonal inverse_advance")
if inverse_matrix is not None:
msg = (
"inverse_matrix is only supported for "
@@ -1219,6 +1585,7 @@ def _bilinear_self_factors() -> tuple[Tensor | None, Tensor | None]:
)
if self.sparsity == "distributed":
+ self._require_unit_filter_degree(action="distributed inverse_advance")
if inverse_matrix is not None:
msg = (
"inverse_matrix is only supported for "
diff --git a/src/koopman_graph/operators/graphon.py b/src/koopman_graph/operators/graphon.py
index fc78011..7746af2 100644
--- a/src/koopman_graph/operators/graphon.py
+++ b/src/koopman_graph/operators/graphon.py
@@ -1,13 +1,46 @@
-r"""Graphon-sampled adjacency helper for transfer experiments.
+r"""Graphon sampling and dense-kernel estimation for transfer experiments.
-Samples a dense adjacency from a kernel \(W:[0,1]^2\to[0,1]\) at \(N\)
-uniform nodes. Theory bounds are cited, not proved in-repo.
+Samples a dense adjacency from a kernel :math:`W:[0,1]^2\to[0,1]` at
+:math:`N` uniform nodes, or estimates a constant / product / low-rank
+kernel from aligned graphs. Theory bounds are cited, not proved
+in-repo. Sparse-graph graphon limits are out of scope
+(``LovaszSzegedy2006``; ``Ruiz2023Transferability``).
+
+References
+----------
+Lovász, L. and Szegedy, B. (2006). Limits of dense graph sequences.
+*Journal of Combinatorial Theory, Series B* 96:933–957.
+doi:10.1016/j.jctb.2006.05.001 (``LovaszSzegedy2006``).
+
+Ruiz, L., Chamon, L. F. O. and Ribeiro, A. (2023). Transferability
+properties of graph neural networks. *IEEE Transactions on Signal
+Processing* 71:3474–3489. doi:10.1109/TSP.2023.3297848
+(``Ruiz2023Transferability``).
"""
from __future__ import annotations
+from collections.abc import Sequence
+from dataclasses import dataclass
+from typing import Literal
+
import torch
from torch import Tensor
+from torch_geometric.data import Data
+
+__all__ = [
+ "MAX_GRAPHON_NODES",
+ "GraphonEstimate",
+ "GraphonKernelFamily",
+ "estimate_graphon",
+ "sample_graphon_adjacency",
+]
+
+GraphonKernelFamily = Literal["constant", "product", "low_rank"]
+_FAMILIES = frozenset({"constant", "product", "low_rank"})
+MAX_GRAPHON_NODES = 256
+"""Dense teaching ceiling on aligned node count for ``estimate_graphon``."""
+_FAMILIES_DOC = '{"constant", "product", "low_rank"}'
def sample_graphon_adjacency(
@@ -16,20 +49,25 @@ def sample_graphon_adjacency(
kernel: str = "constant",
density: float = 0.3,
generator: torch.Generator | None = None,
+ positions: Tensor | None = None,
) -> Tensor:
r"""Sample a symmetric 0/1 adjacency from a simple graphon.
Parameters
----------
num_nodes : int
- Node count \(N\).
+ Node count :math:`N`.
kernel : {"constant", "product"}, optional
``constant`` uses edge probability ``density``; ``product`` uses
- \(W(u,v)=u v\) after sorting uniform latent positions.
+ :math:`W(u,v)=u v` at uniform (or supplied) latent positions.
density : float, optional
- Edge probability for the constant graphon.
+ Edge probability for the constant graphon. Dimensionless in
+ ``[0, 1]``. Default ``0.3``.
generator : torch.Generator or None, optional
Optional RNG.
+ positions : Tensor or None, optional
+ Latent coordinates ``(N,)`` in ``[0, 1]``. Drawn uniformly when
+ omitted. Used by the product kernel.
Returns
-------
@@ -37,23 +75,300 @@ def sample_graphon_adjacency(
Integer ``edge_index`` with shape ``(2, E)`` (undirected, both
orientations).
+ Raises
+ ------
+ ValueError
+ If ``num_nodes < 2``, ``density`` is outside ``[0, 1]``,
+ ``positions`` has the wrong shape or lies outside ``[0, 1]``,
+ or ``kernel`` is unknown.
+
Notes
-----
Dense-graph limit viewpoint of Lovász and Szegedy (2006;
- ``LovaszSzegedy2006``). In-repo transfer experiments cite those
- bounds; they are not proved here.
+ ``LovaszSzegedy2006``). Transferability of filters / GNNs across
+ sizes is not automatic on arbitrary sparse graphs
+ (``Ruiz2023Transferability``). Bounds are cited, not proved here.
"""
if num_nodes < 2:
raise ValueError(f"num_nodes must be >= 2, got {num_nodes}")
- positions = torch.rand(num_nodes, generator=generator)
+ if kernel == "constant" and not 0.0 <= float(density) <= 1.0:
+ raise ValueError(f"density must lie in [0, 1], got {density}")
+ if positions is None:
+ coords = torch.rand(num_nodes, generator=generator)
+ else:
+ if positions.ndim != 1 or int(positions.shape[0]) != num_nodes:
+ msg = (
+ f"positions must have shape (num_nodes,), got {tuple(positions.shape)}"
+ )
+ raise ValueError(msg)
+ coords = positions.to(dtype=torch.float32)
+ if float(coords.min()) < 0.0 or float(coords.max()) > 1.0:
+ raise ValueError("positions must lie in [0, 1]")
if kernel == "constant":
probs = torch.full((num_nodes, num_nodes), float(density))
elif kernel == "product":
- probs = positions.unsqueeze(1) * positions.unsqueeze(0)
+ probs = coords.unsqueeze(1) * coords.unsqueeze(0)
else:
raise ValueError(f"unknown graphon kernel {kernel!r}")
probs = torch.triu(probs, diagonal=1)
samples = torch.rand((num_nodes, num_nodes), generator=generator) < probs
src, dst = samples.nonzero(as_tuple=True)
- edge_index = torch.stack([torch.cat([src, dst]), torch.cat([dst, src])], dim=0)
- return edge_index
+ return torch.stack([torch.cat([src, dst]), torch.cat([dst, src])], dim=0)
+
+
+def _graph_num_nodes(graph: Data) -> int:
+ """Return node count for a homogeneous snapshot.
+
+ Parameters
+ ----------
+ graph : Data
+ Homogeneous graph.
+
+ Returns
+ -------
+ int
+ Node count.
+
+ Raises
+ ------
+ TypeError
+ If ``graph`` is not homogeneous ``Data``.
+ ValueError
+ If the node count cannot be inferred.
+ """
+ if type(graph) is not Data:
+ msg = (
+ "estimate_graphon supports homogeneous Data only, "
+ f"got {type(graph).__name__}"
+ )
+ raise TypeError(msg)
+ if graph.x is not None and graph.x.ndim == 2:
+ return int(graph.x.shape[0])
+ if "num_nodes" in graph and graph["num_nodes"] is not None:
+ return int(graph["num_nodes"])
+ edge_index = graph.edge_index
+ if edge_index is not None and int(edge_index.numel()) > 0:
+ return int(edge_index.max()) + 1
+ msg = "cannot infer num_nodes from graph without x, num_nodes, or edges"
+ raise ValueError(msg)
+
+
+def _dense_adjacency(graph: Data, num_nodes: int) -> Tensor:
+ """Return a loopless symmetric 0/1 adjacency.
+
+ Parameters
+ ----------
+ graph : Data
+ Homogeneous snapshot.
+ num_nodes : int
+ Union size.
+
+ Returns
+ -------
+ Tensor
+ Dense ``(N, N)`` adjacency (float64).
+ """
+ adjacency = torch.zeros(num_nodes, num_nodes, dtype=torch.float64)
+ edge_index = graph.edge_index
+ if edge_index is not None and int(edge_index.numel()) > 0:
+ if int(edge_index.min()) < 0 or int(edge_index.max()) >= num_nodes:
+ msg = (
+ "edge_index endpoints must lie in "
+ f"[0, {num_nodes}), got min={int(edge_index.min())}, "
+ f"max={int(edge_index.max())}"
+ )
+ raise ValueError(msg)
+ src = edge_index[0].long()
+ dst = edge_index[1].long()
+ adjacency[src, dst] = 1.0
+ adjacency[dst, src] = 1.0
+ adjacency.fill_diagonal_(0.0)
+ return adjacency
+
+
+def _mean_offdiag(adjacency: Tensor) -> float:
+ """Return the mean of the strict upper triangle.
+
+ Parameters
+ ----------
+ adjacency : Tensor
+ Square dense matrix.
+
+ Returns
+ -------
+ float
+ Mean off-diagonal (upper) entry.
+ """
+ num_nodes = int(adjacency.shape[0])
+ tri = torch.triu(adjacency, diagonal=1)
+ count = num_nodes * (num_nodes - 1) / 2.0
+ return float(tri.sum() / count)
+
+
+def _zero_diagonal(matrix: Tensor) -> Tensor:
+ """Return a copy with a zero diagonal.
+
+ Parameters
+ ----------
+ matrix : Tensor
+ Square matrix.
+
+ Returns
+ -------
+ Tensor
+ Loopless copy.
+ """
+ out = matrix.clone()
+ out.fill_diagonal_(0.0)
+ return out
+
+
+@dataclass(frozen=True, eq=False)
+class GraphonEstimate:
+ """Dense graphon-family fit on aligned homogeneous graphs.
+
+ Attributes
+ ----------
+ kernel_family : {"constant", "product", "low_rank"}
+ Requested family.
+ density : float
+ Mean off-diagonal edge probability (dimensionless).
+ probability_matrix : Tensor
+ Estimated loopless edge probabilities ``(N, N)``.
+ latent_scores : Tensor or None
+ Product-kernel scores ``(N,)`` in ``[0, 1]``.
+ factors : Tensor or None
+ Low-rank factors ``(N, r)``.
+ rank : int or None
+ Requested SVD rank when ``kernel_family="low_rank"``.
+ n_graphs : int
+ Number of input graphs.
+ num_nodes : int
+ Shared node count.
+
+ Notes
+ -----
+ Equality is disabled because the payload holds tensors. This is a
+ teaching estimator, not a unique graphon identifier. Low-rank
+ ``factors`` use non-negative singular values and need not satisfy
+ :math:`P = FF^{\\top}`.
+ """
+
+ kernel_family: GraphonKernelFamily
+ density: float
+ probability_matrix: Tensor
+ latent_scores: Tensor | None
+ factors: Tensor | None
+ rank: int | None
+ n_graphs: int
+ num_nodes: int
+
+
+def estimate_graphon(
+ graphs: Sequence[Data],
+ *,
+ kernel_family: GraphonKernelFamily = "low_rank",
+ rank: int = 4,
+) -> GraphonEstimate:
+ """Estimate a constant, product, or low-rank kernel from aligned graphs.
+
+ Graphs must share a finite node count :math:`N` (dense teaching path).
+ Constant recovery is the mean off-diagonal density. Product recovery
+ uses :math:`\\hat u_i = 2 d_i / (N-1)` on the mean adjacency (for
+ :math:`W(u,v)=uv`). Low-rank is a truncated SVD of that mean
+ adjacency, clipped to ``[0, 1]``. This does **not** identify a unique
+ graphon, does not transfer to arbitrary sparse sensor graphs, and is
+ not Borgs–Chayes–Lovász sparse-graph theory
+ (``LovaszSzegedy2006``; ``Ruiz2023Transferability``).
+
+ Parameters
+ ----------
+ graphs : sequence of Data
+ Homogeneous snapshots with a shared ``N``.
+ kernel_family : {"constant", "product", "low_rank"}, optional
+ Family to fit. Default ``"low_rank"``.
+ rank : int, optional
+ SVD rank for ``low_rank``. Default ``4``. Ignored otherwise.
+
+ Returns
+ -------
+ GraphonEstimate
+ Fitted kernel on the observed node set.
+
+ Raises
+ ------
+ ValueError
+ If the graph list is empty, ``N`` is mixed or outside
+ ``[2, MAX_GRAPHON_NODES]``, or ``kernel_family`` / ``rank`` is
+ invalid.
+ TypeError
+ If a graph is not homogeneous ``Data``.
+ """
+ if kernel_family not in _FAMILIES:
+ raise ValueError(
+ f"kernel_family must be one of {_FAMILIES_DOC}, got {kernel_family!r}"
+ )
+ snapshots = tuple(graphs)
+ if not snapshots:
+ raise ValueError("estimate_graphon requires at least one graph")
+ counts = [_graph_num_nodes(graph) for graph in snapshots]
+ num_nodes = counts[0]
+ if any(count != num_nodes for count in counts):
+ msg = (
+ "estimate_graphon requires aligned graphs with a shared N; "
+ f"got {sorted(set(counts))}. Unaligned / unbounded node "
+ "growth is refused."
+ )
+ raise ValueError(msg)
+ if num_nodes < 2:
+ raise ValueError(f"num_nodes must be >= 2, got {num_nodes}")
+ if num_nodes > MAX_GRAPHON_NODES:
+ msg = (
+ "estimate_graphon refuses N > "
+ f"{MAX_GRAPHON_NODES} (dense O(N^2) teaching ceiling), "
+ f"got {num_nodes}"
+ )
+ raise ValueError(msg)
+ stacked = torch.stack(
+ [_dense_adjacency(graph, num_nodes) for graph in snapshots],
+ dim=0,
+ )
+ mean_adj = stacked.mean(dim=0)
+ density = _mean_offdiag(mean_adj)
+ latent_scores: Tensor | None = None
+ factors: Tensor | None = None
+ fitted_rank: int | None = None
+ if kernel_family == "constant":
+ probs = density * torch.ones(
+ num_nodes,
+ num_nodes,
+ dtype=torch.float64,
+ )
+ probability_matrix = _zero_diagonal(probs)
+ elif kernel_family == "product":
+ degrees = mean_adj.sum(dim=1)
+ latent_scores = (2.0 * degrees / float(num_nodes - 1)).clamp(0.0, 1.0)
+ probability_matrix = _zero_diagonal(
+ latent_scores.unsqueeze(1) * latent_scores.unsqueeze(0)
+ )
+ else:
+ if int(rank) < 1:
+ raise ValueError(f"rank must be a positive int, got {rank}")
+ fitted_rank = min(int(rank), num_nodes - 1)
+ left, values, right = torch.linalg.svd(mean_adj, full_matrices=False)
+ kept = left[:, :fitted_rank] * values[:fitted_rank]
+ reconstructed = kept @ right[:fitted_rank]
+ reconstructed = 0.5 * (reconstructed + reconstructed.T)
+ probability_matrix = _zero_diagonal(reconstructed.clamp(0.0, 1.0))
+ scale = values[:fitted_rank].clamp(min=0.0).sqrt()
+ factors = left[:, :fitted_rank] * scale
+ return GraphonEstimate(
+ kernel_family=kernel_family,
+ density=density,
+ probability_matrix=probability_matrix,
+ latent_scores=latent_scores,
+ factors=factors,
+ rank=fitted_rank,
+ n_graphs=len(snapshots),
+ num_nodes=num_nodes,
+ )
diff --git a/src/koopman_graph/operators/kronecker_spectrum.py b/src/koopman_graph/operators/kronecker_spectrum.py
index a93bdab..1a1b71f 100644
--- a/src/koopman_graph/operators/kronecker_spectrum.py
+++ b/src/koopman_graph/operators/kronecker_spectrum.py
@@ -1,6 +1,6 @@
"""Kronecker-sum exact spectrum helpers for networked graph operators.
-For the factorized form (discrete ``K`` or continuous generator ``L``)
+For the one-tap form (discrete ``K`` or continuous generator ``L``)
.. math::
@@ -16,22 +16,32 @@
M_{\\mathrm{eff}} (v_i \\otimes w_{ij}) = \\mu_{ij}\\, (v_i \\otimes w_{ij})
-under the package's node-block ``vec`` stacking (C-order ``Z.reshape(-1)``
-with shape ``(N·d,)``, matching
+The discrete monomial extension
+:math:`K_{\\mathrm{eff}} = \\sum_{k=0}^{P} \\widehat{A}^{k} \\otimes K_{k}`
+uses the same adjacency eigenpairs with the pencil
+:math:`B(\\lambda_i) = \\sum_{k=0}^{P} \\lambda_i^{k} K_{k}`. That is
+**not** a sum of independent factor eigenvalues
+:math:`\\sum_k \\lambda_i^{k} \\nu_j^{(k)}`.
+
+Assembly uses the package's node-block ``vec`` stacking (C-order
+``Z.reshape(-1)`` with shape ``(N·d,)``, matching
:meth:`~koopman_graph.operators.graph.GraphKoopmanOperator.effective_matrix`
/
:meth:`~koopman_graph.operators.continuous_graph.ContinuousGraphKoopmanOperator.effective_generator`
-and ``torch.kron`` assembly). Ambient eigenvector columns are unit-normalized
+and ``torch.kron``). Ambient eigenvector columns are unit-normalized
``torch.kron(v_i, w_{ij})``. Discrete spectra fill growth rates via
:func:`~koopman_graph.spectrum_types.compute_spectrum`; continuous generator
spectra use :func:`~koopman_graph.spectrum_types.compute_generator_spectrum`
-conventions (real-part growth rates).
+conventions (real-part growth rates). Continuous graph remains the one-tap
+generator form (no polynomial ``L`` reduction here).
Eligibility (callers must gate before use)
-----------------------------------------
* ``sparsity in {"dense", "block_diagonal"}`` (not ``"distributed"``)
* ``adjacency in {"symmetric", "random_walk"}`` (not dual)
* Shared self factor only (no orbit / isotypic per-orbit self banks)
+* Discrete polynomial degree :math:`P \\ge 0` is eligible when the
+ gates above hold (single shift)
Honesty
-------
@@ -39,8 +49,9 @@
(``matrix_free`` / ``sparsity="distributed"``). Continuous graph has no
Arnoldi spectrum path — ineligible / failed reduction falls back to dense
``effective_generator`` eigendecomposition.
-* Returns ``None`` when the reduction is unreliable so callers can fall back
- to dense assembly — never a silent wrong spectrum.
+* Returns ``None`` when the reduction is numerically unreliable so callers
+ can fall back to dense assembly — never a silent wrong spectrum.
+ Structurally ineligible adjacency (dual / unknown) raises ``ValueError``.
* Random-walk :math:`\\widehat{A}` is checked with a residual on
``eig``; symmetric :math:`\\widehat{A}` uses ``eigh`` (Hermitian) with
non-finite checks only.
@@ -50,6 +61,8 @@
from __future__ import annotations
+from collections.abc import Sequence
+
import torch
from torch import Tensor
@@ -66,8 +79,10 @@
__all__ = [
"DEFAULT_KRONECKER_ADJ_RESIDUAL_TOL",
+ "eigenvalues_k_eff_kronecker_polynomial",
"eigenvalues_k_eff_kronecker_sum",
"kronecker_sum_spectrum_eligible",
+ "spectrum_k_eff_kronecker_polynomial",
"spectrum_k_eff_kronecker_sum",
"spectrum_l_eff_kronecker_sum",
]
@@ -248,30 +263,84 @@ def _adjacency_eigenpairs(
return None
-def _k_eff_eigenpairs_kronecker_sum(
+def _require_eligible_adjacency(adjacency: GraphAdjacency | str) -> None:
+ """Raise when adjacency is not a single commuting shift.
+
+ Parameters
+ ----------
+ adjacency : {"symmetric", "random_walk", "dual_random_walk"} or str
+ Requested neighbor-coupling mode.
+
+ Raises
+ ------
+ ValueError
+ If ``adjacency`` is not ``"symmetric"`` or ``"random_walk"``. Dual
+ random-walk has two non-commuting shifts.
+ """
+ if adjacency in _ELIGIBLE_ADJACENCY:
+ return
+ msg = (
+ "Kronecker-sum spectrum requires adjacency in "
+ "{'symmetric', 'random_walk'}; received "
+ f"{adjacency!r}. Dual random-walk has two non-commuting "
+ "shifts; use dense effective_matrix eigendecomposition."
+ )
+ raise ValueError(msg)
+
+
+def _factor_pencil(lam: Tensor, hop_matrices: Sequence[Tensor]) -> Tensor:
+ """Return the latent pencil :math:`B(\\lambda) = \\sum_k \\lambda^k K_k`.
+
+ Length-1 and length-2 cases use the closed forms of the historical
+ one-tap helper so :math:`P=1` floats stay bit-identical.
+
+ Parameters
+ ----------
+ lam : Tensor
+ Scalar adjacency eigenvalue (0-d).
+ hop_matrices : sequence of Tensor
+ Complex hop factors ``(K_0, ..., K_P)`` of common shape ``(d, d)``.
+
+ Returns
+ -------
+ Tensor
+ Pencil of shape ``(d, d)``.
+ """
+ n_hops = len(hop_matrices)
+ if n_hops == 1:
+ return hop_matrices[0]
+ block = hop_matrices[0] + lam * hop_matrices[1]
+ if n_hops == 2:
+ return block
+ power = lam * lam
+ for index in range(2, n_hops):
+ block = block + power * hop_matrices[index]
+ if index + 1 < n_hops:
+ power = power * lam
+ return block
+
+
+def _k_eff_eigenpairs_kronecker_polynomial(
*,
- k_self: Tensor,
- k_nbr: Tensor,
+ hop_matrices: Sequence[Tensor],
edge_index: Tensor,
num_nodes: int,
adjacency: GraphAdjacency | str,
edge_weight: Tensor | None,
residual_tol: float,
) -> tuple[Tensor, Tensor] | None:
- """Assemble unsorted ambient eigenpairs via Kronecker-sum reduction.
+ """Assemble unsorted ambient eigenpairs via the polynomial pencil.
Parameters
----------
- k_self : Tensor
- Shared self factor of shape ``(d, d)``.
- k_nbr : Tensor
- Neighbor factor of shape ``(d, d)``.
+ hop_matrices : sequence of Tensor
+ Square hop factors ``(K_0, ..., K_P)`` of common shape ``(d, d)``.
edge_index : Tensor
COO edge index of shape ``(2, E)``.
num_nodes : int
Number of graph nodes ``N``.
adjacency : {"symmetric", "random_walk"} or str
- Normalized adjacency mode.
+ Normalized adjacency mode. Dual / unknown modes raise.
edge_weight : Tensor or None
Optional edge weights of length ``E``.
residual_tol : float
@@ -281,17 +350,26 @@ def _k_eff_eigenpairs_kronecker_sum(
-------
tuple[Tensor, Tensor] or None
``(eigenvalues, eigenvectors)`` with shapes ``(N·d,)`` and
- ``(N·d, N·d)`` (unit columns), or ``None`` on failure.
+ ``(N·d, N·d)`` (unit columns), or ``None`` on numerical failure.
+
+ Raises
+ ------
+ ValueError
+ If ``adjacency`` is structurally ineligible (dual / unknown).
"""
- if adjacency not in _ELIGIBLE_ADJACENCY:
- return None
- if k_self.ndim != 2 or k_nbr.ndim != 2:
+ _require_eligible_adjacency(adjacency)
+ hops = tuple(hop_matrices)
+ if not hops:
return None
- if k_self.shape != k_nbr.shape or k_self.shape[0] != k_self.shape[1]:
+ first = hops[0]
+ if first.ndim != 2 or first.shape[0] != first.shape[1]:
return None
+ for matrix in hops[1:]:
+ if matrix.ndim != 2 or matrix.shape != first.shape:
+ return None
if num_nodes < 1:
return None
- if not _factors_finite(k_self, k_nbr):
+ if not _factors_finite(*hops):
return None
adj_pairs = _adjacency_eigenpairs(
@@ -299,23 +377,22 @@ def _k_eff_eigenpairs_kronecker_sum(
num_nodes,
adjacency=adjacency,
edge_weight=edge_weight,
- dtype=k_self.dtype,
+ dtype=first.dtype,
residual_tol=residual_tol,
)
if adj_pairs is None:
return None
adj_eigs, adj_vecs = adj_pairs
- latent_dim = k_self.shape[0]
- factor_dtype = _complex_dtype(k_self.dtype)
- k_self_c = k_self.to(dtype=factor_dtype)
- k_nbr_c = k_nbr.to(dtype=factor_dtype)
+ latent_dim = first.shape[0]
+ factor_dtype = _complex_dtype(first.dtype)
+ hops_c = tuple(matrix.to(dtype=factor_dtype) for matrix in hops)
ambient_dim = num_nodes * latent_dim
eigenvalues: list[Tensor] = []
eigenvectors: list[Tensor] = []
for index, lam in enumerate(adj_eigs):
- block = k_self_c + lam * k_nbr_c
+ block = _factor_pencil(lam, hops_c)
block_eigs, block_vecs = torch.linalg.eig(block)
if not _factors_finite(block_eigs, block_vecs):
return None
@@ -336,6 +413,95 @@ def _k_eff_eigenpairs_kronecker_sum(
return evals, evecs
+def _k_eff_eigenpairs_kronecker_sum(
+ *,
+ k_self: Tensor,
+ k_nbr: Tensor,
+ edge_index: Tensor,
+ num_nodes: int,
+ adjacency: GraphAdjacency | str,
+ edge_weight: Tensor | None,
+ residual_tol: float,
+) -> tuple[Tensor, Tensor] | None:
+ """Assemble unsorted ambient eigenpairs via the one-tap Kronecker sum.
+
+ Thin wrapper over :func:`_k_eff_eigenpairs_kronecker_polynomial` with
+ hop factors ``(k_self, k_nbr)``.
+
+ Parameters
+ ----------
+ k_self : Tensor
+ Shared self factor of shape ``(d, d)``.
+ k_nbr : Tensor
+ Neighbor factor of shape ``(d, d)``.
+ edge_index : Tensor
+ COO edge index of shape ``(2, E)``.
+ num_nodes : int
+ Number of graph nodes ``N``.
+ adjacency : {"symmetric", "random_walk"} or str
+ Normalized adjacency mode.
+ edge_weight : Tensor or None
+ Optional edge weights of length ``E``.
+ residual_tol : float
+ Relative residual threshold for adjacency eigendecomposition.
+
+ Returns
+ -------
+ tuple[Tensor, Tensor] or None
+ ``(eigenvalues, eigenvectors)`` with shapes ``(N·d,)`` and
+ ``(N·d, N·d)`` (unit columns), or ``None`` on failure.
+
+ Raises
+ ------
+ ValueError
+ If ``adjacency`` is structurally ineligible (dual / unknown).
+ """
+ return _k_eff_eigenpairs_kronecker_polynomial(
+ hop_matrices=(k_self, k_nbr),
+ edge_index=edge_index,
+ num_nodes=num_nodes,
+ adjacency=adjacency,
+ edge_weight=edge_weight,
+ residual_tol=residual_tol,
+ )
+
+
+def _discrete_spectrum_from_eigenpairs(
+ pairs: tuple[Tensor, Tensor],
+ time_step: float,
+) -> KoopmanSpectrum:
+ """Sort eigenpairs by descending magnitude and fill discrete rates.
+
+ Parameters
+ ----------
+ pairs : tuple of Tensor
+ Unsorted ``(eigenvalues, eigenvectors)`` from the Kronecker core.
+ time_step : float
+ Positive discrete sampling interval.
+
+ Returns
+ -------
+ KoopmanSpectrum
+ Magnitude-sorted spectrum with growth rates and frequencies.
+ """
+ eigenvalues, eigenvectors = pairs
+ magnitudes = eigenvalues.abs()
+ order = torch.argsort(magnitudes, descending=True)
+ eigenvalues = eigenvalues[order]
+ eigenvectors = eigenvectors[:, order]
+ magnitudes = magnitudes[order]
+ growth_rates = torch.log(magnitudes.clamp_min(1e-30)) / time_step
+ frequencies = torch.angle(eigenvalues) / (2 * torch.pi * time_step)
+ return KoopmanSpectrum(
+ eigenvalues=eigenvalues,
+ eigenvectors=eigenvectors,
+ magnitudes=magnitudes,
+ growth_rates=growth_rates,
+ frequencies=frequencies,
+ time_step=float(time_step),
+ )
+
+
def eigenvalues_k_eff_kronecker_sum(
*,
k_self: Tensor,
@@ -348,11 +514,12 @@ def eigenvalues_k_eff_kronecker_sum(
) -> Tensor | None:
"""Compute :math:`K_{\\mathrm{eff}}` eigenvalues via Kronecker-sum reduction.
- Thin wrapper over the shared eigenpair core. Does **not** check
- ``sparsity`` / orbit eligibility — callers must gate with
- :func:`kronecker_sum_spectrum_eligible` (and ``shared_self``) first.
- Returns ``None`` when the reduction is unreliable so the caller can fall
- back to dense assembly.
+ Thin wrapper over the shared eigenpair core for the one-tap map
+ :math:`I\\otimes K_{\\mathrm{self}} + \\widehat{A}\\otimes K_{\\mathrm{nbr}}`.
+ Does **not** check ``sparsity`` / orbit eligibility — callers must gate
+ with :func:`kronecker_sum_spectrum_eligible` (and ``shared_self``)
+ first. Returns ``None`` when the reduction is numerically unreliable
+ so the caller can fall back to dense assembly.
Parameters
----------
@@ -365,7 +532,7 @@ def eigenvalues_k_eff_kronecker_sum(
num_nodes : int
Node count ``N``.
adjacency : {"symmetric", "random_walk"}
- Single-factor adjacency mode. Dual mode is unsupported here.
+ Single-factor adjacency mode. Dual mode raises.
edge_weight : Tensor or None, optional
Optional edge weights ``(E,)``.
residual_tol : float, optional
@@ -377,10 +544,68 @@ def eigenvalues_k_eff_kronecker_sum(
Tensor or None
Complex eigenvalues with shape ``(N·d,)`` (unsorted), or ``None``
when the reduction is unreliable.
+
+ Raises
+ ------
+ ValueError
+ If ``adjacency`` is dual or otherwise not a single commuting shift.
+ """
+ return eigenvalues_k_eff_kronecker_polynomial(
+ hop_matrices=(k_self, k_nbr),
+ edge_index=edge_index,
+ num_nodes=num_nodes,
+ adjacency=adjacency,
+ edge_weight=edge_weight,
+ residual_tol=residual_tol,
+ )
+
+
+def eigenvalues_k_eff_kronecker_polynomial(
+ *,
+ hop_matrices: Sequence[Tensor],
+ edge_index: Tensor,
+ num_nodes: int,
+ adjacency: GraphAdjacency | str,
+ edge_weight: Tensor | None = None,
+ residual_tol: float = DEFAULT_KRONECKER_ADJ_RESIDUAL_TOL,
+) -> Tensor | None:
+ """Compute polynomial :math:`K_{\\mathrm{eff}}` eigenvalues via Kronecker reduction.
+
+ Uses :math:`B(\\lambda_i)=\\sum_k \\lambda_i^k K_k` on each adjacency
+ eigenpair. That is **not** a sum of independent factor eigenvalues.
+ Does **not** check ``sparsity`` / orbit eligibility — callers must gate
+ with :func:`kronecker_sum_spectrum_eligible`. Returns ``None`` when
+ the reduction is numerically unreliable.
+
+ Parameters
+ ----------
+ hop_matrices : sequence of Tensor
+ Square hop factors ``(K_0, ..., K_P)`` of common shape ``(d, d)``.
+ Length 1 is the self-only map; length 2 is the one-tap sum.
+ edge_index : Tensor
+ Edge index ``(2, E)``.
+ num_nodes : int
+ Node count ``N``.
+ adjacency : {"symmetric", "random_walk"}
+ Single-factor adjacency mode. Dual mode raises.
+ edge_weight : Tensor or None, optional
+ Optional edge weights ``(E,)``.
+ residual_tol : float, optional
+ Random-walk adjacency residual tolerance.
+
+ Returns
+ -------
+ Tensor or None
+ Complex eigenvalues with shape ``(N·d,)`` (unsorted), or ``None``
+ when the reduction is unreliable.
+
+ Raises
+ ------
+ ValueError
+ If ``adjacency`` is dual or otherwise not a single commuting shift.
"""
- pairs = _k_eff_eigenpairs_kronecker_sum(
- k_self=k_self,
- k_nbr=k_nbr,
+ pairs = _k_eff_eigenpairs_kronecker_polynomial(
+ hop_matrices=hop_matrices,
edge_index=edge_index,
num_nodes=num_nodes,
adjacency=adjacency,
@@ -392,10 +617,9 @@ def eigenvalues_k_eff_kronecker_sum(
return pairs[0]
-def spectrum_k_eff_kronecker_sum(
+def spectrum_k_eff_kronecker_polynomial(
*,
- k_self: Tensor,
- k_nbr: Tensor,
+ hop_matrices: Sequence[Tensor],
edge_index: Tensor,
num_nodes: int,
adjacency: GraphAdjacency | str,
@@ -403,29 +627,28 @@ def spectrum_k_eff_kronecker_sum(
time_step: float = 1.0,
residual_tol: float = DEFAULT_KRONECKER_ADJ_RESIDUAL_TOL,
) -> KoopmanSpectrum | None:
- """Exact :math:`K_{\\mathrm{eff}}` spectrum via Kronecker-sum reduction.
+ """Exact polynomial :math:`K_{\\mathrm{eff}}` spectrum via Kronecker reduction.
- Builds ambient eigenvectors as unit-normalized ``kron(v_i, w_ij)``, sorts
- by descending magnitude, and fills growth rates / frequencies with the
+ Builds ambient eigenvectors as unit-normalized ``kron(v_i, w_ij)``
+ from :math:`B(\\lambda_i)=\\sum_k \\lambda_i^k K_k`, sorts by
+ descending magnitude, and fills growth rates / frequencies with the
same discrete conventions as
:func:`~koopman_graph.spectrum_types.compute_spectrum`.
Does **not** check ``sparsity`` / orbit eligibility — callers must gate
with :func:`kronecker_sum_spectrum_eligible`. Returns ``None`` when the
- reduction is unreliable.
+ reduction is numerically unreliable.
Parameters
----------
- k_self : Tensor
- Shared self factor with shape ``(d, d)``.
- k_nbr : Tensor
- Neighbor factor with shape ``(d, d)``.
+ hop_matrices : sequence of Tensor
+ Square hop factors ``(K_0, ..., K_P)`` of common shape ``(d, d)``.
edge_index : Tensor
Edge index ``(2, E)``.
num_nodes : int
Node count ``N``.
adjacency : {"symmetric", "random_walk"}
- Single-factor adjacency mode.
+ Single-factor adjacency mode. Dual mode raises.
edge_weight : Tensor or None, optional
Optional edge weights ``(E,)``.
time_step : float, optional
@@ -437,12 +660,16 @@ def spectrum_k_eff_kronecker_sum(
-------
KoopmanSpectrum or None
Magnitude-sorted spectrum with ambient eigenvectors, or ``None``.
+
+ Raises
+ ------
+ ValueError
+ If ``adjacency`` is dual or otherwise not a single commuting shift.
"""
if time_step <= 0:
return None
- pairs = _k_eff_eigenpairs_kronecker_sum(
- k_self=k_self,
- k_nbr=k_nbr,
+ pairs = _k_eff_eigenpairs_kronecker_polynomial(
+ hop_matrices=hop_matrices,
edge_index=edge_index,
num_nodes=num_nodes,
adjacency=adjacency,
@@ -451,21 +678,69 @@ def spectrum_k_eff_kronecker_sum(
)
if pairs is None:
return None
- eigenvalues, eigenvectors = pairs
- magnitudes = eigenvalues.abs()
- order = torch.argsort(magnitudes, descending=True)
- eigenvalues = eigenvalues[order]
- eigenvectors = eigenvectors[:, order]
- magnitudes = magnitudes[order]
- growth_rates = torch.log(magnitudes.clamp_min(1e-30)) / time_step
- frequencies = torch.angle(eigenvalues) / (2 * torch.pi * time_step)
- return KoopmanSpectrum(
- eigenvalues=eigenvalues,
- eigenvectors=eigenvectors,
- magnitudes=magnitudes,
- growth_rates=growth_rates,
- frequencies=frequencies,
- time_step=float(time_step),
+ return _discrete_spectrum_from_eigenpairs(pairs, time_step)
+
+
+def spectrum_k_eff_kronecker_sum(
+ *,
+ k_self: Tensor,
+ k_nbr: Tensor,
+ edge_index: Tensor,
+ num_nodes: int,
+ adjacency: GraphAdjacency | str,
+ edge_weight: Tensor | None = None,
+ time_step: float = 1.0,
+ residual_tol: float = DEFAULT_KRONECKER_ADJ_RESIDUAL_TOL,
+) -> KoopmanSpectrum | None:
+ """Exact one-tap :math:`K_{\\mathrm{eff}}` spectrum via Kronecker-sum reduction.
+
+ Thin wrapper over :func:`spectrum_k_eff_kronecker_polynomial` with hop
+ factors ``(k_self, k_nbr)``. Builds ambient eigenvectors as
+ unit-normalized ``kron(v_i, w_ij)``, sorts by descending magnitude,
+ and fills growth rates / frequencies with the same discrete
+ conventions as :func:`~koopman_graph.spectrum_types.compute_spectrum`.
+
+ Does **not** check ``sparsity`` / orbit eligibility — callers must gate
+ with :func:`kronecker_sum_spectrum_eligible`. Returns ``None`` when the
+ reduction is numerically unreliable.
+
+ Parameters
+ ----------
+ k_self : Tensor
+ Shared self factor with shape ``(d, d)``.
+ k_nbr : Tensor
+ Neighbor factor with shape ``(d, d)``.
+ edge_index : Tensor
+ Edge index ``(2, E)``.
+ num_nodes : int
+ Node count ``N``.
+ adjacency : {"symmetric", "random_walk"}
+ Single-factor adjacency mode. Dual mode raises.
+ edge_weight : Tensor or None, optional
+ Optional edge weights ``(E,)``.
+ time_step : float, optional
+ Positive discrete sampling interval for growth rates / frequencies.
+ residual_tol : float, optional
+ Random-walk adjacency residual tolerance.
+
+ Returns
+ -------
+ KoopmanSpectrum or None
+ Magnitude-sorted spectrum with ambient eigenvectors, or ``None``.
+
+ Raises
+ ------
+ ValueError
+ If ``adjacency`` is dual or otherwise not a single commuting shift.
+ """
+ return spectrum_k_eff_kronecker_polynomial(
+ hop_matrices=(k_self, k_nbr),
+ edge_index=edge_index,
+ num_nodes=num_nodes,
+ adjacency=adjacency,
+ edge_weight=edge_weight,
+ time_step=time_step,
+ residual_tol=residual_tol,
)
@@ -479,19 +754,20 @@ def spectrum_l_eff_kronecker_sum(
edge_weight: Tensor | None = None,
residual_tol: float = DEFAULT_KRONECKER_ADJ_RESIDUAL_TOL,
) -> KoopmanSpectrum | None:
- """Exact :math:`L_{\\mathrm{eff}}` spectrum via Kronecker-sum reduction.
+ """Exact :math:`L_{\\mathrm{eff}}` spectrum via one-tap Kronecker-sum reduction.
Reuses the same eigenpair core as :func:`spectrum_k_eff_kronecker_sum`
- (identical Kronecker-sum algebra for generator factors). Sorts by
- descending magnitude and fills growth rates / frequencies with the same
- continuous conventions as
+ (identical Kronecker-sum algebra for generator factors). Continuous
+ graph remains the one-tap generator form; there is no polynomial
+ :math:`L` reduction here. Sorts by descending magnitude and fills
+ growth rates / frequencies with the same continuous conventions as
:func:`~koopman_graph.spectrum_types.compute_generator_spectrum`
(``growth_rates = Re(λ)``, ``frequencies = Im(λ)/(2π)``,
``time_step=1.0``).
Does **not** check ``sparsity`` eligibility — callers must gate with
:func:`kronecker_sum_spectrum_eligible`. Returns ``None`` when the
- reduction is unreliable.
+ reduction is numerically unreliable.
Parameters
----------
@@ -504,7 +780,7 @@ def spectrum_l_eff_kronecker_sum(
num_nodes : int
Node count ``N``.
adjacency : {"symmetric", "random_walk"}
- Single-factor adjacency mode.
+ Single-factor adjacency mode. Dual mode raises.
edge_weight : Tensor or None, optional
Optional edge weights ``(E,)``.
residual_tol : float, optional
@@ -515,10 +791,14 @@ def spectrum_l_eff_kronecker_sum(
KoopmanSpectrum or None
Magnitude-sorted generator spectrum with ambient eigenvectors, or
``None``.
+
+ Raises
+ ------
+ ValueError
+ If ``adjacency`` is dual or otherwise not a single commuting shift.
"""
- pairs = _k_eff_eigenpairs_kronecker_sum(
- k_self=l_self,
- k_nbr=l_nbr,
+ pairs = _k_eff_eigenpairs_kronecker_polynomial(
+ hop_matrices=(l_self, l_nbr),
edge_index=edge_index,
num_nodes=num_nodes,
adjacency=adjacency,
diff --git a/src/koopman_graph/operators/linear.py b/src/koopman_graph/operators/linear.py
new file mode 100644
index 0000000..3d8f677
--- /dev/null
+++ b/src/koopman_graph/operators/linear.py
@@ -0,0 +1,895 @@
+"""Matrix-free linear-operator protocol for networked Koopman maps.
+
+:class:`LinearOperatorProtocol` is the common surface (``matvec``,
+``rmatvec``, ``solve``, ``expm_action``, leading eigpairs, residual
+norms). :class:`PolynomialGraphLinearOperator` wraps a monomial
+degree-:math:`P\\ge 2` graph polynomial.
+:class:`MatrixFreeGraphLinearOperator` wraps the existing one-tap
+:func:`~koopman_graph.operators.matrix_free.apply_k_eff_graph` /
+:func:`~koopman_graph.operators.matrix_free.invert_k_eff_graph` /
+:func:`~koopman_graph.operators.matrix_free.spectrum_k_eff_graph`
+path.
+
+Dense assembly of :math:`K_{\\mathrm{eff}}` is refused when
+:math:`N\\cdot d` exceeds :data:`MAX_DENSE_LINEAR_OPERATOR_SIZE`
+(same teaching ceiling as assembled eig-reg / joint Schur). Trainer
+DDP / ``[distributed]`` extras do **not** shrink that representation.
+Exact Kronecker spectrum remains a special case — leading eigpairs
+here are Arnoldi Ritz values, not ``eig(B(λ))``.
+:math:`10^{5}`-node scaling is not a release gate.
+
+This module must not import :mod:`koopman_graph.model`.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Protocol, runtime_checkable
+
+import torch
+from torch import Tensor
+
+from koopman_graph.graph_utils import (
+ dense_symmetric_normalized_adjacency,
+ symmetric_normalized_adjacency_matvec,
+)
+from koopman_graph.operators.matrix_free import (
+ DEFAULT_MATRIX_FREE_INVERSE_MAX_ITERS,
+ DEFAULT_MATRIX_FREE_INVERSE_TOL,
+ DEFAULT_MATRIX_FREE_SPECTRUM_TOL,
+ _apply_self_preconditioner,
+ _arnoldi_eigs,
+ _invert_square_factor,
+ _richardson_invert,
+ apply_k_eff_graph,
+ flatten_node_latents,
+ invert_k_eff_graph,
+ spectrum_k_eff_graph,
+ unflatten_node_latents,
+)
+from koopman_graph.operators.polynomial_graph import (
+ apply_monomial_powers,
+ dense_polynomial_kronecker,
+)
+
+# Match assembled eig-reg / joint-Schur teaching ceiling (``N·d``).
+MAX_DENSE_LINEAR_OPERATOR_SIZE = 4096
+DEFAULT_EXPM_TAYLOR_TERMS = 16
+
+__all__ = [
+ "DEFAULT_EXPM_TAYLOR_TERMS",
+ "EigResult",
+ "LinearOperatorProtocol",
+ "MAX_DENSE_LINEAR_OPERATOR_SIZE",
+ "MatrixFreeGraphLinearOperator",
+ "MemoryEstimate",
+ "PolynomialGraphLinearOperator",
+]
+
+
+@dataclass(frozen=True)
+class MemoryEstimate:
+ """Declared cost of a linear operator without assembling it.
+
+ Attributes
+ ----------
+ dim : int
+ Flat width ``N·d``.
+ dense_entries : int
+ Entries in a dense ``(N·d, N·d)`` layout.
+ dense_allowed : bool
+ ``True`` when dense assembly is under the teaching ceiling.
+ notes : str
+ Honesty string. Trainer DDP does not change this estimate.
+ """
+
+ dim: int
+ dense_entries: int
+ dense_allowed: bool
+ notes: str
+
+
+@dataclass(frozen=True)
+class EigResult:
+ """Leading-modulus Ritz pairs from a matrix-free Arnoldi pass.
+
+ Attributes
+ ----------
+ eigenvalues : Tensor
+ Complex values ``(k,)``, largest-modulus first.
+ residual_norms : Tensor
+ Per-mode ``||A v - λ v|| / ||v||`` with shape ``(k,)``.
+ converged : bool
+ ``True`` when every returned mode met the requested ``tol``.
+ """
+
+ eigenvalues: Tensor
+ residual_norms: Tensor
+ converged: bool
+
+
+@runtime_checkable
+class LinearOperatorProtocol(Protocol):
+ """Common matrix-free algebra surface for networked maps.
+
+ Implementers apply a linear map on flat ``(N·d,)`` vectors without
+ assembling :math:`K_{\\mathrm{eff}}`. This is operator math — **not**
+ trainer DDP.
+
+ Notes
+ -----
+ ``@runtime_checkable`` verifies method presence only. Exact
+ Kronecker spectrum remains a special case.
+ """
+
+ def matvec(self, x: Tensor) -> Tensor:
+ """Apply ``A x``.
+
+ Parameters
+ ----------
+ x : Tensor
+ Flat vector ``(N·d,)``.
+
+ Returns
+ -------
+ Tensor
+ Flat result ``(N·d,)``.
+ """
+ ...
+
+ def rmatvec(self, x: Tensor) -> Tensor:
+ """Apply ``A^T x``.
+
+ Parameters
+ ----------
+ x : Tensor
+ Flat vector ``(N·d,)``.
+
+ Returns
+ -------
+ Tensor
+ Flat result ``(N·d,)``.
+ """
+ ...
+
+ def solve(self, b: Tensor, *, tol: float, max_iters: int) -> Tensor:
+ """Solve ``A x = b`` without a dense factorization.
+
+ Parameters
+ ----------
+ b : Tensor
+ Flat right-hand side ``(N·d,)``.
+ tol : float
+ Relative residual tolerance.
+ max_iters : int
+ Iteration budget.
+
+ Returns
+ -------
+ Tensor
+ Flat solution ``(N·d,)``.
+ """
+ ...
+
+ def expm_action(self, t: float, b: Tensor) -> Tensor:
+ """Apply ``exp(t A) b`` from matvecs (Taylor teaching path).
+
+ Parameters
+ ----------
+ t : float
+ Time scale.
+ b : Tensor
+ Flat right-hand side ``(N·d,)``.
+
+ Returns
+ -------
+ Tensor
+ Flat result ``(N·d,)``.
+ """
+ ...
+
+ def leading_eigpairs(self, k: int, *, tol: float) -> EigResult:
+ """Return ``k`` largest-modulus Ritz pairs and residuals.
+
+ Parameters
+ ----------
+ k : int
+ Number of modes.
+ tol : float
+ Ritz residual tolerance.
+
+ Returns
+ -------
+ EigResult
+ Accepted pairs. Not a Kronecker exact spectrum.
+ """
+ ...
+
+ def residual_norm(self, x: Tensor, b: Tensor) -> Tensor:
+ """Return ``||A x - b||_2``.
+
+ Parameters
+ ----------
+ x, b : Tensor
+ Flat vectors ``(N·d,)``.
+
+ Returns
+ -------
+ Tensor
+ Scalar residual norm.
+ """
+ ...
+
+ @property
+ def memory_estimate(self) -> MemoryEstimate:
+ """Declared dense-vs-matrix-free cost.
+
+ Returns
+ -------
+ MemoryEstimate
+ ``N·d`` width and whether dense assembly is allowed.
+ """
+ ...
+
+
+def _require_flat(name: str, value: Tensor, *, dim: int) -> Tensor:
+ """Refuse empty, non-real, or wrong-width flat vectors.
+
+ Parameters
+ ----------
+ name : str
+ Field name for the error message.
+ value : Tensor
+ Candidate ``(dim,)`` vector.
+ dim : int
+ Expected length.
+
+ Returns
+ -------
+ Tensor
+ The validated vector.
+
+ Raises
+ ------
+ TypeError
+ If ``value`` is not a tensor.
+ ValueError
+ If rank, dtype, finiteness, or length is invalid.
+ """
+ if not isinstance(value, Tensor):
+ raise TypeError(f"{name} must be a Tensor, got {type(value).__name__}")
+ if value.is_complex():
+ raise ValueError(f"{name} must be real")
+ if not value.is_floating_point():
+ raise ValueError(f"{name} must be a floating-point tensor")
+ if value.ndim != 1 or int(value.shape[0]) != int(dim):
+ raise ValueError(f"{name} must have shape ({dim},), got {tuple(value.shape)}")
+ if not bool(torch.isfinite(value).all().item()):
+ raise ValueError(f"{name} must be finite")
+ return value
+
+
+def _taylor_expm_action(
+ matvec: object,
+ t: float,
+ b: Tensor,
+ *,
+ n_terms: int = DEFAULT_EXPM_TAYLOR_TERMS,
+) -> Tensor:
+ """Evaluate ``exp(t A) b`` from repeated ``matvec`` (Taylor).
+
+ Parameters
+ ----------
+ matvec : callable
+ ``A @ v`` on flat vectors.
+ t : float
+ Time scale. Must be finite.
+ b : Tensor
+ Flat right-hand side.
+ n_terms : int, optional
+ Number of Taylor terms after the identity. Default
+ :data:`DEFAULT_EXPM_TAYLOR_TERMS`.
+
+ Returns
+ -------
+ Tensor
+ Flat ``exp(t A) b`` in the dtype of ``b``.
+
+ Raises
+ ------
+ ValueError
+ If ``t`` or ``n_terms`` is invalid.
+ """
+ if not isinstance(t, int | float) or isinstance(t, bool):
+ raise ValueError(f"t must be a finite float, got {t!r}")
+ if not torch.isfinite(torch.tensor(float(t))):
+ raise ValueError(f"t must be finite, got {t!r}")
+ if isinstance(n_terms, bool) or not isinstance(n_terms, int) or n_terms < 1:
+ raise ValueError(f"n_terms must be a positive int, got {n_terms!r}")
+ if float(t) == 0.0:
+ return b.clone()
+ working = b.detach().to(dtype=torch.float64)
+ term = working.clone()
+ out = working.clone()
+ scale = float(t)
+ apply = matvec
+ for order in range(1, n_terms + 1):
+ term = apply(term) * (scale / float(order))
+ out = out + term
+ return out.to(dtype=b.dtype)
+
+
+class _SymmetricGraphLinearMap:
+ """Shared symmetric-adjacency bookkeeping for graph wrappers.
+
+ Parameters
+ ----------
+ hop_matrices : tuple of Tensor
+ ``K_0, …, K_P`` each ``(d, d)``.
+ edge_index : Tensor
+ COO edges ``(2, E)``.
+ num_nodes : int
+ Node count ``N``.
+ edge_weight : Tensor or None
+ Optional scalar edge weights.
+ """
+
+ def __init__(
+ self,
+ hop_matrices: tuple[Tensor, ...],
+ edge_index: Tensor,
+ num_nodes: int,
+ edge_weight: Tensor | None,
+ ) -> None:
+ """Validate hops, topology, and the symmetric teaching path.
+
+ Parameters
+ ----------
+ hop_matrices : tuple of Tensor
+ Square latent factors.
+ edge_index : Tensor
+ COO edges.
+ num_nodes : int
+ Node count.
+ edge_weight : Tensor or None
+ Optional weights.
+
+ Raises
+ ------
+ ValueError
+ If hops, ``num_nodes``, or ``edge_index`` are invalid.
+ """
+ if len(hop_matrices) < 1:
+ raise ValueError("hop_matrices must be non-empty")
+ latent_dim = int(hop_matrices[0].shape[0])
+ if latent_dim < 1:
+ raise ValueError("latent_dim must be >= 1")
+ expected = (latent_dim, latent_dim)
+ for index, matrix in enumerate(hop_matrices):
+ if not isinstance(matrix, Tensor):
+ raise TypeError(
+ f"hop_matrices[{index}] must be a Tensor, "
+ f"got {type(matrix).__name__}"
+ )
+ if matrix.shape != expected:
+ msg = (
+ f"hop_matrices[{index}] must have shape {expected}, "
+ f"got {tuple(matrix.shape)}"
+ )
+ raise ValueError(msg)
+ if matrix.is_complex() or not matrix.is_floating_point():
+ raise ValueError("hop_matrices must be real floating-point")
+ if not bool(torch.isfinite(matrix).all().item()):
+ raise ValueError("hop_matrices must be finite")
+ if isinstance(num_nodes, bool) or not isinstance(num_nodes, int):
+ raise ValueError(f"num_nodes must be a positive int, got {num_nodes!r}")
+ if num_nodes < 1:
+ raise ValueError(f"num_nodes must be >= 1, got {num_nodes}")
+ if not isinstance(edge_index, Tensor) or edge_index.ndim != 2:
+ raise ValueError("edge_index must have shape (2, E)")
+ if int(edge_index.shape[0]) != 2:
+ raise ValueError(
+ f"edge_index must have shape (2, E), got {tuple(edge_index.shape)}"
+ )
+ self.hop_matrices = hop_matrices
+ self.edge_index = edge_index
+ self.edge_weight = edge_weight
+ self.num_nodes = int(num_nodes)
+ self.latent_dim = latent_dim
+ self.dim = self.num_nodes * self.latent_dim
+
+ def _adj_matvec(self, states: Tensor) -> Tensor:
+ """Apply symmetric-normalized ``Â`` to node latents.
+
+ Parameters
+ ----------
+ states : Tensor
+ Node table ``(N, d)``.
+
+ Returns
+ -------
+ Tensor
+ ``Â Z``.
+ """
+ return symmetric_normalized_adjacency_matvec(
+ self.edge_index,
+ states,
+ edge_weight=self.edge_weight,
+ num_nodes=self.num_nodes,
+ )
+
+ def _apply_hops(self, flat: Tensor, hops: tuple[Tensor, ...]) -> Tensor:
+ """Apply a Kronecker polynomial via sparse adjacency matvecs.
+
+ Parameters
+ ----------
+ flat : Tensor
+ Flat state ``(N·d,)``.
+ hops : tuple of Tensor
+ Factors ``K_0, …, K_P``.
+
+ Returns
+ -------
+ Tensor
+ Flat result.
+ """
+ states = unflatten_node_latents(
+ flat,
+ num_nodes=self.num_nodes,
+ latent_dim=self.latent_dim,
+ )
+ out = states @ hops[0].transpose(-2, -1)
+ if len(hops) > 1:
+ out = out + apply_monomial_powers(
+ states,
+ hops[1:],
+ self._adj_matvec,
+ min_power=1,
+ )
+ return flatten_node_latents(out)
+
+ def matvec(self, x: Tensor) -> Tensor:
+ """Apply the stored polynomial to a flat vector.
+
+ Parameters
+ ----------
+ x : Tensor
+ Flat state ``(N·d,)``.
+
+ Returns
+ -------
+ Tensor
+ ``K_eff x``.
+ """
+ return self._apply_hops(_require_flat("x", x, dim=self.dim), self.hop_matrices)
+
+ def rmatvec(self, x: Tensor) -> Tensor:
+ """Apply the adjoint (symmetric ``Â`` ⇒ transposed factors).
+
+ Parameters
+ ----------
+ x : Tensor
+ Flat state ``(N·d,)``.
+
+ Returns
+ -------
+ Tensor
+ ``K_eff^T x``.
+ """
+ transposed = tuple(matrix.transpose(-2, -1) for matrix in self.hop_matrices)
+ return self._apply_hops(_require_flat("x", x, dim=self.dim), transposed)
+
+ def residual_norm(self, x: Tensor, b: Tensor) -> Tensor:
+ """Return ``||K_eff x - b||_2``.
+
+ Parameters
+ ----------
+ x, b : Tensor
+ Flat vectors ``(N·d,)``.
+
+ Returns
+ -------
+ Tensor
+ Scalar residual norm.
+ """
+ left = self.matvec(_require_flat("x", x, dim=self.dim))
+ rhs = _require_flat("b", b, dim=self.dim)
+ return torch.linalg.vector_norm(left - rhs)
+
+ def expm_action(self, t: float, b: Tensor) -> Tensor:
+ """Taylor action ``exp(t K_eff) b`` (no dense exponential).
+
+ Parameters
+ ----------
+ t : float
+ Time scale.
+ b : Tensor
+ Flat right-hand side.
+
+ Returns
+ -------
+ Tensor
+ Flat result.
+ """
+ rhs = _require_flat("b", b, dim=self.dim)
+ return _taylor_expm_action(self.matvec, t, rhs)
+
+ def _solve_richardson(
+ self,
+ b: Tensor,
+ *,
+ tol: float,
+ max_iters: int,
+ ) -> Tensor:
+ """Self-preconditioned Richardson solve.
+
+ Parameters
+ ----------
+ b : Tensor
+ Flat right-hand side.
+ tol : float
+ Relative residual tolerance.
+ max_iters : int
+ Iteration budget.
+
+ Returns
+ -------
+ Tensor
+ Flat solution.
+ """
+ rhs = _require_flat("b", b, dim=self.dim)
+ k_self_inv = _invert_square_factor(self.hop_matrices[0])
+
+ def _apply_m_inv(flat: Tensor) -> Tensor:
+ """Apply ``I ⊗ K_0^{-1}``.
+
+ Parameters
+ ----------
+ flat : Tensor
+ Flat residual ``(N·d,)``.
+
+ Returns
+ -------
+ Tensor
+ Preconditioned residual.
+ """
+ return _apply_self_preconditioner(
+ flat,
+ k_self_inv,
+ num_nodes=self.num_nodes,
+ latent_dim=self.latent_dim,
+ )
+
+ result = _richardson_invert(
+ self.matvec,
+ _apply_m_inv,
+ rhs,
+ max_iters=max_iters,
+ tol=tol,
+ x0=None,
+ )
+ return result.solution
+
+ def _leading_from_matvec(self, k: int, *, tol: float) -> EigResult:
+ """Arnoldi on the stored matvec.
+
+ Parameters
+ ----------
+ k : int
+ Number of modes.
+ tol : float
+ Ritz residual tolerance.
+
+ Returns
+ -------
+ EigResult
+ Accepted pairs.
+ """
+ if isinstance(k, bool) or not isinstance(k, int) or k < 1:
+ raise ValueError(f"k must be a positive int, got {k!r}")
+ if k > self.dim:
+ raise ValueError(f"k must be <= dim={self.dim}, got {k}")
+ ncv = min(self.dim, max(2 * k + 2, 20))
+ raw = _arnoldi_eigs(
+ self.matvec,
+ self.dim,
+ num_modes=k,
+ tol=tol,
+ ncv=ncv,
+ seed=0,
+ device=self.hop_matrices[0].device,
+ dtype=self.hop_matrices[0].dtype,
+ )
+ return EigResult(
+ eigenvalues=raw.eigenvalues,
+ residual_norms=raw.residual_norms,
+ converged=raw.converged,
+ )
+
+ def dense_matrix(self) -> Tensor:
+ """Assemble ``K_eff`` or refuse above the teaching ceiling.
+
+ Returns
+ -------
+ Tensor
+ Dense ``(N·d, N·d)`` map.
+
+ Raises
+ ------
+ ValueError
+ If ``N·d`` exceeds :data:`MAX_DENSE_LINEAR_OPERATOR_SIZE`.
+ """
+ if self.dim > MAX_DENSE_LINEAR_OPERATOR_SIZE:
+ msg = (
+ "dense assembly refused: N·d = "
+ f"{self.dim} exceeds MAX_DENSE_LINEAR_OPERATOR_SIZE="
+ f"{MAX_DENSE_LINEAR_OPERATOR_SIZE}. Use matvec / solve / "
+ "leading_eigpairs. Trainer DDP does not raise this ceiling."
+ )
+ raise ValueError(msg)
+ adjacency = dense_symmetric_normalized_adjacency(
+ self.edge_index,
+ self.num_nodes,
+ edge_weight=self.edge_weight,
+ dtype=self.hop_matrices[0].dtype,
+ )
+ return dense_polynomial_kronecker(adjacency, self.hop_matrices)
+
+ @property
+ def memory_estimate(self) -> MemoryEstimate:
+ """Report ``N·d`` and whether dense assembly is allowed.
+
+ Returns
+ -------
+ MemoryEstimate
+ Declared cost. Trainer DDP does not change it.
+ """
+ allowed = self.dim <= MAX_DENSE_LINEAR_OPERATOR_SIZE
+ return MemoryEstimate(
+ dim=self.dim,
+ dense_entries=self.dim * self.dim,
+ dense_allowed=allowed,
+ notes=(
+ "matrix-free operator math; trainer DDP / [distributed] "
+ "does not shrink the dense N·d representation"
+ ),
+ )
+
+
+class PolynomialGraphLinearOperator(_SymmetricGraphLinearMap):
+ """Monomial graph polynomial :math:`P\\ge 2` as a linear operator.
+
+ ``hop_matrices`` is ``(K_0, K_1, …, K_P)`` with ``P = len-1 >= 2``.
+ Not a factory kind. Exact Kronecker spectrum is out of scope here.
+
+ Parameters
+ ----------
+ hop_matrices : sequence of Tensor
+ Square factors, length at least 3.
+ edge_index : Tensor
+ COO edges.
+ num_nodes : int
+ Node count.
+ edge_weight : Tensor or None, optional
+ Optional edge weights.
+ """
+
+ def __init__(
+ self,
+ hop_matrices: tuple[Tensor, ...] | list[Tensor],
+ *,
+ edge_index: Tensor,
+ num_nodes: int,
+ edge_weight: Tensor | None = None,
+ ) -> None:
+ """Bind a degree-``P>=2`` monomial and a symmetric graph.
+
+ Parameters
+ ----------
+ hop_matrices : sequence of Tensor
+ ``K_0, …, K_P``.
+ edge_index : Tensor
+ COO edges.
+ num_nodes : int
+ Node count.
+ edge_weight : Tensor or None, optional
+ Optional weights.
+
+ Raises
+ ------
+ ValueError
+ If fewer than three factors are supplied.
+ """
+ hops = tuple(hop_matrices)
+ if len(hops) < 3:
+ raise ValueError(
+ "PolynomialGraphLinearOperator requires P>=2 "
+ f"(at least three hop matrices), got {len(hops)}"
+ )
+ super().__init__(hops, edge_index, num_nodes, edge_weight)
+
+ def solve(
+ self,
+ b: Tensor,
+ *,
+ tol: float = DEFAULT_MATRIX_FREE_INVERSE_TOL,
+ max_iters: int = DEFAULT_MATRIX_FREE_INVERSE_MAX_ITERS,
+ ) -> Tensor:
+ """Richardson solve on the polynomial map.
+
+ Parameters
+ ----------
+ b : Tensor
+ Flat right-hand side.
+ tol : float, optional
+ Relative residual tolerance.
+ max_iters : int, optional
+ Iteration budget.
+
+ Returns
+ -------
+ Tensor
+ Flat solution.
+ """
+ return self._solve_richardson(b, tol=tol, max_iters=max_iters)
+
+ def leading_eigpairs(
+ self,
+ k: int,
+ *,
+ tol: float = DEFAULT_MATRIX_FREE_SPECTRUM_TOL,
+ ) -> EigResult:
+ """Arnoldi Ritz pairs (not Kronecker ``eig(B(λ))``).
+
+ Parameters
+ ----------
+ k : int
+ Number of modes.
+ tol : float, optional
+ Ritz residual tolerance.
+
+ Returns
+ -------
+ EigResult
+ Accepted pairs.
+ """
+ return self._leading_from_matvec(k, tol=tol)
+
+
+class MatrixFreeGraphLinearOperator(_SymmetricGraphLinearMap):
+ """One-tap ``I⊗K_self + Â⊗K_nbr`` wrapper around ``matrix_free``.
+
+ Parameters
+ ----------
+ k_self, k_nbr : Tensor
+ Square ``(d, d)`` factors.
+ edge_index : Tensor
+ COO edges.
+ num_nodes : int
+ Node count.
+ edge_weight : Tensor or None, optional
+ Optional edge weights.
+ """
+
+ def __init__(
+ self,
+ k_self: Tensor,
+ k_nbr: Tensor,
+ *,
+ edge_index: Tensor,
+ num_nodes: int,
+ edge_weight: Tensor | None = None,
+ ) -> None:
+ """Bind the existing one-tap matrix-free path.
+
+ Parameters
+ ----------
+ k_self, k_nbr : Tensor
+ Self / neighbor factors.
+ edge_index : Tensor
+ COO edges.
+ num_nodes : int
+ Node count.
+ edge_weight : Tensor or None, optional
+ Optional weights.
+ """
+ super().__init__((k_self, k_nbr), edge_index, num_nodes, edge_weight)
+
+ def matvec(self, x: Tensor) -> Tensor:
+ """Delegate to :func:`apply_k_eff_graph`.
+
+ Parameters
+ ----------
+ x : Tensor
+ Flat state.
+
+ Returns
+ -------
+ Tensor
+ ``K_eff x``.
+ """
+ flat = _require_flat("x", x, dim=self.dim)
+ return apply_k_eff_graph(
+ flat,
+ k_self=self.hop_matrices[0],
+ k_nbr=self.hop_matrices[1],
+ edge_index=self.edge_index,
+ num_nodes=self.num_nodes,
+ adjacency="symmetric",
+ edge_weight=self.edge_weight,
+ )
+
+ def solve(
+ self,
+ b: Tensor,
+ *,
+ tol: float = DEFAULT_MATRIX_FREE_INVERSE_TOL,
+ max_iters: int = DEFAULT_MATRIX_FREE_INVERSE_MAX_ITERS,
+ ) -> Tensor:
+ """Delegate to :func:`invert_k_eff_graph`.
+
+ Parameters
+ ----------
+ b : Tensor
+ Flat right-hand side.
+ tol : float, optional
+ Relative residual tolerance.
+ max_iters : int, optional
+ Iteration budget.
+
+ Returns
+ -------
+ Tensor
+ Flat solution.
+ """
+ rhs = _require_flat("b", b, dim=self.dim)
+ result = invert_k_eff_graph(
+ rhs,
+ k_self=self.hop_matrices[0],
+ k_nbr=self.hop_matrices[1],
+ edge_index=self.edge_index,
+ num_nodes=self.num_nodes,
+ adjacency="symmetric",
+ edge_weight=self.edge_weight,
+ max_iters=max_iters,
+ tol=tol,
+ )
+ return result.solution
+
+ def leading_eigpairs(
+ self,
+ k: int,
+ *,
+ tol: float = DEFAULT_MATRIX_FREE_SPECTRUM_TOL,
+ ) -> EigResult:
+ """Delegate to :func:`spectrum_k_eff_graph` (Arnoldi, not Kronecker).
+
+ Parameters
+ ----------
+ k : int
+ Number of modes.
+ tol : float, optional
+ Ritz residual tolerance.
+
+ Returns
+ -------
+ EigResult
+ Accepted pairs.
+ """
+ if isinstance(k, bool) or not isinstance(k, int) or k < 1:
+ raise ValueError(f"k must be a positive int, got {k!r}")
+ raw = spectrum_k_eff_graph(
+ k_self=self.hop_matrices[0],
+ k_nbr=self.hop_matrices[1],
+ edge_index=self.edge_index,
+ num_nodes=self.num_nodes,
+ num_modes=k,
+ adjacency="symmetric",
+ edge_weight=self.edge_weight,
+ tol=tol,
+ )
+ return EigResult(
+ eigenvalues=raw.eigenvalues,
+ residual_norms=raw.residual_norms,
+ converged=raw.converged,
+ )
diff --git a/src/koopman_graph/operators/mixture.py b/src/koopman_graph/operators/mixture.py
index 3c95a00..7415c36 100644
--- a/src/koopman_graph/operators/mixture.py
+++ b/src/koopman_graph/operators/mixture.py
@@ -2,7 +2,10 @@
Softmax weights over a latent window mix a bank of LTI maps. Distinct from
:class:`~koopman_graph.operators.GlobalLocalKoopmanOperator` (low-rank
-correction of a single backbone). Select via ``koopman="mixture"``.
+correction of a single backbone) and from a parameter interpolant
+:math:`K(\\mu)` (``Macesic2018Nonautonomous``). Carry regime coordinates
+on :attr:`~koopman_graph.data.GraphSnapshotSequence.parameter_trajectory`.
+Select via ``koopman="mixture"``.
"""
from __future__ import annotations
diff --git a/src/koopman_graph/operators/parametric.py b/src/koopman_graph/operators/parametric.py
new file mode 100644
index 0000000..68832f1
--- /dev/null
+++ b/src/koopman_graph/operators/parametric.py
@@ -0,0 +1,743 @@
+"""Parameter interpolant of discrete LTI Koopman operators.
+
+Convex combination :math:`K(\\mu)=\\sum_j \\alpha_j(\\mu) K_j` with simplex
+or RBF weights on a bank of per-node maps. Distinct from latent-gated
+:class:`~koopman_graph.operators.mixture.MixtureKoopmanOperator` and
+piecewise :class:`~koopman_graph.operators.switched.SwitchedKoopmanOperator`.
+Select via ``koopman="parametric"``. Carry :math:`\\mu_t` on
+:attr:`~koopman_graph.data.GraphSnapshotSequence.parameter_trajectory`.
+
+This module must not import :mod:`koopman_graph.model` or
+:mod:`koopman_graph.data`.
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Sequence
+from dataclasses import dataclass
+from typing import Literal
+
+import torch
+from torch import Tensor, nn
+
+from koopman_graph.operators.contract import InitMode, Parameterization
+from koopman_graph.operators.control import ControlMode
+from koopman_graph.operators.discrete import KoopmanOperator
+from koopman_graph.operators.discrete_propagation import advance_step
+from koopman_graph.operators.switched import DEFAULT_NUM_MODES
+
+WeightKind = Literal["rbf", "simplex"]
+DEFAULT_PARAMETER_DIM = 1
+DEFAULT_WEIGHT_KIND: WeightKind = "rbf"
+LENGTHSCALE_FLOOR = 1e-6
+_RIDGE_FLOOR = 0.0
+
+#: Parameterizations whose convex combination keeps the declared constraint.
+INTERPOLANT_SAFE_PARAMETERIZATIONS: frozenset[str] = frozenset(
+ {"dense", "row_stochastic", "doubly_stochastic"}
+)
+
+__all__ = [
+ "DEFAULT_PARAMETER_DIM",
+ "DEFAULT_WEIGHT_KIND",
+ "INTERPOLANT_SAFE_PARAMETERIZATIONS",
+ "LeaveOneRegimeOutReport",
+ "ParametricKoopmanOperator",
+ "WeightKind",
+ "leave_one_regime_out",
+]
+
+
+def _reject_interpolant_parameterization(parameterization: str) -> None:
+ """Refuse mixes that would silently drop a structural constraint.
+
+ Parameters
+ ----------
+ parameterization : str
+ Shared discrete parameterization of each mode.
+
+ Raises
+ ------
+ ValueError
+ If a convex combination does not preserve the constraint.
+ """
+ if parameterization in INTERPOLANT_SAFE_PARAMETERIZATIONS:
+ return
+ msg = (
+ "parametric interpolant refuses parameterization="
+ f"{parameterization!r}: a convex combination of mode matrices "
+ "does not preserve that constraint. Use 'dense', "
+ "'row_stochastic', or 'doubly_stochastic'"
+ )
+ raise ValueError(msg)
+
+
+def _ridge_edmd(z: Tensor, z_next: Tensor, *, ridge: float) -> Tensor:
+ """Ridge EDMD for :math:`z_{+} \\approx z K^{\\top}`.
+
+ Parameters
+ ----------
+ z : Tensor
+ Latents with shape ``(n, d)``.
+ z_next : Tensor
+ Successor latents with the same shape.
+ ridge : float
+ Tikhonov weight on ``K`` (must be ``>= 0``).
+
+ Returns
+ -------
+ Tensor
+ Estimated ``K`` with shape ``(d, d)``.
+
+ Raises
+ ------
+ ValueError
+ If shapes disagree or ``ridge`` is negative.
+ """
+ if z.ndim != 2 or z_next.ndim != 2:
+ msg = (
+ "ridge EDMD requires 2-D latents (n, d), "
+ f"got z={tuple(z.shape)} z_next={tuple(z_next.shape)}"
+ )
+ raise ValueError(msg)
+ if z.shape != z_next.shape:
+ msg = (
+ "z and z_next must share shape (n, d), "
+ f"got z={tuple(z.shape)} z_next={tuple(z_next.shape)}"
+ )
+ raise ValueError(msg)
+ if ridge < _RIDGE_FLOOR:
+ msg = f"ridge must be >= 0, got {ridge}"
+ raise ValueError(msg)
+ n_pairs, dim = z.shape
+ if n_pairs < 1:
+ msg = "ridge EDMD requires at least one pair"
+ raise ValueError(msg)
+ gram = z.T @ z
+ if ridge > 0.0:
+ gram = gram + ridge * torch.eye(dim, dtype=z.dtype, device=z.device)
+ k_t = torch.linalg.solve(gram, z.T @ z_next)
+ return k_t.T
+
+
+def _one_step_mse(z: Tensor, z_next: Tensor, matrix: Tensor) -> Tensor:
+ """Mean squared residual of :math:`z_{+} - z K^{\\top}`.
+
+ Parameters
+ ----------
+ z, z_next : Tensor
+ Latent pairs with shape ``(n, d)``.
+ matrix : Tensor
+ Discrete map ``K`` with shape ``(d, d)``.
+
+ Returns
+ -------
+ Tensor
+ Scalar MSE.
+ """
+ residual = z_next - z @ matrix.T
+ return residual.square().mean()
+
+
+class ParametricKoopmanOperator(nn.Module):
+ """Discrete per-node interpolant :math:`K(\\mu)=\\sum_j \\alpha_j(\\mu) K_j`.
+
+ ``weight_kind="rbf"`` uses L1-normalized Gaussian kernels at learnable
+ anchors. ``weight_kind="simplex"`` uses softmax of a learned affine map
+ of :math:`\\mu`. Both produce simplex weights, so the mix is a convex
+ combination of the mode matrices.
+
+ Parameters
+ ----------
+ latent_dim : int
+ Latent width of each mode.
+ num_modes : int, optional
+ Number of LTI maps. Default is 2.
+ parameter_dim : int, optional
+ Width :math:`d_\\mu` of the regime coordinate. Default is 1.
+ weight_kind : {"rbf", "simplex"}, optional
+ Interpolation weights. Default is ``"rbf"``.
+ init_mode, init_scale, parameterization, max_spectral_radius
+ Forwarded to each mode operator.
+ control_dim : int, optional
+ Shared control dimension.
+ control_mode : {"additive", "bilinear"}, optional
+ Shared control coupling.
+ bilinear_rank : int or None, optional
+ Shared bilinear rank.
+
+ Raises
+ ------
+ ValueError
+ If ``num_modes`` / ``parameter_dim`` are invalid, ``weight_kind``
+ is unknown, or ``parameterization`` is not preserved by a convex
+ combination.
+
+ Notes
+ -----
+ This is not a cocycle :math:`K(t)` and not a latent-gated mixture
+ :math:`\\sum_i w_i(z) K_i`. A single stationary spectrum of
+ :attr:`matrix` (equal-weight mix) is generally inappropriate for
+ nonautonomous families (``Macesic2018Nonautonomous``). Export of
+ conditioned maps is refused until a discrete homogeneous subset is
+ traceable.
+
+ References
+ ----------
+ Maćešić, S., Črnjarić-Žic, N. and Mezić, I. (2018). Koopman operator
+ family spectrum for nonautonomous systems. *SIAM Journal on Applied
+ Dynamical Systems* 17:2478–2515. doi:10.1137/17M1133610
+ (``Macesic2018Nonautonomous``).
+ """
+
+ def __init__(
+ self,
+ latent_dim: int,
+ num_modes: int = DEFAULT_NUM_MODES,
+ *,
+ parameter_dim: int = DEFAULT_PARAMETER_DIM,
+ weight_kind: WeightKind = DEFAULT_WEIGHT_KIND,
+ init_mode: InitMode = "identity_noise",
+ init_scale: float = 1e-2,
+ parameterization: Parameterization = "dense",
+ max_spectral_radius: float = 0.99,
+ control_dim: int = 0,
+ control_mode: ControlMode = "additive",
+ bilinear_rank: int | None = None,
+ ) -> None:
+ """Initialize the interpolant bank and weight map.
+
+ Parameters
+ ----------
+ latent_dim : int
+ Latent width of each component.
+ num_modes : int, optional
+ Number of LTI maps. Default is 2.
+ parameter_dim : int, optional
+ Regime-coordinate width. Default is 1.
+ weight_kind : {"rbf", "simplex"}, optional
+ Interpolation kind. Default is ``"rbf"``.
+ init_mode : InitMode, optional
+ Weight initialization forwarded to each component.
+ init_scale : float, optional
+ Initialization scale forwarded to each component.
+ parameterization : Parameterization, optional
+ Shared discrete parameterization.
+ max_spectral_radius : float, optional
+ Shared spectral-radius bound.
+ control_dim : int, optional
+ Shared control dimension.
+ control_mode : {"additive", "bilinear"}, optional
+ Shared control coupling.
+ bilinear_rank : int or None, optional
+ Shared bilinear rank.
+ """
+ super().__init__()
+ if num_modes < 1:
+ msg = f"num_modes must be positive, got {num_modes}"
+ raise ValueError(msg)
+ if parameter_dim < 1:
+ msg = f"parameter_dim must be >= 1, got {parameter_dim}"
+ raise ValueError(msg)
+ if weight_kind not in {"rbf", "simplex"}:
+ msg = f"weight_kind must be 'rbf' or 'simplex', got {weight_kind!r}"
+ raise ValueError(msg)
+ _reject_interpolant_parameterization(parameterization)
+ self.latent_dim = latent_dim
+ self.num_modes = int(num_modes)
+ self.parameter_dim = int(parameter_dim)
+ self.weight_kind: WeightKind = weight_kind
+ self.control_dim = control_dim
+ self.control_mode = control_mode
+ self.parameterization = parameterization
+ self.init_mode = init_mode
+ self.init_scale = init_scale
+ self.max_spectral_radius = max_spectral_radius
+ self.bilinear_rank = bilinear_rank
+ self.modes = nn.ModuleList(
+ [
+ KoopmanOperator(
+ latent_dim,
+ init_mode=init_mode,
+ init_scale=init_scale,
+ parameterization=parameterization,
+ max_spectral_radius=max_spectral_radius,
+ control_dim=control_dim,
+ control_mode=control_mode,
+ bilinear_rank=bilinear_rank,
+ )
+ for _ in range(self.num_modes)
+ ]
+ )
+ anchors = torch.zeros(self.num_modes, self.parameter_dim)
+ if self.num_modes > 1:
+ anchors[:, 0] = torch.linspace(0.0, 1.0, self.num_modes)
+ self.anchors = nn.Parameter(anchors)
+ self._raw_lengthscale = nn.Parameter(torch.zeros(()))
+ self.gate = nn.Linear(self.parameter_dim, self.num_modes)
+ self._stored_parameters: Tensor | None = None
+
+ @property
+ def lengthscale(self) -> Tensor:
+ """Positive RBF length-scale (softplus of a raw parameter).
+
+ Returns
+ -------
+ Tensor
+ Scalar length-scale ``> 0``.
+ """
+ return torch.nn.functional.softplus(self._raw_lengthscale) + LENGTHSCALE_FLOOR
+
+ def set_lengthscale(self, value: float | Tensor) -> None:
+ """Write a positive length-scale into the unconstrained parameter.
+
+ Parameters
+ ----------
+ value : float or Tensor
+ Target length-scale (must be ``> 0``).
+
+ Raises
+ ------
+ ValueError
+ If ``value`` is not positive and finite.
+ """
+ scale = float(value)
+ if not math.isfinite(scale) or scale <= 0.0:
+ msg = f"lengthscale must be a positive finite float, got {value!r}"
+ raise ValueError(msg)
+ # Inverse softplus: log(exp(y) - 1) with y = scale - floor.
+ shifted = scale - LENGTHSCALE_FLOOR
+ if shifted <= 0.0:
+ msg = (
+ "lengthscale must exceed "
+ f"{LENGTHSCALE_FLOOR:g} after the positivity floor, got {scale}"
+ )
+ raise ValueError(msg)
+ inverse = math.log(math.expm1(shifted))
+ with torch.no_grad():
+ self._raw_lengthscale.fill_(inverse)
+
+ def set_anchors(self, anchors: Tensor) -> None:
+ """Copy regime anchors in place.
+
+ Parameters
+ ----------
+ anchors : Tensor
+ Shape ``(num_modes, parameter_dim)``.
+
+ Raises
+ ------
+ ValueError
+ If the shape is wrong or values are non-finite.
+ """
+ if anchors.shape != (self.num_modes, self.parameter_dim):
+ msg = (
+ "anchors must have shape "
+ f"({self.num_modes}, {self.parameter_dim}), "
+ f"got {tuple(anchors.shape)}"
+ )
+ raise ValueError(msg)
+ if anchors.is_floating_point() and not torch.all(torch.isfinite(anchors)):
+ msg = "anchors must be finite"
+ raise ValueError(msg)
+ with torch.no_grad():
+ self.anchors.copy_(anchors.to(dtype=self.anchors.dtype))
+
+ def set_parameters(self, parameters: Tensor) -> None:
+ """Store a default :math:`\\mu` used when ``advance`` omits it.
+
+ Parameters
+ ----------
+ parameters : Tensor
+ Regime coordinate with shape ``(parameter_dim,)``.
+ """
+ resolved = self._resolve_parameters(parameters)
+ self._stored_parameters = resolved.detach()
+
+ def _resolve_parameters(self, parameters: Tensor | None) -> Tensor:
+ """Validate :math:`\\mu` or fall back to the stored coordinate.
+
+ Parameters
+ ----------
+ parameters : Tensor or None
+ Caller :math:`\\mu`, or ``None`` to use :meth:`set_parameters`.
+
+ Returns
+ -------
+ Tensor
+ Coordinate with shape ``(parameter_dim,)`` on the module device.
+
+ Raises
+ ------
+ ValueError
+ If :math:`\\mu` is missing, has the wrong shape, or is non-finite.
+ """
+ if parameters is None:
+ stored = self._stored_parameters
+ if stored is None:
+ msg = (
+ "ParametricKoopmanOperator.advance requires parameters "
+ "with shape (parameter_dim,) or a prior set_parameters "
+ "call; got None"
+ )
+ raise ValueError(msg)
+ return stored
+ if parameters.ndim != 1 or int(parameters.shape[0]) != self.parameter_dim:
+ msg = (
+ "parameters must have shape "
+ f"({self.parameter_dim},), got {tuple(parameters.shape)}"
+ )
+ raise ValueError(msg)
+ finite = torch.isfinite(parameters)
+ if parameters.is_floating_point() and not bool(torch.all(finite)):
+ msg = "parameters must be finite"
+ raise ValueError(msg)
+ return parameters.to(device=self.anchors.device, dtype=self.anchors.dtype)
+
+ def interpolation_weights(self, parameters: Tensor | None = None) -> Tensor:
+ """Return simplex weights :math:`\\alpha(\\mu)`.
+
+ Parameters
+ ----------
+ parameters : Tensor or None, optional
+ Regime coordinate with shape ``(parameter_dim,)``. When omitted,
+ uses :meth:`set_parameters`.
+
+ Returns
+ -------
+ Tensor
+ Nonnegative weights with shape ``(num_modes,)`` summing to 1.
+ """
+ mu = self._resolve_parameters(parameters)
+ if self.weight_kind == "simplex":
+ return torch.softmax(self.gate(mu), dim=-1)
+ dist_sq = torch.sum((self.anchors - mu) ** 2, dim=-1)
+ scale = self.lengthscale * self.lengthscale
+ logits = -dist_sq / scale
+ return torch.softmax(logits, dim=-1)
+
+ @property
+ def matrix(self) -> Tensor:
+ """Equal-weight mix of mode matrices (state-independent view).
+
+ Returns
+ -------
+ Tensor
+ Uniform convex combination of the :math:`K_j`. This is not
+ :math:`K(\\mu)` at a stored coordinate.
+ """
+ stacked = torch.stack([mode.matrix for mode in self.modes], dim=0)
+ weights = torch.full(
+ (self.num_modes,),
+ 1.0 / self.num_modes,
+ dtype=stacked.dtype,
+ device=stacked.device,
+ )
+ return torch.einsum("m,mij->ij", weights, stacked)
+
+ def bound_metric(self) -> Tensor:
+ """Maximum component bound metric.
+
+ Returns
+ -------
+ Tensor
+ Scalar bound.
+ """
+ return torch.stack([mode.bound_metric() for mode in self.modes]).max()
+
+ def effective_matrix(self, parameters: Tensor | None = None) -> Tensor:
+ """Interpolant matrix :math:`K(\\mu)`.
+
+ Parameters
+ ----------
+ parameters : Tensor or None, optional
+ Regime coordinate. When omitted, uses :meth:`set_parameters`.
+
+ Returns
+ -------
+ Tensor
+ Convex combination of mode matrices.
+ """
+ weights = self.interpolation_weights(parameters)
+ stacked = torch.stack([mode.matrix for mode in self.modes], dim=0)
+ return torch.einsum("m,mij->ij", weights, stacked)
+
+ def _controlled_step(
+ self,
+ z: Tensor,
+ *,
+ matrix: Tensor,
+ control: Tensor | None,
+ ) -> Tensor:
+ """Advance with a frozen interpolant matrix and mode-0 control.
+
+ Parameters
+ ----------
+ z : Tensor
+ Latent states.
+ matrix : Tensor
+ Effective ``K``.
+ control : Tensor or None
+ Control input.
+
+ Returns
+ -------
+ Tensor
+ Advanced latents.
+ """
+ active = self.modes[0]
+ coupling = (
+ active.bilinear_matrices() if self.control_mode == "bilinear" else None
+ )
+ return advance_step(
+ z,
+ control,
+ matrix=matrix,
+ control_matrix=getattr(active, "B", None) if self.control_dim > 0 else None,
+ control_dim=self.control_dim,
+ control_mode=self.control_mode,
+ latent_dim=self.latent_dim,
+ coupling=coupling,
+ )
+
+ def advance(
+ self,
+ z: Tensor,
+ delta_t: float | Tensor | None = None,
+ *,
+ control: Tensor | None = None,
+ parameters: Tensor | None = None,
+ edge_index: Tensor | None = None,
+ edge_weight: Tensor | None = None,
+ ) -> Tensor:
+ """Advance with :math:`K(\\mu)`.
+
+ Parameters
+ ----------
+ z : Tensor
+ Latent states.
+ delta_t : float, Tensor, or None, optional
+ Ignored.
+ control : Tensor or None, optional
+ Control input.
+ parameters : Tensor or None, optional
+ Regime coordinate with shape ``(parameter_dim,)``.
+ edge_index : Tensor or None, optional
+ Ignored.
+ edge_weight : Tensor or None, optional
+ Ignored.
+
+ Returns
+ -------
+ Tensor
+ Advanced latents.
+ """
+ del delta_t, edge_index, edge_weight
+ matrix = self.effective_matrix(parameters)
+ return self._controlled_step(z, matrix=matrix, control=control)
+
+ def inverse_advance(
+ self,
+ z: Tensor,
+ delta_t: float | Tensor | None = None,
+ *,
+ control: Tensor | None = None,
+ inverse_matrix: Tensor | None = None,
+ parameters: Tensor | None = None,
+ edge_index: Tensor | None = None,
+ edge_weight: Tensor | None = None,
+ ) -> Tensor:
+ """Inverse of the interpolant at :math:`\\mu` (not a mix of inverses).
+
+ Parameters
+ ----------
+ z : Tensor
+ Latent states.
+ delta_t : float, Tensor, or None, optional
+ Ignored.
+ control : Tensor or None, optional
+ Control input.
+ inverse_matrix : Tensor or None, optional
+ Optional precomputed inverse of :math:`K(\\mu)`.
+ parameters : Tensor or None, optional
+ Regime coordinate.
+ edge_index : Tensor or None, optional
+ Ignored.
+ edge_weight : Tensor or None, optional
+ Ignored.
+
+ Returns
+ -------
+ Tensor
+ Inverse-advanced latents.
+ """
+ del delta_t, edge_index, edge_weight
+ if inverse_matrix is None:
+ inverse_matrix = torch.linalg.inv(self.effective_matrix(parameters))
+ return self.modes[0].inverse_advance(
+ z, control=control, inverse_matrix=inverse_matrix
+ )
+
+ def forward(
+ self,
+ z: Tensor,
+ control: Tensor | None = None,
+ *,
+ parameters: Tensor | None = None,
+ ) -> Tensor:
+ """Module forward.
+
+ Parameters
+ ----------
+ z : Tensor
+ Latent states.
+ control : Tensor or None, optional
+ Control input.
+ parameters : Tensor or None, optional
+ Regime coordinate.
+
+ Returns
+ -------
+ Tensor
+ Advanced latents.
+ """
+ return self.advance(z, control=control, parameters=parameters)
+
+
+@dataclass(frozen=True, eq=False)
+class LeaveOneRegimeOutReport:
+ """One-step comparison of an interpolant against pooled LTI EDMD.
+
+ Attributes
+ ----------
+ held_out_mu : Tensor
+ Regime coordinate that was held out, shape ``(d_mu,)``.
+ interpolant_mse : float
+ Hold-out one-step MSE of the RBF interpolant.
+ pooled_lti_mse : float
+ Hold-out one-step MSE of ridge EDMD on concatenated train pairs.
+ n_train_pairs : int
+ Number of latent pairs used to fit the interpolant and the pooled map.
+ n_holdout_pairs : int
+ Number of hold-out pairs.
+ ridge : float
+ Tikhonov weight used for both EDMD fits.
+ """
+
+ held_out_mu: Tensor
+ interpolant_mse: float
+ pooled_lti_mse: float
+ n_train_pairs: int
+ n_holdout_pairs: int
+ ridge: float
+
+
+def leave_one_regime_out(
+ regimes: Sequence[tuple[Tensor, Tensor, Tensor]],
+ *,
+ hold_out: int,
+ ridge: float = 1e-4,
+) -> LeaveOneRegimeOutReport:
+ """Fit an RBF interpolant on all but one :math:`\\mu` and compare to pooled LTI.
+
+ Each regime is ``(mu, z, z_next)`` with ``mu`` shape ``(d_mu,)`` and
+ latent pairs shape ``(n_j, d)``. Train regimes receive per-regime ridge
+ EDMD maps placed at those :math:`\\mu` as RBF anchors. The pooled map is
+ ridge EDMD on the concatenated train pairs. Both are scored by one-step
+ MSE on the held-out regime. This is an identification helper on latents,
+ not a GNN forecast claim.
+
+ Parameters
+ ----------
+ regimes : sequence of (Tensor, Tensor, Tensor)
+ Per-regime ``(mu, z, z_next)``. At least two regimes are required.
+ hold_out : int
+ Index of the held-out regime.
+ ridge : float, optional
+ Tikhonov weight. Default is ``1e-4``.
+
+ Returns
+ -------
+ LeaveOneRegimeOutReport
+ Hold-out MSEs and pair counts.
+
+ Raises
+ ------
+ ValueError
+ If there are fewer than two regimes, ``hold_out`` is out of range,
+ widths disagree, or a train regime is empty.
+ """
+ n_regimes = len(regimes)
+ if n_regimes < 2:
+ msg = f"leave_one_regime_out requires at least 2 regimes, got {n_regimes}"
+ raise ValueError(msg)
+ if not 0 <= int(hold_out) < n_regimes:
+ msg = f"hold_out must be in [0, {n_regimes}), got {hold_out}"
+ raise ValueError(msg)
+ hold = int(hold_out)
+ mu_h, z_h, z_next_h = regimes[hold]
+ if mu_h.ndim != 1 or int(mu_h.shape[0]) < 1:
+ msg = (
+ "held-out mu must have shape (d_mu,) with d_mu >= 1, "
+ f"got {tuple(mu_h.shape)}"
+ )
+ raise ValueError(msg)
+ parameter_dim = int(mu_h.shape[0])
+ train_mus: list[Tensor] = []
+ train_maps: list[Tensor] = []
+ train_z: list[Tensor] = []
+ train_z_next: list[Tensor] = []
+ latent_dim: int | None = None
+ for index, (mu, z, z_next) in enumerate(regimes):
+ if index == hold:
+ continue
+ if mu.shape != mu_h.shape:
+ msg = (
+ f"regime {index} mu shape {tuple(mu.shape)} must match "
+ f"held-out {tuple(mu_h.shape)}"
+ )
+ raise ValueError(msg)
+ k_j = _ridge_edmd(z, z_next, ridge=ridge)
+ if latent_dim is None:
+ latent_dim = int(k_j.shape[0])
+ train_mus.append(mu)
+ train_maps.append(k_j)
+ train_z.append(z)
+ train_z_next.append(z_next)
+ if latent_dim is None:
+ msg = "leave_one_regime_out requires at least one train regime"
+ raise ValueError(msg)
+ interpolant = ParametricKoopmanOperator(
+ latent_dim,
+ num_modes=len(train_maps),
+ parameter_dim=parameter_dim,
+ weight_kind="rbf",
+ parameterization="dense",
+ init_mode="identity",
+ control_dim=0,
+ )
+ interpolant.set_anchors(torch.stack(train_mus, dim=0))
+ pairwise = torch.cdist(interpolant.anchors, interpolant.anchors)
+ off = pairwise[pairwise > 0]
+ if off.numel() > 0:
+ interpolant.set_lengthscale(float(off.median().item()))
+ for mode, matrix in zip(interpolant.modes, train_maps, strict=True):
+ mode.set_dense_matrix(matrix)
+ k_hold = interpolant.effective_matrix(mu_h)
+ interpolant_mse = float(_one_step_mse(z_h, z_next_h, k_hold).item())
+ pooled = _ridge_edmd(
+ torch.cat(train_z, dim=0),
+ torch.cat(train_z_next, dim=0),
+ ridge=ridge,
+ )
+ pooled_mse = float(_one_step_mse(z_h, z_next_h, pooled).item())
+ n_train = int(sum(int(block.shape[0]) for block in train_z))
+ return LeaveOneRegimeOutReport(
+ held_out_mu=mu_h.detach().clone(),
+ interpolant_mse=interpolant_mse,
+ pooled_lti_mse=pooled_mse,
+ n_train_pairs=n_train,
+ n_holdout_pairs=int(z_h.shape[0]),
+ ridge=float(ridge),
+ )
diff --git a/src/koopman_graph/operators/polynomial_graph.py b/src/koopman_graph/operators/polynomial_graph.py
new file mode 100644
index 0000000..d47a1e6
--- /dev/null
+++ b/src/koopman_graph/operators/polynomial_graph.py
@@ -0,0 +1,205 @@
+"""Monomial polynomial helpers for discrete graph Koopman operators.
+
+The degree-``P`` networked map (row-state convention
+``Z ← Z K^T``) is the Kronecker polynomial
+
+.. math::
+
+ K_{\\mathrm{eff}}
+ = \\sum_{k=0}^{P} \\widehat{A}^{k} \\otimes K_{k},
+ \\qquad
+ K_{0} \\equiv K_{\\mathrm{self}},
+ \\quad
+ K_{1} \\equiv K_{\\mathrm{nbr}}.
+
+This module assembles that sum and applies the equivalent stacked matvec
+``vec(Z_{t+1}) = K_eff vec(Z_t)``. It is **not** a second public operator
+class; :class:`~koopman_graph.operators.graph.GraphKoopmanOperator` owns
+``filter_degree`` (default ``1``). Dual random-walk is an extra
+**direction** (a second shift), not an extra hop: callers pass each
+normalized adjacency separately.
+
+Chebyshev bases and the exact Kronecker spectrum reduction
+``eig(sum_k λ^k K_k)`` are out of scope here.
+
+References
+----------
+Guo, Y., Schaller, M., Worthmann, K. & Streif, S. (2025). Modularized
+data-driven approximation of the Koopman operator and generator.
+*Physica D: Nonlinear Phenomena*, 476, 134651.
+https://doi.org/10.1016/j.physd.2025.134651
+(``Guo2025ModularEDMD``)
+
+The monomial hop expansion here is **not** Guo et al. modularized EDMD
+(subsystem dictionaries and generator-side coupling). That paper is
+related networked-EDMD literature only.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Sequence
+
+import torch
+from torch import Tensor
+
+__all__ = [
+ "apply_monomial_powers",
+ "dense_polynomial_kronecker",
+ "validate_filter_degree",
+]
+
+
+def validate_filter_degree(filter_degree: object) -> int:
+ """Return a non-negative integer hop degree, or raise ``ValueError``.
+
+ Parameters
+ ----------
+ filter_degree : object
+ Requested monomial degree ``P``. Must be a non-boolean ``int``
+ with ``P >= 0``.
+
+ Returns
+ -------
+ int
+ Validated degree.
+
+ Raises
+ ------
+ ValueError
+ If ``filter_degree`` is a ``bool``, not an ``int``, or is negative.
+ """
+ if isinstance(filter_degree, bool) or not isinstance(filter_degree, int):
+ msg = (
+ "filter_degree must be a non-negative int, "
+ f"got {filter_degree!r} ({type(filter_degree).__name__})"
+ )
+ raise ValueError(msg)
+ if filter_degree < 0:
+ msg = f"filter_degree must be >= 0, got {filter_degree}"
+ raise ValueError(msg)
+ return filter_degree
+
+
+def dense_polynomial_kronecker(
+ adjacency: Tensor,
+ hop_matrices: Sequence[Tensor],
+ *,
+ start_power: int = 0,
+) -> Tensor:
+ """Assemble ``sum_k A^{start_power+k} ⊗ K_k`` for dense factors.
+
+ Parameters
+ ----------
+ adjacency : Tensor
+ Dense shift ``(N, N)`` (normalized adjacency ``Â`` or a dual
+ counterpart).
+ hop_matrices : sequence of Tensor
+ Factors ``K_{start_power}, …`` each with shape ``(d, d)``. Must be
+ non-empty.
+ start_power : int, optional
+ Exponent on ``adjacency`` for ``hop_matrices[0]``. Default ``0``
+ (identity Kronecker term). Must be ``>= 0``.
+
+ Returns
+ -------
+ Tensor
+ Dense ``(N·d, N·d)`` Kronecker polynomial. Empty ``hop_matrices``
+ is rejected because the latent width ``d`` would be undefined.
+
+ Raises
+ ------
+ ValueError
+ If ``adjacency`` is not square 2-D, ``start_power < 0``,
+ ``hop_matrices`` is empty, or a factor is not square ``(d, d)``.
+ """
+ if adjacency.ndim != 2 or adjacency.shape[0] != adjacency.shape[1]:
+ msg = (
+ f"adjacency must be a square 2-D tensor, got shape {tuple(adjacency.shape)}"
+ )
+ raise ValueError(msg)
+ if start_power < 0:
+ msg = f"start_power must be >= 0, got {start_power}"
+ raise ValueError(msg)
+ if not hop_matrices:
+ msg = "hop_matrices must be non-empty"
+ raise ValueError(msg)
+
+ latent_dim = hop_matrices[0].shape[0]
+ expected = (latent_dim, latent_dim)
+ for index, matrix in enumerate(hop_matrices):
+ if matrix.shape != expected:
+ msg = (
+ f"hop_matrices[{index}] must have shape {expected}, "
+ f"got {tuple(matrix.shape)}"
+ )
+ raise ValueError(msg)
+
+ power = torch.linalg.matrix_power(adjacency, start_power)
+ total = torch.kron(power, hop_matrices[0])
+ for matrix in hop_matrices[1:]:
+ power = power @ adjacency
+ total = total + torch.kron(power, matrix)
+ return total
+
+
+def apply_monomial_powers(
+ states: Tensor,
+ hop_matrices: Sequence[Tensor],
+ matvec: Callable[[Tensor], Tensor],
+ *,
+ min_power: int = 1,
+) -> Tensor:
+ """Apply ``sum_k (A^k Z) K_k^T`` via repeated adjacency matvecs.
+
+ ``hop_matrices[0]`` is the factor at hop ``min_power``. The self term
+ (``k = 0``) is **not** applied here.
+
+ Parameters
+ ----------
+ states : Tensor
+ Node latents ``Z`` with shape ``(N, d)``.
+ hop_matrices : sequence of Tensor
+ Factors ``K_{min_power}, …`` each ``(d, d)``. An empty sequence
+ returns zeros like ``states``.
+ matvec : callable
+ One-hop map ``Z → Â Z`` (or a dual backward shift).
+ min_power : int, optional
+ Hop count for ``hop_matrices[0]``. Default ``1``. Must be
+ ``>= 1``.
+
+ Returns
+ -------
+ Tensor
+ Neighbor-style contribution with the same shape as ``states``.
+
+ Raises
+ ------
+ ValueError
+ If ``min_power < 1`` or a factor shape does not match ``d``.
+ """
+ if min_power < 1:
+ msg = f"min_power must be >= 1 (self term is not a matvec), got {min_power}"
+ raise ValueError(msg)
+ if not hop_matrices:
+ return torch.zeros_like(states)
+
+ latent_dim = states.shape[-1]
+ expected = (latent_dim, latent_dim)
+ for index, matrix in enumerate(hop_matrices):
+ if matrix.shape != expected:
+ msg = (
+ f"hop_matrices[{index}] must have shape {expected}, "
+ f"got {tuple(matrix.shape)}"
+ )
+ raise ValueError(msg)
+
+ term = torch.zeros_like(states)
+ powered = states
+ n_factors = len(hop_matrices)
+ max_power = min_power + n_factors - 1
+ for power in range(1, max_power + 1):
+ powered = matvec(powered)
+ index = power - min_power
+ if 0 <= index < n_factors:
+ term = term + powered @ hop_matrices[index].T
+ return term
diff --git a/src/koopman_graph/operators/stochastic.py b/src/koopman_graph/operators/stochastic.py
index 94feede..81d7116 100644
--- a/src/koopman_graph/operators/stochastic.py
+++ b/src/koopman_graph/operators/stochastic.py
@@ -5,7 +5,9 @@
:math:`Q = I_N \\otimes \\mathrm{diag}(\\sigma^2)`, where
:math:`\\sigma = \\mathrm{softplus}(\\texttt{process_log_std})`. Noise is applied
in :meth:`~koopman_graph.operators.contract.KoopmanOperatorContract.advance`
-after the linear map; :meth:`forward` stays deterministic.
+after the linear map; :meth:`forward` stays deterministic. Distinct from
+the opt-in :class:`~koopman_graph.operators.DriftDiffusionKoopman`
+Euler–Maruyama / Yosida stepper.
"""
from __future__ import annotations
diff --git a/src/koopman_graph/operators/stochastic_sde.py b/src/koopman_graph/operators/stochastic_sde.py
new file mode 100644
index 0000000..b3496a1
--- /dev/null
+++ b/src/koopman_graph/operators/stochastic_sde.py
@@ -0,0 +1,312 @@
+"""Opt-in drift–diffusion stepper (Euler–Maruyama / Yosida MVP).
+
+The SDE :math:`dz = L z\\,dt + \\Sigma\\,dW` is a discrete-semigroup
+approximation, not certified Itô theory and not stochastic gEDMD / SDMD
+identification (``Xu2025StochasticSemigroup``, ``Zhou2025Yosida``).
+``dynamics_mode="stochastic"`` remains diagonal process noise after a
+discrete :math:`K` (:mod:`koopman_graph.operators.stochastic`).
+
+This module must not import :mod:`koopman_graph.model`,
+:mod:`koopman_graph.data`, or :mod:`koopman_graph.uq`.
+"""
+
+from __future__ import annotations
+
+import math
+from typing import Literal
+
+import torch
+from torch import Tensor, nn
+
+StepKind = Literal["euler_maruyama", "yosida"]
+DEFAULT_DIFFUSION_RANK = 1
+DEFAULT_STEP_KIND: StepKind = "euler_maruyama"
+_DIFFUSION_INIT = 0.1
+
+__all__ = [
+ "DEFAULT_DIFFUSION_RANK",
+ "DEFAULT_STEP_KIND",
+ "DriftDiffusionKoopman",
+]
+
+
+def _require_positive_interval(delta_t: float | Tensor) -> float:
+ """Validate a shared integration interval.
+
+ Parameters
+ ----------
+ delta_t : float or Tensor
+ Step length in the caller time unit.
+
+ Returns
+ -------
+ float
+ Accepted interval.
+
+ Raises
+ ------
+ ValueError
+ If ``delta_t`` is not a finite non-negative scalar.
+ """
+ value = float(torch.as_tensor(delta_t).reshape(-1)[0].item())
+ if not math.isfinite(value) or value < 0.0:
+ msg = f"delta_t must be a finite non-negative interval, got {delta_t!r}"
+ raise ValueError(msg)
+ return value
+
+
+class DriftDiffusionKoopman(nn.Module):
+ """Latent drift–diffusion :math:`dz = Lz\\,dt + F dW` with a discrete step.
+
+ Row-vector convention matches continuous operators
+ (:math:`z \\mapsto z L^{\\top}`). Diffusion is low-rank
+ :math:`\\Sigma\\Sigma^{\\top} = F F^{\\top}` with independent Wiener
+ increments per leading batch / node row. ``forward`` is the
+ conditional-expectation semigroup step (no noise). ``advance`` draws
+ one path increment.
+
+ Euler–Maruyama uses :math:`z + \\Delta t\\, z L^{\\top}`. Yosida uses
+ the implicit resolvent :math:`z (I - \\Delta t L^{\\top})^{-1}`.
+ Neither is a certified SDE semigroup or SDMD estimator.
+
+ Parameters
+ ----------
+ latent_dim : int
+ Latent width :math:`d`.
+ diffusion_rank : int, optional
+ Rank of :math:`F` with shape ``(d, rank)``. Default is 1.
+ step_kind : {"euler_maruyama", "yosida"}, optional
+ Discrete increment. Default is Euler–Maruyama.
+
+ Notes
+ -----
+ :math:`L` has units of inverse time. :math:`F` has units of latent
+ per square-root time. ``delta_t`` shares the time unit.
+ ``dynamics_mode="stochastic"`` is a different API (diagonal
+ :math:`\\varepsilon` after discrete :math:`K`).
+
+ References
+ ----------
+ Xu, Y., Shao, K., Ishikawa, I., Hashimoto, Y., Logothetis, N. and
+ Shen, Z. (2025). A data-driven framework for Koopman semigroup
+ estimation in stochastic dynamical systems. *Chaos* 35:103123.
+ doi:10.1063/5.0283640 (``Xu2025StochasticSemigroup``).
+ Zhou, J., Meng, Y. and Liu, J. (2025). Koopman spectral analysis
+ and system identification for stochastic dynamical systems via
+ Yosida approximation of generators. arXiv:2504.13912
+ (``Zhou2025Yosida``).
+ """
+
+ def __init__(
+ self,
+ latent_dim: int,
+ *,
+ diffusion_rank: int = DEFAULT_DIFFUSION_RANK,
+ step_kind: StepKind = DEFAULT_STEP_KIND,
+ ) -> None:
+ """Initialize a dense drift and low-rank diffusion factor.
+
+ Parameters
+ ----------
+ latent_dim : int
+ Latent width.
+ diffusion_rank : int, optional
+ Rank of the diffusion factor. Default is 1.
+ step_kind : {"euler_maruyama", "yosida"}, optional
+ Discrete increment kind.
+ """
+ super().__init__()
+ if type(latent_dim) is not int or latent_dim < 1:
+ msg = f"latent_dim must be a positive int, got {latent_dim!r}"
+ raise ValueError(msg)
+ if type(diffusion_rank) is not int or not 1 <= diffusion_rank <= latent_dim:
+ msg = (
+ "diffusion_rank must be an int in "
+ f"[1, {latent_dim}], got {diffusion_rank!r}"
+ )
+ raise ValueError(msg)
+ if step_kind not in {"euler_maruyama", "yosida"}:
+ msg = f"step_kind must be 'euler_maruyama' or 'yosida', got {step_kind!r}"
+ raise ValueError(msg)
+ self.latent_dim = latent_dim
+ self.diffusion_rank = diffusion_rank
+ self.step_kind: StepKind = step_kind
+ self.L = nn.Parameter(-torch.eye(latent_dim))
+ factor = torch.zeros(latent_dim, diffusion_rank)
+ for index in range(diffusion_rank):
+ factor[index, index] = _DIFFUSION_INIT
+ self.diffusion = nn.Parameter(factor)
+
+ def set_drift(self, drift: Tensor) -> None:
+ """Write the dense generator :math:`L`.
+
+ Parameters
+ ----------
+ drift : Tensor
+ Square matrix with shape ``(d, d)`` in inverse-time units.
+
+ Raises
+ ------
+ ValueError
+ If the shape is not ``(latent_dim, latent_dim)``.
+ """
+ if drift.shape != (self.latent_dim, self.latent_dim):
+ msg = (
+ "drift must have shape "
+ f"({self.latent_dim}, {self.latent_dim}), got {tuple(drift.shape)}"
+ )
+ raise ValueError(msg)
+ with torch.no_grad():
+ self.L.copy_(drift.to(dtype=self.L.dtype, device=self.L.device))
+
+ def set_diffusion(self, factor: Tensor) -> None:
+ """Write the low-rank factor :math:`F`.
+
+ Parameters
+ ----------
+ factor : Tensor
+ Matrix with shape ``(d, rank)`` in latent per square-root time.
+
+ Raises
+ ------
+ ValueError
+ If the shape is not ``(latent_dim, diffusion_rank)``.
+ """
+ expected = (self.latent_dim, self.diffusion_rank)
+ if factor.shape != expected:
+ msg = (
+ "diffusion factor must have shape "
+ f"{expected}, got {tuple(factor.shape)}"
+ )
+ raise ValueError(msg)
+ with torch.no_grad():
+ self.diffusion.copy_(
+ factor.to(dtype=self.diffusion.dtype, device=self.diffusion.device)
+ )
+
+ def diffusion_covariance(self) -> Tensor:
+ """Return :math:`F F^{\\top}` with shape ``(d, d)``.
+
+ Returns
+ -------
+ Tensor
+ Instantaneous diffusion covariance (latent² / time).
+ """
+ return self.diffusion @ self.diffusion.transpose(-1, -2)
+
+ def mean_advance(self, z: Tensor, delta_t: float | Tensor) -> Tensor:
+ """Conditional-expectation semigroup step (no diffusion).
+
+ Parameters
+ ----------
+ z : Tensor
+ Latents with trailing dimension ``latent_dim``.
+ delta_t : float or Tensor
+ Interval in the caller time unit. ``0`` returns ``z``.
+
+ Returns
+ -------
+ Tensor
+ Mean increment with the same shape as ``z``.
+ """
+ self._check_latent(z)
+ interval = _require_positive_interval(delta_t)
+ if interval == 0.0:
+ return z
+ if self.step_kind == "euler_maruyama":
+ return z + interval * (z @ self.L.transpose(-1, -2))
+ eye = torch.eye(self.latent_dim, dtype=z.dtype, device=z.device)
+ left = eye - interval * self.L
+ return torch.linalg.solve(left, z.transpose(-1, -2)).transpose(-1, -2)
+
+ def advance(
+ self,
+ z: Tensor,
+ delta_t: float | Tensor | None = None,
+ *,
+ control: Tensor | None = None,
+ edge_index: Tensor | None = None,
+ edge_weight: Tensor | None = None,
+ generator: torch.Generator | None = None,
+ ) -> Tensor:
+ """Draw one path increment (sampled diffusion).
+
+ Parameters
+ ----------
+ z : Tensor
+ Latents with trailing dimension ``latent_dim``.
+ delta_t : float, Tensor, or None
+ Required interval in the caller time unit.
+ control : Tensor or None, optional
+ Must be ``None``. The MVP is uncontrolled.
+ edge_index, edge_weight : Tensor or None, optional
+ Ignored (per-node stepper).
+ generator : torch.Generator or None, optional
+ Optional RNG for the Wiener increment.
+
+ Returns
+ -------
+ Tensor
+ Sampled latents with the same shape as ``z``.
+
+ Raises
+ ------
+ ValueError
+ If ``delta_t`` is missing or ``control`` is set.
+ """
+ del edge_index, edge_weight
+ if control is not None:
+ msg = "DriftDiffusionKoopman is uncontrolled; pass control=None"
+ raise ValueError(msg)
+ if delta_t is None:
+ msg = "DriftDiffusionKoopman.advance requires delta_t"
+ raise ValueError(msg)
+ mean = self.mean_advance(z, delta_t)
+ interval = _require_positive_interval(delta_t)
+ if interval == 0.0:
+ return mean
+ noise_shape = (*z.shape[:-1], self.diffusion_rank)
+ xi = torch.randn(
+ noise_shape,
+ dtype=z.dtype,
+ device=z.device,
+ generator=generator,
+ )
+ return mean + math.sqrt(interval) * (xi @ self.diffusion.transpose(-1, -2))
+
+ def forward(self, z: Tensor, delta_t: float | Tensor) -> Tensor:
+ """Module forward: conditional-expectation step.
+
+ Parameters
+ ----------
+ z : Tensor
+ Latents with trailing dimension ``latent_dim``.
+ delta_t : float or Tensor
+ Interval in the caller time unit.
+
+ Returns
+ -------
+ Tensor
+ Mean increment (no diffusion).
+ """
+ return self.mean_advance(z, delta_t)
+
+ def _check_latent(self, z: Tensor) -> None:
+ """Refuse a trailing width that is not ``latent_dim``.
+
+ Parameters
+ ----------
+ z : Tensor
+ Candidate latents.
+
+ Raises
+ ------
+ ValueError
+ If the trailing dimension does not match.
+ """
+ if z.shape[-1] != self.latent_dim:
+ msg = (
+ "z trailing dimension must equal latent_dim "
+ f"{self.latent_dim}, got {tuple(z.shape)}"
+ )
+ raise ValueError(msg)
diff --git a/src/koopman_graph/operators/switched.py b/src/koopman_graph/operators/switched.py
index ace42bd..ea710e3 100644
--- a/src/koopman_graph/operators/switched.py
+++ b/src/koopman_graph/operators/switched.py
@@ -3,9 +3,13 @@
A finite bank of LTI :class:`~koopman_graph.operators.KoopmanOperator`
maps. The active mode is an integer ``mode_index`` (default 0) or the
argmax of a latent gate. Each mode remains linear; this is not a nonlinear
-latent operator.
+latent operator and is not a parameter interpolant :math:`K(\\mu)`
+(``Macesic2018Nonautonomous``). Carry regime coordinates on
+:attr:`~koopman_graph.data.GraphSnapshotSequence.parameter_trajectory`.
-Select via ``koopman="switched"``.
+Select via ``koopman="switched"``. Optional per-step ``phase_index``
+selects a mode without mutating :attr:`mode_index` (time-of-day bins
+from :func:`~koopman_graph.data.diurnal_phase_index`).
"""
from __future__ import annotations
@@ -133,6 +137,33 @@ def _active(self) -> KoopmanOperator:
"""
return self.modes[self.mode_index] # type: ignore[return-value]
+ def _mode_for(self, phase_index: int | None) -> KoopmanOperator:
+ """Return the LTI mode for a step, optionally overriding the latch.
+
+ Parameters
+ ----------
+ phase_index : int or None
+ When set, select that bank index without writing
+ :attr:`mode_index`. ``None`` uses the latched mode.
+
+ Returns
+ -------
+ KoopmanOperator
+ Mode used for this step.
+
+ Raises
+ ------
+ ValueError
+ If ``phase_index`` is outside ``[0, num_modes)``.
+ """
+ if phase_index is None:
+ return self._active()
+ index = int(phase_index)
+ if not 0 <= index < self.num_modes:
+ msg = f"phase_index must be in [0, {self.num_modes}), got {phase_index}"
+ raise ValueError(msg)
+ return self.modes[index] # type: ignore[return-value]
+
def infer_mode(self, z: Tensor) -> int:
"""Return the argmax gate mode from mean latents.
@@ -188,6 +219,7 @@ def advance(
control: Tensor | None = None,
edge_index: Tensor | None = None,
edge_weight: Tensor | None = None,
+ phase_index: int | None = None,
) -> Tensor:
"""Advance with the active LTI map.
@@ -203,6 +235,8 @@ def advance(
Ignored (per-node modes).
edge_weight : Tensor or None, optional
Ignored.
+ phase_index : int or None, optional
+ Per-step mode override. Does not write :attr:`mode_index`.
Returns
-------
@@ -210,7 +244,7 @@ def advance(
Advanced latents.
"""
del delta_t, edge_index, edge_weight
- return self._active().advance(z, control=control)
+ return self._mode_for(phase_index).advance(z, control=control)
def inverse_advance(
self,
@@ -221,6 +255,7 @@ def inverse_advance(
inverse_matrix: Tensor | None = None,
edge_index: Tensor | None = None,
edge_weight: Tensor | None = None,
+ phase_index: int | None = None,
) -> Tensor:
"""Inverse step of the active LTI map.
@@ -238,6 +273,8 @@ def inverse_advance(
Ignored.
edge_weight : Tensor or None, optional
Ignored.
+ phase_index : int or None, optional
+ Per-step mode override. Does not write :attr:`mode_index`.
Returns
-------
@@ -245,11 +282,17 @@ def inverse_advance(
Inverse-advanced latents.
"""
del delta_t, edge_index, edge_weight
- return self._active().inverse_advance(
+ return self._mode_for(phase_index).inverse_advance(
z, control=control, inverse_matrix=inverse_matrix
)
- def forward(self, z: Tensor, control: Tensor | None = None) -> Tensor:
+ def forward(
+ self,
+ z: Tensor,
+ control: Tensor | None = None,
+ *,
+ phase_index: int | None = None,
+ ) -> Tensor:
"""Module forward: active-mode advance.
Parameters
@@ -258,10 +301,12 @@ def forward(self, z: Tensor, control: Tensor | None = None) -> Tensor:
Latent states.
control : Tensor or None, optional
Control input.
+ phase_index : int or None, optional
+ Per-step mode override. Does not write :attr:`mode_index`.
Returns
-------
Tensor
Advanced latents.
"""
- return self.advance(z, control=control)
+ return self.advance(z, control=control, phase_index=phase_index)
diff --git a/src/koopman_graph/protocols.py b/src/koopman_graph/protocols.py
index 16f22f5..3e24aaa 100644
--- a/src/koopman_graph/protocols.py
+++ b/src/koopman_graph/protocols.py
@@ -36,7 +36,11 @@ class ForecastModel(Protocol):
Structural contract for classical baselines
(:class:`~koopman_graph.baselines.DMDBaseline`,
:class:`~koopman_graph.baselines.DMDcBaseline`,
- :class:`~koopman_graph.baselines.EDMDBaseline`),
+ :class:`~koopman_graph.baselines.EDMDBaseline`,
+ :class:`~koopman_graph.baselines.MpEDMDBaseline`,
+ :class:`~koopman_graph.baselines.GEDMDBaseline`,
+ :class:`~koopman_graph.baselines.HankelDMDBaseline`,
+ :class:`~koopman_graph.baselines.HAVOKBaseline`),
spatiotemporal GNN forecaster baselines
(:class:`~koopman_graph.baselines.gnn.STGCNBaseline`,
:class:`~koopman_graph.baselines.gnn.DCRNNBaseline`,
@@ -45,7 +49,8 @@ class ForecastModel(Protocol):
**Not drop-in interchangeable at call sites.** Implementer signatures diverge
(DMDc ``predict`` requires ``controls``; classical baselines accept ``Data``
- only; GNN forecasters accept optional ``controls`` /
+ only; Hankel-DMD / HAVOK also accept optional ``history`` and zero-pad
+ older delays when it is omitted; GNN forecasters accept optional ``controls`` /
``future_topologies`` kwargs but reject non-``None`` values;
``GraphKoopmanModel.predict`` also accepts tensors, optional topology /
control kwargs; continuous ``spectrum`` may take ``delta_t``; GNN
@@ -171,9 +176,15 @@ class UncontrolledForecastModel(Protocol):
where ``initial_graph`` is a PyG ``Data`` snapshot. Satisfied (as a
signature superset or exact match) by
:class:`~koopman_graph.baselines.DMDBaseline`,
- :class:`~koopman_graph.baselines.EDMDBaseline`, and
+ :class:`~koopman_graph.baselines.EDMDBaseline`,
+ :class:`~koopman_graph.baselines.MpEDMDBaseline`,
+ :class:`~koopman_graph.baselines.GEDMDBaseline`,
+ :class:`~koopman_graph.baselines.HankelDMDBaseline`,
+ :class:`~koopman_graph.baselines.HAVOKBaseline`, and
:class:`~koopman_graph.model.GraphKoopmanModel` when called with
- ``Data`` + ``steps`` only.
+ ``Data`` + ``steps`` only. Optional ``history`` on Hankel-DMD / HAVOK
+ does not break that Data-only call; omitted history zero-pads older
+ delays.
:class:`~koopman_graph.baselines.DMDcBaseline` is **not** in this peer set:
its ``predict`` requires a ``controls`` sequence. Use loose
diff --git a/src/koopman_graph/serialization.py b/src/koopman_graph/serialization.py
index f8d384f..e9eea38 100644
--- a/src/koopman_graph/serialization.py
+++ b/src/koopman_graph/serialization.py
@@ -6,7 +6,8 @@
Full architecture config for discrete and continuous dynamics, hybrid
physics observables, control (including bilinear metadata), delay
embeddings, and built-in operator kinds (per-node / graph / hypergraph /
- global_local / continuous_graph / switched / mixture / hodge).
+ global_local / continuous_graph / switched / mixture / parametric /
+ hodge).
Encoder/decoder ``type`` strings include
``"gcn"``, ``"gat"``, ``"sage"``, ``"diffconv"``, ``"transformer"``,
``"hyper_enc"``, ``"hyper_dec"``, ``"sim_enc"``, ``"sim_dec"``,
@@ -23,7 +24,8 @@
``"block_diagonal"``, or ``"distributed"`` for supported networked kinds),
``adjacency`` (``"symmetric"`` / ``"random_walk"`` /
``"dual_random_walk"`` for graph / continuous-graph operators, else
- ``None``), additive ``hypergraph_incidence_mode``
+ ``None``), additive ``filter_degree`` (monomial hop degree for discrete
+ graph operators; absent ⇒ ``1``), additive ``hypergraph_incidence_mode``
(``"zhou_symmetric"`` / ``"forward_random_walk"`` /
``"dual_random_walk"``; absent ⇒ ``"zhou_symmetric"`` for hypergraph;
``None`` otherwise), ``learn_topology`` (``None`` or ``"self_adaptive"``)
@@ -31,7 +33,14 @@
``auto_orbits``, ``orbit_partition``, and ``method`` for orbit-tied
graph / hypergraph operators; additive ``symmetry`` field
``"orbit"`` / ``"isotypic"`` records ``koopman_symmetry`` without a
- format bump). When ``koopman_kind="hetero_graph"``,
+ format bump). Additive ``graph_dynamics`` (``None`` or a mapping with
+ ``topology_head``, ``recursive_training``, ``topology_loss_weight``,
+ ``presence_loss_weight``, and ``candidate_k``; absent ⇒ ``None``)
+ records opt-in graph-state closure without a format bump. Additive
+ ``koopman_num_modes`` / ``koopman_parameter_dim`` /
+ ``koopman_weight_kind`` (absent ⇒ factory defaults ``2`` / ``1`` /
+ ``"rbf"``) round-trip ``koopman_kind="parametric"`` without a format
+ bump. When ``koopman_kind="hetero_graph"``,
additive keys ``node_types``, ``edge_types`` (JSON ``(src, rel, dst)``
triples), ``relation_tying`` (``"independent"`` / ``"basis"``),
``basis_size``, and
@@ -116,6 +125,7 @@
from safetensors.torch import save_file as safetensors_save_file
from torch import nn
+from koopman_graph.data.graph_state import graph_dynamics_from_mapping
from koopman_graph.data.hetero_layout import validate_latent_dims
from koopman_graph.nn import (
DEFAULT_TOPOLOGY_EMBEDDING_DIM,
@@ -159,6 +169,7 @@
HypergraphKoopmanOperator,
KoopmanOperator,
MixtureKoopmanOperator,
+ ParametricKoopmanOperator,
SwitchedKoopmanOperator,
resolve_factory_stability_bound,
)
@@ -247,6 +258,7 @@
SwitchedKoopmanOperator,
MixtureKoopmanOperator,
HodgeKoopmanOperator,
+ ParametricKoopmanOperator,
)
_RESERVED_KOOPMAN_KINDS: dict[str, str] = {}
@@ -876,8 +888,8 @@ def _require_serializable_koopman(model: ModeShapeModel) -> None:
"ContinuousKoopmanOperator, GraphKoopmanOperator, "
"HypergraphKoopmanOperator, GlobalLocalKoopmanOperator, "
"ContinuousGraphKoopmanOperator, HeteroGraphKoopmanOperator, "
- "SwitchedKoopmanOperator, MixtureKoopmanOperator, and "
- "HodgeKoopmanOperator "
+ "SwitchedKoopmanOperator, MixtureKoopmanOperator, "
+ "ParametricKoopmanOperator, and HodgeKoopmanOperator "
"instances. Custom injected operators are not round-trippable; "
"save the operator state separately or reconstruct the model with "
f"koopman=... after load. Got {type(model.koopman).__name__}."
@@ -1164,6 +1176,11 @@ def build_model_config(model: ModeShapeModel) -> dict[str, Any]:
"decoder": decoder_config,
"sparsity": sparsity,
"adjacency": adjacency,
+ "filter_degree": (
+ int(model.koopman.filter_degree)
+ if isinstance(model.koopman, GraphKoopmanOperator)
+ else None
+ ),
"hypergraph_incidence_mode": (
str(incidence_mode)
if isinstance(model.koopman, HypergraphKoopmanOperator)
@@ -1175,6 +1192,11 @@ def build_model_config(model: ModeShapeModel) -> dict[str, Any]:
if getattr(model, "learn_topology", None) is not None
else None
),
+ "graph_dynamics": (
+ model.graph_dynamics.to_mapping()
+ if getattr(model, "graph_dynamics", None) is not None
+ else None
+ ),
"symmetry": (
model.koopman.symmetry_config()
if hasattr(model.koopman, "symmetry_config")
@@ -1182,6 +1204,21 @@ def build_model_config(model: ModeShapeModel) -> dict[str, Any]:
),
"allow_node_churn": bool(getattr(model, "allow_node_churn", False)),
"has_presence_masks": bool(getattr(model, "has_presence_masks", False)),
+ "koopman_num_modes": (
+ int(model.koopman.num_modes)
+ if isinstance(model.koopman, ParametricKoopmanOperator)
+ else None
+ ),
+ "koopman_parameter_dim": (
+ int(model.koopman.parameter_dim)
+ if isinstance(model.koopman, ParametricKoopmanOperator)
+ else None
+ ),
+ "koopman_weight_kind": (
+ str(model.koopman.weight_kind)
+ if isinstance(model.koopman, ParametricKoopmanOperator)
+ else None
+ ),
}
entity_ids = getattr(model, "entity_ids", None)
if entity_ids is not None:
@@ -1478,6 +1515,9 @@ def reconstruct_model(
koopman_auxiliary_hidden_dims=config.get("koopman_auxiliary_hidden_dims"),
koopman_sparsity=config.get("sparsity", "dense"),
koopman_adjacency=koopman_adjacency,
+ koopman_filter_degree=(
+ 1 if config.get("filter_degree") is None else int(config["filter_degree"])
+ ),
koopman_hypergraph_incidence_mode=str(
config.get("hypergraph_incidence_mode", "zhou_symmetric")
or "zhou_symmetric"
@@ -1499,6 +1539,7 @@ def reconstruct_model(
if topology_embedding_dim is not None
else DEFAULT_TOPOLOGY_EMBEDDING_DIM
),
+ graph_dynamics=graph_dynamics_from_mapping(config.get("graph_dynamics")),
control_dim=config.get("control_dim", 0),
control_mode=config.get("control_mode", "additive"),
bilinear_rank=config.get("bilinear_rank"),
@@ -1525,6 +1566,21 @@ def reconstruct_model(
else False
),
koopman_latent_dims=hetero_latent_dims,
+ koopman_num_modes=(
+ 2
+ if config.get("koopman_num_modes") is None
+ else int(config["koopman_num_modes"])
+ ),
+ koopman_parameter_dim=(
+ 1
+ if config.get("koopman_parameter_dim") is None
+ else int(config["koopman_parameter_dim"])
+ ),
+ koopman_weight_kind=(
+ "rbf"
+ if config.get("koopman_weight_kind") is None
+ else str(config["koopman_weight_kind"])
+ ),
)
if (
koopman_symmetry == "isotypic"
diff --git a/src/koopman_graph/spectrum_types.py b/src/koopman_graph/spectrum_types.py
index 1052b9a..bac37df 100644
--- a/src/koopman_graph/spectrum_types.py
+++ b/src/koopman_graph/spectrum_types.py
@@ -11,11 +11,219 @@
from __future__ import annotations
+import math
+import warnings
from dataclasses import dataclass
import torch
from torch import Tensor
+#: Argument tolerance (radians) for the Nyquist-adjacent aliasing flag.
+#: Negative-real eigenvalues have :math:`\arg=\pi` and therefore flag.
+ALIASING_ARG_ATOL = 1e-3
+
+#: Dimensionless 2-norm warning threshold for :math:`\kappa(V)` after
+#: column 2-normalization (same definition as
+#: :attr:`SpectralDiagnostics.eigenvector_condition`).
+#: :meth:`KoopmanSpectrum.mode_amplitudes` emits a ``UserWarning`` when
+#: :math:`\kappa(V)` exceeds this value, then still solves :math:`Va=z`.
+#: Float64 numerical singularity is near :math:`1/u\sim 10^{16}`; this
+#: threshold warns well before that.
+CONDITION_WARN = 1e6
+
+_DEFECTIVE_V_MESSAGE = (
+ "Eigenvector matrix is singular (defective or numerically "
+ "non-diagonalizable). Do not invert V; use a Schur invariant "
+ "subspace or a well-conditioned subset of modes. "
+ "mode_amplitudes does not compute a Schur form."
+)
+
+
+class DefectiveSpectrumError(torch.linalg.LinAlgError):
+ """Raised when an eigenbasis cannot be inverted because V is singular.
+
+ Used by :meth:`KoopmanSpectrum.mode_amplitudes` and
+ :func:`~koopman_graph.operators.continuous_van_loan.matrix_log`.
+ The stored eigenbasis is defective or numerically singular, so
+ inverting :math:`V` is not well-posed. Use a Schur invariant
+ subspace, a well-conditioned subset of modes, or
+ ``matrix_log(..., defective="schur")``. This exception is **not**
+ an automatic Schur fallback and does **not** mean defectivity marks
+ every critical regime.
+
+ Notes
+ -----
+ Wilkinson (1965) treats defective eigenproblems: a matrix need not
+ admit a full eigenbasis. Finite-horizon transients of a non-normal
+ but diagonalizable map are a separate issue; see
+ :class:`SpectralDiagnostics`.
+
+ References
+ ----------
+ Wilkinson, J. H. (1965). *The Algebraic Eigenvalue Problem*. Clarendon
+ Press. (``Wilkinson1965``)
+ """
+
+
+@dataclass(frozen=True)
+class SpectralDiagnostics:
+ """Eigenvector conditioning, non-normality, and sampling diagnostics.
+
+ These quantities flag when a stored eigenbasis is a poor amplitude
+ coordinate and when non-normality can produce transient growth above
+ :math:`\\rho(K)^t`. Discrete spectra also report the Nyquist frequency
+ :math:`1/(2\\Delta t)` in **cycles per unit time**, a per-mode aliasing
+ flag when :math:`\\pi-|\\arg\\lambda|\\le` ``ALIASING_ARG_ATOL``,
+ and :math:`\\operatorname{sign}(\\operatorname{Re}\\lambda)` so that
+ :math:`\\log|\\lambda|` is not read as a signed growth rate. They are
+ **not** a finite-horizon bound on :math:`\\|K^k\\|`, not a replacement
+ for
+ :meth:`~koopman_graph.operators.KoopmanOperator.stability_certificate`,
+ not a resolvent-norm / pseudospectrum certificate, and not a
+ sampling-theorem identification result. Resolvent grids remain opt-in
+ via :func:`~koopman_graph.analysis.resolvent_norm_grid`.
+
+ :math:`\\kappa(V)` uses the matrix 2-norm of **column-normalized** right
+ eigenvectors so a normal matrix scores near 1 rather than reflecting
+ arbitrary ``eig`` column scaling. Per-mode
+ :math:`\\kappa_i = 1/|w_i^H v_i|` uses unit-norm left and right
+ eigenvectors. Departure is
+ :math:`\\|KK^H - K^H K\\|_F`; the relative variant divides by
+ :math:`\\|K\\|_F^2` (dimensionless; 0 when :math:`K=0`).
+
+ Attributes
+ ----------
+ eigenvector_condition : float
+ :math:`\\kappa(V) = \\|V\\|_2 \\|V^{-1}\\|_2` after column
+ 2-normalization. ``+inf`` when :math:`V` is numerically singular.
+ Dimensionless.
+ eigenvalue_sensitivities : Tensor
+ Per-mode Wilkinson :math:`\\kappa_i` with shape ``(latent_dim,)``,
+ aligned with stored eigenpairs. Non-negative; ``+inf`` when the
+ corresponding pair cannot be normalized. Dimensionless.
+ departure_from_normality : float
+ Frobenius departure :math:`\\|KK^H - K^H K\\|_F` (same units as
+ :math:`|K|^2`). Always finite for a finite matrix.
+ departure_from_normality_relative : float
+ ``departure_from_normality / ||K||_F^2`` when :math:`\\|K\\|_F > 0`,
+ else ``0``. Dimensionless.
+ nyquist_frequency : float or None
+ Discrete Nyquist frequency :math:`1/(2\\Delta t)` in cycles per
+ unit time (the same time unit as ``time_step``). ``None`` on
+ generator spectra (the placeholder ``time_step=1.0`` on
+ :class:`KoopmanSpectrum` is not a sampling interval).
+ aliasing_warning : Tensor
+ Boolean mask with shape ``(latent_dim,)``. ``True`` when
+ :math:`\\pi-|\\arg\\lambda|\\le` ``ALIASING_ARG_ATOL``
+ (radians). All ``False`` on generator spectra. Negative-real
+ eigenvalues have :math:`\\arg=\\pi` and therefore flag.
+ sign_of_eigenvalue : Tensor
+ :math:`\\operatorname{sign}(\\operatorname{Re}\\lambda)\\in
+ \\{-1,0,1\\}` with shape ``(latent_dim,)``. Dimensionless.
+ Filled for both discrete and generator spectra.
+
+ Notes
+ -----
+ Finite-horizon transients of non-normal operators can exceed the
+ spectral-radius envelope even when every :math:`|\\lambda|\\le 1`.
+ Discrete frequencies remain principal values in cycles per unit
+ time; phase unwrapping does not recover aliases. :math:`\\log|\\lambda|`
+ discards sign.
+
+ References
+ ----------
+ Wilkinson, J. H. (1965). *The Algebraic Eigenvalue Problem*. Clarendon
+ Press. (``Wilkinson1965``)
+
+ Trefethen, L. N. and Embree, M. (2005). *Spectra and Pseudospectra*.
+ Princeton University Press. https://doi.org/10.1515/9780691213101
+ (``TrefethenEmbree2005``)
+
+ Nyquist frequency :math:`1/(2\\Delta t)` is standard sampling theory
+ (cycles per unit time).
+
+ Zeng, Z., Yue, Z., Mauroy, A., Gonçalves, J., and Yuan, Y. (2022).
+ A sampling theorem for exact identification of continuous-time
+ nonlinear dynamical systems. *2022 IEEE 61st Conference on Decision
+ and Control (CDC)*, 6686–6692.
+ https://doi.org/10.1109/CDC51059.2022.9992482
+ (``Zeng2022Sampling``; related Koopman sampling literature — this
+ helper does not implement that identification theorem.)
+ """
+
+ eigenvector_condition: float
+ eigenvalue_sensitivities: Tensor
+ departure_from_normality: float
+ departure_from_normality_relative: float
+ nyquist_frequency: float | None
+ aliasing_warning: Tensor
+ sign_of_eigenvalue: Tensor
+
+ def __post_init__(self) -> None:
+ """Validate shapes and non-negativity of diagnostic fields.
+
+ Raises
+ ------
+ ValueError
+ If sensitivities, aliasing flags, or signs are not aligned
+ 1-D tensors, contain NaN, or if any scalar field is NaN or
+ negative (``+inf`` is allowed for condition numbers).
+ """
+ sens = self.eigenvalue_sensitivities
+ if sens.ndim != 1 or int(sens.numel()) == 0:
+ msg = (
+ "eigenvalue_sensitivities must be a nonempty 1-D tensor, "
+ f"got shape {tuple(sens.shape)}"
+ )
+ raise ValueError(msg)
+ if bool(torch.isnan(sens).any().item()):
+ raise ValueError("eigenvalue_sensitivities must not contain NaN")
+ if bool((sens < 0).any().item()):
+ raise ValueError("eigenvalue_sensitivities must be non-negative")
+ n = int(sens.numel())
+ alias = self.aliasing_warning
+ if alias.ndim != 1 or int(alias.numel()) != n:
+ msg = (
+ "aliasing_warning must have shape (latent_dim,), "
+ f"got {tuple(alias.shape)} for latent_dim={n}"
+ )
+ raise ValueError(msg)
+ if alias.dtype != torch.bool:
+ raise ValueError("aliasing_warning must be a bool tensor")
+ signs = self.sign_of_eigenvalue
+ if signs.ndim != 1 or int(signs.numel()) != n:
+ msg = (
+ "sign_of_eigenvalue must have shape (latent_dim,), "
+ f"got {tuple(signs.shape)} for latent_dim={n}"
+ )
+ raise ValueError(msg)
+ if signs.is_complex():
+ raise ValueError("sign_of_eigenvalue must be real")
+ if bool(torch.isnan(signs).any().item()):
+ raise ValueError("sign_of_eigenvalue must not contain NaN")
+ allowed = (signs == -1) | (signs == 0) | (signs == 1)
+ if not bool(allowed.all().item()):
+ raise ValueError("sign_of_eigenvalue values must be in {-1, 0, 1}")
+ nyquist = self.nyquist_frequency
+ if nyquist is not None and (
+ math.isnan(nyquist) or not math.isfinite(nyquist) or nyquist <= 0
+ ):
+ raise ValueError(
+ f"nyquist_frequency must be a finite value > 0 when set, got {nyquist}"
+ )
+ for name, value in (
+ ("eigenvector_condition", self.eigenvector_condition),
+ ("departure_from_normality", self.departure_from_normality),
+ (
+ "departure_from_normality_relative",
+ self.departure_from_normality_relative,
+ ),
+ ):
+ if math.isnan(value):
+ raise ValueError(f"{name} must not be NaN")
+ if value < 0:
+ raise ValueError(f"{name} must be non-negative")
+
@dataclass(frozen=True)
class KoopmanSpectrum:
@@ -39,10 +247,19 @@ class KoopmanSpectrum:
as a placeholder (native continuous-time units).
Optional ``residuals`` carry a posteriori data-driven residuals from
- :func:`~koopman_graph.analysis.spectral_residuals`. Populate with
- ``dataclasses.replace(spectrum, residuals=report.residuals)`` — this
- dataclass is frozen. Residuals are analysis state only and are **not**
- part of model checkpoints.
+ :func:`~koopman_graph.analysis.spectral_residuals`. Optional
+ ``diagnostics`` carry :class:`SpectralDiagnostics` (eigenvector
+ condition, per-mode Wilkinson sensitivities, Frobenius departure
+ from normality, discrete Nyquist frequency in cycles per unit time,
+ aliasing flags, and :math:`\\operatorname{sign}(\\operatorname{Re}
+ \\lambda)`). Populate either field with ``dataclasses.replace`` —
+ this dataclass is frozen. Residuals and diagnostics are analysis
+ state only and are **not** part of model checkpoints. Diagnostics
+ are **not** a finite-horizon bound on :math:`\\|K^k\\|`. Discrete
+ frequencies remain principal values; they do not recover aliases.
+ :meth:`mode_amplitudes` warns when :math:`\\kappa(V)` exceeds
+ ``CONDITION_WARN`` and raises :class:`DefectiveSpectrumError` when
+ :math:`V` is singular.
Attributes
----------
@@ -63,6 +280,13 @@ class KoopmanSpectrum:
residuals : Tensor or None
Optional non-negative real residuals with shape ``(latent_dim,)``.
Default is ``None`` (unset).
+ diagnostics : SpectralDiagnostics or None
+ Optional conditioning / non-normality diagnostics. Default is
+ ``None`` (unset; Kronecker assembly paths may omit them).
+ :func:`compute_spectrum` and :func:`compute_generator_spectrum`
+ populate this field. Generator spectra set
+ ``nyquist_frequency`` to ``None`` and ``aliasing_warning`` to
+ all ``False``.
"""
eigenvalues: Tensor
@@ -72,37 +296,57 @@ class KoopmanSpectrum:
frequencies: Tensor
time_step: float
residuals: Tensor | None = None
+ diagnostics: SpectralDiagnostics | None = None
def __post_init__(self) -> None:
- """Validate optional residual tensor shape and values.
+ """Validate optional residual and diagnostic tensors.
Raises
------
ValueError
If ``residuals`` is present but not a 1-D tensor of length
- ``latent_dim``, or contains negative / non-finite values.
+ ``latent_dim``, or contains negative / non-finite values; or if
+ diagnostic tensors are not length ``latent_dim``.
"""
- if self.residuals is None:
- return
latent_dim = int(self.eigenvalues.numel())
- if self.residuals.ndim != 1 or int(self.residuals.numel()) != latent_dim:
- msg = (
- "residuals must have shape (latent_dim,), "
- f"got {tuple(self.residuals.shape)} for latent_dim={latent_dim}"
- )
- raise ValueError(msg)
- if not bool(torch.isfinite(self.residuals).all().item()):
- msg = "residuals must be finite"
- raise ValueError(msg)
- if bool((self.residuals < 0).any().item()):
- msg = "residuals must be non-negative"
- raise ValueError(msg)
+ if self.residuals is not None:
+ if self.residuals.ndim != 1 or int(self.residuals.numel()) != latent_dim:
+ msg = (
+ "residuals must have shape (latent_dim,), "
+ f"got {tuple(self.residuals.shape)} for latent_dim={latent_dim}"
+ )
+ raise ValueError(msg)
+ if not bool(torch.isfinite(self.residuals).all().item()):
+ msg = "residuals must be finite"
+ raise ValueError(msg)
+ if bool((self.residuals < 0).any().item()):
+ msg = "residuals must be non-negative"
+ raise ValueError(msg)
+ if self.diagnostics is None:
+ return
+ diag = self.diagnostics
+ for name, tensor in (
+ ("eigenvalue_sensitivities", diag.eigenvalue_sensitivities),
+ ("aliasing_warning", diag.aliasing_warning),
+ ("sign_of_eigenvalue", diag.sign_of_eigenvalue),
+ ):
+ if tensor.ndim != 1 or int(tensor.numel()) != latent_dim:
+ msg = (
+ f"diagnostics.{name} must have shape (latent_dim,), "
+ f"got {tuple(tensor.shape)} for latent_dim={latent_dim}"
+ )
+ raise ValueError(msg)
def mode_amplitudes(self, latent_states: Tensor) -> Tensor:
"""Project latent states onto the Koopman eigenvector basis.
For a latent row vector ``z``, the returned amplitudes ``a`` satisfy
- ``z.T = eigenvectors @ a``. Any leading dimensions are preserved.
+ ``z.T = eigenvectors @ a`` (equivalently :math:`Va=z^{\\top}`).
+ Any leading dimensions are preserved. When :math:`\\kappa(V)`
+ exceeds ``CONDITION_WARN`` the solve still proceeds after a
+ warning. Singular :math:`V` raises
+ :class:`DefectiveSpectrumError` rather than a bare
+ ``RuntimeError``.
Parameters
----------
@@ -118,8 +362,17 @@ def mode_amplitudes(self, latent_states: Tensor) -> Tensor:
------
ValueError
If the trailing latent dimension does not match the spectrum.
- RuntimeError
- If the eigenvector matrix is singular.
+ DefectiveSpectrumError
+ If the eigenvector matrix is singular or defective. The
+ message hints at a Schur invariant subspace; this method
+ does not compute one.
+
+ Warnings
+ --------
+ UserWarning
+ Emitted when :math:`\\kappa(V)` exceeds ``CONDITION_WARN``.
+ The solve still proceeds. Not a finite-horizon bound on
+ :math:`\\|K^k\\|`.
"""
latent_dim = self.eigenvectors.shape[0]
if latent_states.ndim == 0 or latent_states.shape[-1] != latent_dim:
@@ -130,12 +383,311 @@ def mode_amplitudes(self, latent_states: Tensor) -> Tensor:
raise ValueError(msg)
vectors = self.eigenvectors.to(device=latent_states.device)
+ if self.diagnostics is not None:
+ kappa = self.diagnostics.eigenvector_condition
+ else:
+ kappa = _column_normalized_condition(vectors)
+ if not math.isfinite(kappa):
+ raise DefectiveSpectrumError(_DEFECTIVE_V_MESSAGE)
+ if kappa > CONDITION_WARN:
+ warnings.warn(
+ f"Eigenvector condition kappa(V)={kappa:.3e} exceeds "
+ f"CONDITION_WARN={CONDITION_WARN:.0e}. Mode amplitudes "
+ "remain the solution of V a = z but are a poor "
+ "coordinate; this is not a finite-horizon bound on "
+ "||K^k||.",
+ UserWarning,
+ stacklevel=2,
+ )
states = latent_states.to(dtype=vectors.dtype)
flat_states = states.reshape(-1, latent_dim)
- amplitudes = torch.linalg.solve(vectors, flat_states.T).T
+ try:
+ amplitudes = torch.linalg.solve(vectors, flat_states.T).T
+ except torch.linalg.LinAlgError as exc:
+ raise DefectiveSpectrumError(_DEFECTIVE_V_MESSAGE) from exc
return amplitudes.reshape(latent_states.shape)
+def _departure_from_normality(matrix: Tensor) -> tuple[float, float]:
+ """Return Frobenius departure and the relative (scale-free) variant.
+
+ Parameters
+ ----------
+ matrix : Tensor
+ Square operator or generator.
+
+ Returns
+ -------
+ tuple of float
+ Absolute :math:`\\|MM^H-M^H M\\|_F` and that quantity over
+ :math:`\\|M\\|_F^2` (0 when the Frobenius norm is 0).
+ """
+ commutator = matrix @ matrix.mH - matrix.mH @ matrix
+ departure = float(torch.linalg.matrix_norm(commutator, ord="fro").real.item())
+ frobenius = float(torch.linalg.matrix_norm(matrix, ord="fro").real.item())
+ if frobenius == 0.0:
+ return departure, 0.0
+ return departure, departure / (frobenius * frobenius)
+
+
+def _real_dtype_for(vectors: Tensor) -> torch.dtype:
+ """Return a real dtype matching ``vectors`` precision.
+
+ Parameters
+ ----------
+ vectors : Tensor
+ Eigenvector matrix (real or complex).
+
+ Returns
+ -------
+ torch.dtype
+ ``float64`` for double / complex128 storage, else ``float32``.
+ """
+ if vectors.dtype in {torch.complex128, torch.float64}:
+ return torch.float64
+ return torch.float32
+
+
+def _column_unit_eigenvectors(eigenvectors: Tensor) -> Tensor | None:
+ """Return column 2-normalized eigenvectors, or None if invalid.
+
+ Parameters
+ ----------
+ eigenvectors : Tensor
+ Right eigenvectors as columns, shape ``(n, n)``.
+
+ Returns
+ -------
+ Tensor or None
+ Column-normalized copy, or ``None`` when a column norm is
+ non-finite or non-positive.
+ """
+ col_norms = torch.linalg.vector_norm(eigenvectors, dim=0)
+ if not bool(torch.isfinite(col_norms).all().item()) or bool(
+ (col_norms <= 0).any().item()
+ ):
+ return None
+ return eigenvectors / col_norms.to(dtype=eigenvectors.dtype)
+
+
+def _column_normalized_condition(eigenvectors: Tensor) -> float:
+ """Return :math:`\\kappa(V)` after column 2-normalization, or ``+inf``.
+
+ Parameters
+ ----------
+ eigenvectors : Tensor
+ Right eigenvectors as columns, shape ``(n, n)``.
+
+ Returns
+ -------
+ float
+ Dimensionless 2-norm condition number, or ``+inf`` when :math:`V`
+ is numerically singular.
+ """
+ vectors_unit = _column_unit_eigenvectors(eigenvectors)
+ if vectors_unit is None:
+ return math.inf
+ try:
+ condition = torch.linalg.cond(vectors_unit)
+ except (RuntimeError, torch.linalg.LinAlgError):
+ return math.inf
+ condition_value = float(condition.real.item())
+ if not math.isfinite(condition_value):
+ return math.inf
+ return condition_value
+
+
+def aliasing_warning_mask(eigenvalues: Tensor) -> Tensor:
+ """Return True where :math:`|\\arg\\lambda|` is within atol of :math:`\\pi`.
+
+ The comparison is
+ :math:`\\pi-|\\arg\\lambda|\\le` ``ALIASING_ARG_ATOL``
+ (radians). Negative-real values have :math:`\\arg=\\pi` and flag.
+ This mask does not recover aliases and does not change
+ :math:`\\log|\\lambda|` growth rates.
+
+ Parameters
+ ----------
+ eigenvalues : Tensor
+ Real or complex eigenvalues (any shape).
+
+ Returns
+ -------
+ Tensor
+ Boolean mask with the same shape as ``eigenvalues``.
+ """
+ values = (
+ eigenvalues
+ if eigenvalues.is_complex()
+ else eigenvalues.to(dtype=torch.complex128)
+ )
+ return (math.pi - torch.angle(values).abs()) <= ALIASING_ARG_ATOL
+
+
+def _nyquist_aliasing_fields(
+ eigenvalues: Tensor,
+ *,
+ time_step: float | None,
+) -> tuple[float | None, Tensor, Tensor]:
+ """Nyquist frequency, aliasing mask, and sign of Re(lambda).
+
+ Reports :math:`1/(2\\Delta t)` in cycles per unit time,
+ :math:`\\operatorname{sign}(\\operatorname{Re}\\lambda)`, and the
+ Nyquist-adjacent aliasing mask.
+
+ Parameters
+ ----------
+ eigenvalues : Tensor
+ Eigenvalues aligned with stored eigenpairs, shape ``(n,)``.
+ time_step : float or None
+ Discrete sampling interval, or ``None`` for a generator spectrum.
+
+ Returns
+ -------
+ nyquist_frequency : float or None
+ :math:`1/(2\\Delta t)` in cycles per unit time, or ``None``.
+ aliasing_warning : Tensor
+ Boolean mask, shape ``(n,)``. All ``False`` when ``time_step``
+ is ``None``.
+ sign_of_eigenvalue : Tensor
+ :math:`\\operatorname{sign}(\\operatorname{Re}\\lambda)` on the
+ eigenvalue device.
+
+ Raises
+ ------
+ ValueError
+ If ``time_step`` is set but is not a finite value > 0.
+ """
+ real_part = eigenvalues.real if eigenvalues.is_complex() else eigenvalues
+ signs = torch.sign(real_part)
+ n = int(eigenvalues.numel())
+ if time_step is None:
+ alias = torch.zeros(n, dtype=torch.bool, device=eigenvalues.device)
+ return None, alias, signs
+ dt = float(time_step)
+ if not math.isfinite(dt) or dt <= 0.0:
+ msg = f"time_step must be a finite value > 0 when set, got {time_step}"
+ raise ValueError(msg)
+ nyquist = 1.0 / (2.0 * dt)
+ return nyquist, aliasing_warning_mask(eigenvalues), signs
+
+
+def compute_spectral_diagnostics(
+ matrix: Tensor,
+ eigenvectors: Tensor,
+ *,
+ eigenvalues: Tensor,
+ time_step: float | None = None,
+) -> SpectralDiagnostics:
+ """Compute conditioning, non-normality, and sampling diagnostics.
+
+ ``matrix`` is the discrete operator or generator whose eigenpairs are
+ stored in ``eigenvectors`` (columns, already in spectrum order).
+ Pass the matching ``eigenvalues`` (same order). For discrete maps,
+ ``time_step`` is the sampling interval :math:`\\Delta t` and
+ ``nyquist_frequency`` is :math:`1/(2\\Delta t)` in cycles per unit
+ time. For generator spectra, pass ``time_step=None`` so Nyquist is
+ unset and aliasing flags stay ``False``. Does not assemble a
+ resolvent grid and does not implement a sampling-theorem
+ identification test.
+
+ Parameters
+ ----------
+ matrix : Tensor
+ Square operator or generator with shape ``(n, n)``.
+ eigenvectors : Tensor
+ Right eigenvectors as columns, shape ``(n, n)``.
+ eigenvalues : Tensor
+ Eigenvalues aligned with ``eigenvectors`` columns, shape
+ ``(n,)``.
+ time_step : float or None, optional
+ Discrete sampling interval. ``None`` (default) marks a generator
+ spectrum: ``nyquist_frequency`` is ``None`` and
+ ``aliasing_warning`` is all ``False``. ``sign_of_eigenvalue`` is
+ still filled.
+
+ Returns
+ -------
+ SpectralDiagnostics
+ Condition numbers, Frobenius departure, Nyquist frequency,
+ aliasing flags, and eigenvalue signs. ``+inf`` condition fields
+ when :math:`V` is numerically singular; departure and sampling
+ fields remain finite.
+
+ Raises
+ ------
+ ValueError
+ If ``matrix`` or ``eigenvectors`` is not a nonempty square pair of
+ matching size, if ``eigenvalues`` is not length ``n``, or if
+ ``time_step`` is set but is not a finite value > 0.
+ """
+ if matrix.ndim != 2 or matrix.shape[0] != matrix.shape[1]:
+ msg = f"matrix must be square, got shape {tuple(matrix.shape)}"
+ raise ValueError(msg)
+ if eigenvectors.ndim != 2 or eigenvectors.shape[0] != eigenvectors.shape[1]:
+ msg = f"eigenvectors must be square, got shape {tuple(eigenvectors.shape)}"
+ raise ValueError(msg)
+ if matrix.shape[0] != eigenvectors.shape[0]:
+ msg = (
+ "matrix and eigenvectors must share dimension, "
+ f"got {tuple(matrix.shape)} vs {tuple(eigenvectors.shape)}"
+ )
+ raise ValueError(msg)
+ if matrix.shape[0] == 0:
+ raise ValueError("matrix must be non-empty")
+ n = int(eigenvectors.shape[0])
+ if eigenvalues.ndim != 1 or int(eigenvalues.numel()) != n:
+ msg = (
+ "eigenvalues must have shape (n,), "
+ f"got {tuple(eigenvalues.shape)} for n={n}"
+ )
+ raise ValueError(msg)
+
+ departure, relative = _departure_from_normality(matrix)
+ nyquist, aliasing, signs = _nyquist_aliasing_fields(
+ eigenvalues,
+ time_step=time_step,
+ )
+ real_dtype = _real_dtype_for(eigenvectors)
+ inf_sens = torch.full(
+ (n,),
+ math.inf,
+ dtype=real_dtype,
+ device=eigenvectors.device,
+ )
+ inf_diag = SpectralDiagnostics(
+ eigenvector_condition=math.inf,
+ eigenvalue_sensitivities=inf_sens,
+ departure_from_normality=departure,
+ departure_from_normality_relative=relative,
+ nyquist_frequency=nyquist,
+ aliasing_warning=aliasing,
+ sign_of_eigenvalue=signs,
+ )
+
+ vectors_unit = _column_unit_eigenvectors(eigenvectors)
+ if vectors_unit is None:
+ return inf_diag
+ try:
+ condition = torch.linalg.cond(vectors_unit)
+ inverse = torch.linalg.inv(vectors_unit)
+ except (RuntimeError, torch.linalg.LinAlgError):
+ return inf_diag
+ condition_value = float(condition.real.item())
+ if not math.isfinite(condition_value):
+ return inf_diag
+ sensitivities = torch.linalg.vector_norm(inverse, dim=1).real.to(dtype=real_dtype)
+ return SpectralDiagnostics(
+ eigenvector_condition=condition_value,
+ eigenvalue_sensitivities=sensitivities,
+ departure_from_normality=departure,
+ departure_from_normality_relative=relative,
+ nyquist_frequency=nyquist,
+ aliasing_warning=aliasing,
+ sign_of_eigenvalue=signs,
+ )
+
+
def compute_spectrum(operator: Tensor, time_step: float) -> KoopmanSpectrum:
"""Compute the sorted spectrum and continuous-time mode characteristics.
@@ -155,7 +707,12 @@ def compute_spectrum(operator: Tensor, time_step: float) -> KoopmanSpectrum:
-------
KoopmanSpectrum
Eigenpairs sorted by descending magnitude, plus growth rates and
- frequencies converted using ``time_step``.
+ frequencies converted using ``time_step``. Frequencies are
+ principal values in **cycles per unit time**. ``diagnostics`` is
+ always populated, including Nyquist frequency
+ :math:`1/(2\\Delta t)`, aliasing flags, and
+ :math:`\\operatorname{sign}(\\operatorname{Re}\\lambda)`. Emits
+ one ``UserWarning`` when any mode is Nyquist-adjacent.
Raises
------
@@ -186,6 +743,22 @@ def compute_spectrum(operator: Tensor, time_step: float) -> KoopmanSpectrum:
growth_rates = torch.log(magnitudes) / time_step
frequencies = torch.angle(eigenvalues) / (2 * torch.pi * time_step)
+ diagnostics = compute_spectral_diagnostics(
+ operator,
+ eigenvectors,
+ eigenvalues=eigenvalues,
+ time_step=float(time_step),
+ )
+ if bool(diagnostics.aliasing_warning.any().item()):
+ warnings.warn(
+ "One or more discrete-time eigenvalues have argument within "
+ f"{ALIASING_ARG_ATOL} rad of pi (Nyquist phase). Reported "
+ "frequencies are principal values in cycles per unit time; "
+ "phase unwrapping does not recover aliases. log(|lambda|) "
+ "discards sign(Re lambda).",
+ UserWarning,
+ stacklevel=2,
+ )
return KoopmanSpectrum(
eigenvalues=eigenvalues,
eigenvectors=eigenvectors,
@@ -193,6 +766,7 @@ def compute_spectrum(operator: Tensor, time_step: float) -> KoopmanSpectrum:
growth_rates=growth_rates,
frequencies=frequencies,
time_step=float(time_step),
+ diagnostics=diagnostics,
)
@@ -214,7 +788,11 @@ def compute_generator_spectrum(generator: Tensor) -> KoopmanSpectrum:
-------
KoopmanSpectrum
Eigenpairs sorted by descending magnitude with native continuous-time
- growth rates and frequencies.
+ growth rates and frequencies in **cycles per unit time**.
+ ``diagnostics`` is always populated.
+ ``nyquist_frequency`` is ``None`` and ``aliasing_warning`` is
+ all ``False`` (the placeholder ``time_step=1.0`` is not a
+ sampling interval). ``sign_of_eigenvalue`` is still filled.
Raises
------
@@ -248,6 +826,12 @@ def compute_generator_spectrum(generator: Tensor) -> KoopmanSpectrum:
growth_rates=growth_rates,
frequencies=frequencies,
time_step=1.0,
+ diagnostics=compute_spectral_diagnostics(
+ generator,
+ eigenvectors,
+ eigenvalues=eigenvalues,
+ time_step=None,
+ ),
)
diff --git a/src/koopman_graph/training/__init__.py b/src/koopman_graph/training/__init__.py
index ba9a249..e8b4b02 100644
--- a/src/koopman_graph/training/__init__.py
+++ b/src/koopman_graph/training/__init__.py
@@ -29,17 +29,24 @@
composition (re-exports pair helpers for stable deep imports).
``epochs``
``train_one_epoch``, ``train_windowed_epoch``, ``eval_one_epoch``.
+``batched_objectives``
+ Opt-in ``compute_batched_training_loss`` for collated multi-graph
+ reconstruction / forward (package import; not on root ``__all__``).
``inputs``
Multi-trajectory classification and
``resolve_training_sequences`` / ``resolve_validation_sequences``.
``loop``
``run_fit_loop`` plus early-stopping / LR-scheduler helpers.
``callbacks``
- Observe-only :class:`FitCallback` protocol and :class:`NoOpFitCallback`
- (fit-loop wiring is separate; optional trackers live under
- ``koopman_graph.tracking`` when added).
+ Observe-only :class:`FitCallback` protocol, :class:`NoOpFitCallback`,
+ and :class:`ResDMDFitCallback` (default ``mode="observe"``;
+ ``mode="gate"`` may raise at fit end without mutating parameters).
"""
+from koopman_graph.training.batched_objectives import (
+ compute_batched_training_loss,
+ validate_graph_batching_request,
+)
from koopman_graph.training.callbacks import (
FitCallback,
NoOpFitCallback,
@@ -100,6 +107,7 @@
"TrainingInput",
"ValidationInput",
"compute_backward_consistency_sequence_loss",
+ "compute_batched_training_loss",
"compute_eigenvalue_regularization_loss",
"compute_forward_consistency_sequence_loss",
"compute_rollout_loss",
@@ -121,4 +129,5 @@
"should_stop_early",
"train_one_epoch",
"train_windowed_epoch",
+ "validate_graph_batching_request",
]
diff --git a/src/koopman_graph/training/batched_objectives.py b/src/koopman_graph/training/batched_objectives.py
new file mode 100644
index 0000000..1857236
--- /dev/null
+++ b/src/koopman_graph/training/batched_objectives.py
@@ -0,0 +1,459 @@
+"""Opt-in batched reconstruction / forward losses over a PyG ``Batch``.
+
+Vectorizes the default :func:`~koopman_graph.training.compute_training_loss`
+hot path (reconstruction, and forward consistency when weighted) across
+independent homogeneous trajectories. Other terms stay the per-sequence mean
+so totals still match :func:`~koopman_graph.training.mean_training_loss_breakdown`.
+Eigenvalue hinges are never evaluated on the disconnected union graph.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import replace
+
+import torch
+from torch import Tensor, nn
+from torch_geometric.data import Batch
+
+from koopman_graph.data import GraphSnapshotSequence
+from koopman_graph.data.batching import collate_graph_trajectories
+from koopman_graph.graph_utils import propagate_latent, snapshot_edge_weight
+from koopman_graph.losses import masked_mse_loss
+from koopman_graph.nn import DelayEmbeddingEncoder, HypergraphEncoder, RelGraphEncoder
+from koopman_graph.operators import (
+ ContinuousGraphKoopmanOperator,
+ ContinuousHeteroGraphKoopmanOperator,
+ ContinuousKoopmanOperator,
+ GlobalLocalKoopmanOperator,
+ HeteroGraphKoopmanOperator,
+ HypergraphKoopmanOperator,
+)
+from koopman_graph.protocols import TrainableKoopmanModel
+from koopman_graph.training.history import (
+ ExtraLosses,
+ LossWeights,
+ TrainingLossBreakdown,
+ mean_training_loss_breakdown,
+)
+from koopman_graph.training.objectives import compute_training_loss
+from koopman_graph.training.pair_objectives import model_default_delta_t
+
+__all__ = [
+ "compute_batched_training_loss",
+ "validate_graph_batching_request",
+]
+
+
+def _unwrap_encoder(model: TrainableKoopmanModel) -> nn.Module:
+ """Return the base encoder, unwrapping a delay wrapper when present.
+
+ Parameters
+ ----------
+ model : TrainableKoopmanModel
+ Model whose encoder is inspected.
+
+ Returns
+ -------
+ nn.Module
+ Delay base encoder when wrapped, else ``model.encoder``.
+ """
+ encoder = model.encoder
+ if isinstance(encoder, DelayEmbeddingEncoder):
+ return encoder.base_encoder
+ return encoder
+
+
+def validate_graph_batching_request(
+ model: TrainableKoopmanModel,
+ sequences: Sequence[GraphSnapshotSequence],
+) -> None:
+ """Refuse ``batch_graphs=True`` combinations that change ``N`` or mix families.
+
+ Parameters
+ ----------
+ model : TrainableKoopmanModel
+ Model whose operator, encoder, and delay / control flags are checked.
+ sequences : sequence of GraphSnapshotSequence
+ Trajectories that would be collated.
+
+ Raises
+ ------
+ TypeError
+ If any trajectory is not a :class:`GraphSnapshotSequence`.
+ ValueError
+ If a refused feature is active (hetero / hypergraph / continuous /
+ controls / delay / orbits / learned topology / timestamps).
+ """
+ if not sequences:
+ msg = "batch_graphs=True requires at least one GraphSnapshotSequence"
+ raise ValueError(msg)
+ for index, sequence in enumerate(sequences):
+ if not isinstance(sequence, GraphSnapshotSequence):
+ msg = (
+ "batch_graphs=True requires homogeneous GraphSnapshotSequence "
+ f"trajectories; index {index} has type {type(sequence).__name__}"
+ )
+ raise TypeError(msg)
+ if sequence.has_controls:
+ msg = (
+ "batch_graphs=True is unsupported with control_inputs; "
+ f"sequence {index} has controls"
+ )
+ raise ValueError(msg)
+ if sequence.has_hyperedges:
+ msg = (
+ "batch_graphs=True is unsupported with hyperedge incidence; "
+ f"sequence {index} has hyperedges"
+ )
+ raise ValueError(msg)
+ if sequence.has_timestamps:
+ msg = (
+ "batch_graphs=True is unsupported with timestamps; "
+ f"sequence {index} has timestamps"
+ )
+ raise ValueError(msg)
+
+ if int(getattr(model, "n_delays", 1)) > 1:
+ msg = "batch_graphs=True is unsupported with delay embeddings (n_delays>1)"
+ raise ValueError(msg)
+ if int(getattr(model, "control_dim", 0)) > 0:
+ msg = "batch_graphs=True is unsupported when control_dim > 0"
+ raise ValueError(msg)
+ if getattr(model, "learns_pairwise_topology", False):
+ msg = "batch_graphs=True is unsupported with learned pairwise topology"
+ raise ValueError(msg)
+ if getattr(model, "adaptive_topology", None) is not None:
+ msg = "batch_graphs=True is unsupported with adaptive topology"
+ raise ValueError(msg)
+ if getattr(model, "predicted_topology", None) is not None:
+ config = getattr(model, "graph_dynamics", None)
+ if config is not None and bool(config.recursive_training):
+ msg = (
+ "batch_graphs=True is unsupported with recursive graph-state "
+ "topology (predicted  feeds the operator step)"
+ )
+ raise ValueError(msg)
+ if getattr(model, "uses_hypergraph_koopman", False):
+ msg = "batch_graphs=True is unsupported with hypergraph Koopman operators"
+ raise ValueError(msg)
+
+ encoder = _unwrap_encoder(model)
+ if isinstance(encoder, (RelGraphEncoder, HypergraphEncoder)):
+ msg = (
+ "batch_graphs=True is unsupported with RelGraph or hypergraph "
+ f"encoders; got {type(encoder).__name__}"
+ )
+ raise ValueError(msg)
+
+ koopman = model.koopman
+ if isinstance(
+ koopman,
+ (
+ ContinuousKoopmanOperator,
+ ContinuousGraphKoopmanOperator,
+ ContinuousHeteroGraphKoopmanOperator,
+ HeteroGraphKoopmanOperator,
+ HypergraphKoopmanOperator,
+ GlobalLocalKoopmanOperator,
+ ),
+ ):
+ msg = (
+ "batch_graphs=True requires a discrete per-node or graph Koopman "
+ f"operator; got {type(koopman).__name__}"
+ )
+ raise ValueError(msg)
+ if getattr(koopman, "auto_orbits", False) or getattr(
+ koopman, "isotypic_symmetry", False
+ ):
+ msg = "batch_graphs=True is unsupported with orbit-tied or isotypic operators"
+ raise ValueError(msg)
+ if getattr(koopman, "orbit_partition", None) is not None:
+ msg = "batch_graphs=True is unsupported with orbit-tied operators"
+ raise ValueError(msg)
+ if getattr(koopman, "uses_orbit_selves", False):
+ msg = "batch_graphs=True is unsupported with orbit-tied operators"
+ raise ValueError(msg)
+
+
+def _per_graph_mse(
+ prediction: Tensor,
+ target: Tensor,
+ time_batch: Batch,
+ node_masks: Sequence[Tensor | None] | None,
+) -> Tensor:
+ """Return per-graph mean squared error over nodes and features.
+
+ Each graph is reduced independently (mean over that graph's contributing
+ entries), matching per-sequence ``mse_loss`` / ``masked_mse_loss`` rather
+ than a node-weighted union mean.
+
+ Parameters
+ ----------
+ prediction : Tensor
+ Concatenated predictions with shape ``(sum_g N_g, F)``.
+ target : Tensor
+ Concatenated targets with the same shape.
+ time_batch : Batch
+ Time-slice batch whose ``ptr`` splits concatenated node rows.
+ node_masks : sequence of Tensor or None, or None
+ Optional per-graph boolean masks with length ``time_batch.num_graphs``.
+
+ Returns
+ -------
+ Tensor
+ Per-graph scalars with shape ``(num_graphs,)``.
+ """
+ ptr = time_batch.ptr
+ num_graphs = int(time_batch.num_graphs)
+ losses: list[Tensor] = []
+ for graph_id in range(num_graphs):
+ start = int(ptr[graph_id].item())
+ stop = int(ptr[graph_id + 1].item())
+ pred_g = prediction[start:stop]
+ tgt_g = target[start:stop]
+ mask_g = None if node_masks is None else node_masks[graph_id]
+ if mask_g is None:
+ losses.append(nn.functional.mse_loss(pred_g, tgt_g))
+ else:
+ losses.append(masked_mse_loss(pred_g, tgt_g, mask_g))
+ return torch.stack(losses)
+
+
+def _optional_edge_weight(snapshot: Batch) -> Tensor | None:
+ """Return ``edge_weight`` when present on ``snapshot``.
+
+ Parameters
+ ----------
+ snapshot : Batch
+ Time-slice batch.
+
+ Returns
+ -------
+ Tensor or None
+ Edge weights, or ``None`` when absent.
+ """
+ return snapshot_edge_weight(snapshot)
+
+
+def _needs_non_vectorized_terms(
+ loss_weights: LossWeights,
+ model: TrainableKoopmanModel | None = None,
+) -> bool:
+ """Return whether any non-recon/forward term is active.
+
+ Parameters
+ ----------
+ loss_weights : LossWeights
+ Active weights.
+ model : TrainableKoopmanModel or None, optional
+ Used to detect graph-state topology / presence terms.
+
+ Returns
+ -------
+ bool
+ ``True`` when backward, rollout, eigenvalue, extras, sparsity,
+ or graph-state terms are active.
+ """
+ if (
+ loss_weights.backward != 0.0
+ or loss_weights.rollout != 0.0
+ or loss_weights.eigenvalue != 0.0
+ or loss_weights.lie != 0.0
+ or loss_weights.pde != 0.0
+ or loss_weights.sparsity != 0.0
+ or loss_weights.worst_case != 0.0
+ or loss_weights.vamp2 != 0.0
+ ):
+ return True
+ config = None if model is None else getattr(model, "graph_dynamics", None)
+ if config is None:
+ return False
+ return (
+ float(config.topology_loss_weight) > 0.0
+ or float(config.presence_loss_weight) > 0.0
+ )
+
+
+def compute_batched_training_loss(
+ model: TrainableKoopmanModel,
+ sequences: Sequence[GraphSnapshotSequence],
+ loss_weights: LossWeights,
+ *,
+ extra_losses: ExtraLosses | None = None,
+ rollout_horizon: int | None = None,
+ rollout_start_indices: Sequence[int] | None = None,
+) -> TrainingLossBreakdown:
+ """Compute training loss on a collated multi-graph ``Batch``.
+
+ Reconstruction (and forward consistency when weighted) reduce **per
+ graph then mean over graphs**, matching
+ :func:`~koopman_graph.training.mean_training_loss_breakdown`. Remaining
+ terms use the existing per-sequence helpers so the total still matches
+ a Python ``MultiTrajectory`` loop on modest batches.
+
+ Parameters
+ ----------
+ model : TrainableKoopmanModel
+ Homogeneous discrete per-node or graph model.
+ sequences : sequence of GraphSnapshotSequence
+ Trajectories sharing ``num_timesteps`` (node counts may differ).
+ loss_weights : LossWeights
+ Active term weights.
+ extra_losses : ExtraLosses or None, optional
+ Fit-time Lie / PDE callables for leftover per-sequence terms.
+ rollout_horizon : int or None, optional
+ Rollout horizon forwarded to leftover per-sequence losses.
+ rollout_start_indices : sequence of int or None, optional
+ Rollout origins forwarded to leftover per-sequence losses.
+
+ Returns
+ -------
+ TrainingLossBreakdown
+ Unweighted per-term losses and the weighted total.
+
+ Raises
+ ------
+ TypeError, ValueError
+ If :func:`validate_graph_batching_request` or collate rejects the
+ request, or a trajectory has fewer than two snapshots.
+ """
+ validate_graph_batching_request(model, sequences)
+ if sequences[0].num_timesteps < 2:
+ msg = "snapshot sequence must contain at least 2 snapshots for training"
+ raise ValueError(msg)
+
+ device = next(model.parameters()).device
+ reconstruction = torch.zeros((), device=device)
+ forward = torch.zeros((), device=device)
+ vectorize_recon = loss_weights.reconstruction != 0.0
+ vectorize_forward = loss_weights.forward != 0.0
+
+ if vectorize_recon or vectorize_forward:
+ batched = collate_graph_trajectories(sequences)
+ num_times = batched.num_timesteps
+ time_batches = [
+ batched.graphs_at_time(timestep) for timestep in range(num_times)
+ ]
+ latents = [model.encode(time_batch) for time_batch in time_batches]
+ default_delta_t = model_default_delta_t(model)
+ num_pairs = num_times - 1
+ recon_pairs: list[Tensor] = []
+ forward_pairs: list[Tensor] = []
+ for timestep in range(num_pairs):
+ source = time_batches[timestep]
+ target_batch = time_batches[timestep + 1]
+ z_next = propagate_latent(
+ model.koopman,
+ latents[timestep],
+ default_delta_t=default_delta_t,
+ edge_index=target_batch.edge_index,
+ edge_weight=_optional_edge_weight(target_batch),
+ )
+ if vectorize_recon:
+ prediction = model.decoder(
+ z_next,
+ source.edge_index,
+ _optional_edge_weight(source),
+ )
+ recon_masks = [
+ sequence.loss_mask_at(timestep + 1) for sequence in sequences
+ ]
+ recon_pairs.append(
+ _per_graph_mse(
+ prediction,
+ target_batch.x,
+ source,
+ recon_masks,
+ )
+ )
+ if vectorize_forward:
+ pair_masks = [
+ sequence.pair_loss_mask(timestep) for sequence in sequences
+ ]
+ forward_pairs.append(
+ _per_graph_mse(
+ z_next,
+ latents[timestep + 1],
+ source,
+ pair_masks,
+ )
+ )
+ if vectorize_recon:
+ reconstruction = torch.stack(recon_pairs, dim=0).mean(dim=0).mean()
+ if vectorize_forward:
+ forward = torch.stack(forward_pairs, dim=0).mean(dim=0).mean()
+
+ backward = torch.zeros((), device=device)
+ rollout = torch.zeros((), device=device)
+ eigenvalue = torch.zeros((), device=device)
+ lie = torch.zeros((), device=device)
+ pde = torch.zeros((), device=device)
+ sparsity = torch.zeros((), device=device)
+ worst_case = torch.zeros((), device=device)
+ vamp2 = torch.zeros((), device=device)
+ topology = torch.zeros((), device=device)
+ presence = torch.zeros((), device=device)
+ if _needs_non_vectorized_terms(loss_weights, model):
+ rest_weights = replace(
+ loss_weights,
+ reconstruction=0.0,
+ forward=0.0,
+ )
+ rest = mean_training_loss_breakdown(
+ [
+ compute_training_loss(
+ model,
+ sequence,
+ rest_weights,
+ extra_losses=extra_losses,
+ rollout_horizon=rollout_horizon,
+ rollout_start_indices=rollout_start_indices,
+ )
+ for sequence in sequences
+ ]
+ )
+ backward = rest.backward
+ rollout = rest.rollout
+ eigenvalue = rest.eigenvalue
+ lie = rest.lie
+ pde = rest.pde
+ sparsity = rest.sparsity
+ worst_case = rest.worst_case
+ vamp2 = rest.vamp2
+ topology = rest.topology
+ presence = rest.presence
+
+ config = getattr(model, "graph_dynamics", None)
+ topology_weight = 0.0 if config is None else float(config.topology_loss_weight)
+ presence_weight = 0.0 if config is None else float(config.presence_loss_weight)
+
+ total = (
+ loss_weights.reconstruction * reconstruction
+ + loss_weights.forward * forward
+ + loss_weights.backward * backward
+ + loss_weights.rollout * rollout
+ + loss_weights.eigenvalue * eigenvalue
+ + loss_weights.lie * lie
+ + loss_weights.pde * pde
+ + loss_weights.sparsity * sparsity
+ + loss_weights.worst_case * worst_case
+ + loss_weights.vamp2 * vamp2
+ + topology_weight * topology
+ + presence_weight * presence
+ )
+ return TrainingLossBreakdown(
+ reconstruction=reconstruction,
+ forward=forward,
+ backward=backward,
+ rollout=rollout,
+ eigenvalue=eigenvalue,
+ lie=lie,
+ pde=pde,
+ sparsity=sparsity,
+ worst_case=worst_case,
+ vamp2=vamp2,
+ topology=topology,
+ presence=presence,
+ total=total,
+ )
diff --git a/src/koopman_graph/training/callbacks.py b/src/koopman_graph/training/callbacks.py
index 70bdce1..360bb2f 100644
--- a/src/koopman_graph/training/callbacks.py
+++ b/src/koopman_graph/training/callbacks.py
@@ -1,14 +1,19 @@
-"""Observe-only hooks for :func:`~koopman_graph.training.loop.run_fit_loop`.
+"""Fit-loop observer hooks for :func:`~koopman_graph.training.loop.run_fit_loop`.
-Callbacks must not mutate model parameters. Wiring into the fit loop is
-added in a later task; this module defines the protocol and a no-op
-implementation for typing and smoke tests.
+Callbacks must not mutate model parameters. The loop invokes
+``on_fit_start``, ``on_epoch_end``, and ``on_fit_end``. Callbacks that
+expose ``observe_encodings`` receive a frozen time-major latent stack
+from the first training sequence at epoch end (identity-dictionary
+ResDMD layout).
"""
from __future__ import annotations
+import math
from collections.abc import Mapping
-from typing import Any, Protocol, runtime_checkable
+from typing import Any, Literal, Protocol, runtime_checkable
+
+from torch import Tensor
from koopman_graph.training.history import FitHistory, TrainingLossBreakdown
@@ -128,25 +133,69 @@ def on_fit_end(self, *, history: FitHistory) -> None:
class ResDMDFitCallback:
- """Observe-only callback recording the last mean ResDMD residual.
-
- Notes
- -----
- Does not mutate parameters. Callers set :attr:`last_mean_residual` from
- an external :func:`~koopman_graph.analysis.resdmd` evaluation.
+ """Finite-dictionary ResDMD observer with optional opt-in gate.
+
+ Default ``mode="observe"`` records residuals and does not abort.
+ ``mode="gate"`` sets :attr:`rejected` when the max residual exceeds
+ ``tolerance`` and raises :class:`ValueError` from :meth:`on_fit_end`.
+ Does not mutate model parameters. Not a certified infinite-dimensional
+ residual bound.
+
+ Attributes
+ ----------
+ mode : {"observe", "gate"}
+ Observe-only or reject-at-fit-end.
+ tolerance : float
+ Max-residual cutoff (same default as
+ :func:`~koopman_graph.analysis.resdmd`).
+ last_mean_residual : float or None
+ Mean residual from the last :meth:`record` / :meth:`observe_encodings`.
+ last_residual_max : float or None
+ Max residual from the last observation.
+ polluted : bool or None
+ Whether the last max residual exceeded ``tolerance``.
+ rejected : bool
+ True when ``mode="gate"`` and a polluted residual was recorded.
"""
- def __init__(self) -> None:
+ def __init__(
+ self,
+ *,
+ mode: Literal["observe", "gate"] = "observe",
+ tolerance: float = 1e-2,
+ ) -> None:
"""Initialize with no recorded residual.
- Notes
- -----
- ``last_mean_residual`` starts as ``None``.
+ Parameters
+ ----------
+ mode : {"observe", "gate"}, optional
+ Default ``"observe"``.
+ tolerance : float, optional
+ Pollution cutoff. Default ``1e-2``.
+
+ Raises
+ ------
+ ValueError
+ If ``mode`` is unknown or ``tolerance`` is invalid.
"""
+ if mode not in {"observe", "gate"}:
+ msg = f"mode must be 'observe' or 'gate', got {mode!r}"
+ raise ValueError(msg)
+ if isinstance(tolerance, bool) or not isinstance(tolerance, (int, float)):
+ msg = f"tolerance must be a finite float, got {type(tolerance).__name__}"
+ raise ValueError(msg)
+ if not math.isfinite(float(tolerance)) or float(tolerance) < 0.0:
+ msg = f"tolerance must be a finite non-negative float, got {tolerance!r}"
+ raise ValueError(msg)
+ self.mode: Literal["observe", "gate"] = mode
+ self.tolerance = float(tolerance)
self.last_mean_residual: float | None = None
+ self.last_residual_max: float | None = None
+ self.polluted: bool | None = None
+ self.rejected = False
def on_fit_start(self, *, model: Any, fit_kwargs: Mapping[str, Any]) -> None:
- """Reset the recorded residual.
+ """Reset recorded residuals and gate flags.
Parameters
----------
@@ -157,6 +206,9 @@ def on_fit_start(self, *, model: Any, fit_kwargs: Mapping[str, Any]) -> None:
"""
del model, fit_kwargs
self.last_mean_residual = None
+ self.last_residual_max = None
+ self.polluted = None
+ self.rejected = False
def on_epoch_end(
self,
@@ -166,7 +218,7 @@ def on_epoch_end(
val_breakdown: TrainingLossBreakdown | None,
history_so_far: FitHistory | None,
) -> None:
- """No parameter updates; residual is recorded by the caller.
+ """No parameter updates; residuals arrive via :meth:`observe_encodings`.
Parameters
----------
@@ -182,21 +234,93 @@ def on_epoch_end(
del epoch, train_breakdown, val_breakdown, history_so_far
def on_fit_end(self, *, history: FitHistory) -> None:
- """No-op fit end.
+ """Raise in gate mode when a polluted residual was recorded.
Parameters
----------
history : FitHistory
Unused final history.
+
+ Raises
+ ------
+ ValueError
+ If ``mode="gate"`` and :attr:`rejected` is True.
"""
del history
-
- def record(self, residual: float) -> None:
+ if self.mode == "gate" and self.rejected:
+ peak = self.last_residual_max
+ msg = (
+ "ResDMD gate rejected the fit dictionary "
+ f"(residual_max={peak!r} > tolerance={self.tolerance:g})"
+ )
+ raise ValueError(msg)
+
+ def record(self, residual: float, *, residual_max: float | None = None) -> None:
"""Store a scalar residual observation.
Parameters
----------
residual : float
Mean finite-dictionary ResDMD residual.
+ residual_max : float or None, optional
+ Max residual used for the pollution test. Default uses
+ ``residual`` as both mean and max.
+
+ Raises
+ ------
+ ValueError
+ If a residual is non-finite or negative.
+ """
+ mean_residual = float(residual)
+ peak = mean_residual if residual_max is None else float(residual_max)
+ for name, value in (("residual", mean_residual), ("residual_max", peak)):
+ if not math.isfinite(value) or value < 0.0:
+ msg = f"{name} must be a finite non-negative float, got {value!r}"
+ raise ValueError(msg)
+ self.last_mean_residual = mean_residual
+ self.last_residual_max = peak
+ self.polluted = peak > self.tolerance
+ if self.mode == "gate" and self.polluted:
+ self.rejected = True
+
+ def observe_encodings(self, encodings: Tensor) -> None:
+ """Run identity-dictionary ResDMD on a time-major latent stack.
+
+ Layout matches ``evaluate(..., include_resdmd=True)``: one row per
+ snapshot, width :math:`N\\cdot d` (or already ``(T, m)``). Needs
+ ``T >= 3``. Lazy-imports :mod:`koopman_graph.analysis.resdmd`.
+
+ Parameters
+ ----------
+ encodings : Tensor
+ Time-major latents ``(T, N, d)`` or ``(T, m)``.
+
+ Raises
+ ------
+ ValueError
+ If the tensor rank is not 2 or 3.
"""
- self.last_mean_residual = float(residual)
+ if encodings.ndim == 3:
+ stacked = encodings.reshape(encodings.shape[0], -1)
+ elif encodings.ndim == 2:
+ stacked = encodings
+ else:
+ msg = (
+ "encodings must have shape (T, d) or (T, N, d), "
+ f"got {tuple(encodings.shape)}"
+ )
+ raise ValueError(msg)
+ if stacked.shape[0] < 3:
+ return
+ from koopman_graph.analysis.resdmd import resdmd
+
+ report = resdmd(
+ stacked[:-1].detach(),
+ stacked[1:].detach(),
+ tolerance=self.tolerance,
+ )
+ residuals = report.residuals.real
+ self.record(
+ float(residuals.mean().item()),
+ residual_max=float(residuals.max().item()),
+ )
diff --git a/src/koopman_graph/training/device.py b/src/koopman_graph/training/device.py
index e98fd59..021253b 100644
--- a/src/koopman_graph/training/device.py
+++ b/src/koopman_graph/training/device.py
@@ -73,7 +73,7 @@ def sequence_to_device(
sequence: SnapshotSequence,
device: torch.device,
) -> SnapshotSequence:
- """Move a snapshot sequence and optional controls/timestamps to ``device``.
+ """Move a snapshot sequence and optional controls/timestamps/parameters.
Preserves homogeneous vs multiplex container type (no silent cast from
:class:`~koopman_graph.data.HeteroGraphSnapshotSequence` to
@@ -121,6 +121,11 @@ def sequence_to_device(
timestamps=(
None if sequence.timestamps is None else sequence.timestamps.to(device)
),
+ parameter_trajectory=(
+ None
+ if sequence.parameter_trajectory is None
+ else sequence.parameter_trajectory.to(device)
+ ),
observation_masks=(
None
if sequence.observation_masks is None
diff --git a/src/koopman_graph/training/epochs.py b/src/koopman_graph/training/epochs.py
index 27cdced..e64a3fe 100644
--- a/src/koopman_graph/training/epochs.py
+++ b/src/koopman_graph/training/epochs.py
@@ -18,6 +18,7 @@
resolve_rollout_start_indices,
)
from koopman_graph.protocols import TrainableKoopmanModel
+from koopman_graph.training.batched_objectives import compute_batched_training_loss
from koopman_graph.training.history import (
ExtraLosses,
LossWeights,
@@ -135,6 +136,7 @@ def train_one_epoch(
use_amp: bool = False,
amp_dtype: torch.dtype | None = None,
grad_scaler: torch.amp.GradScaler | None = None,
+ batch_graphs: bool = False,
) -> TrainingLossBreakdown:
"""Run one training epoch and return the averaged loss breakdown.
@@ -164,6 +166,12 @@ def train_one_epoch(
Autocast dtype (default ``float16`` on CUDA when AMP is active).
grad_scaler : torch.amp.GradScaler or None, optional
Reused scaler from :func:`~koopman_graph.training.loop.run_fit_loop`.
+ batch_graphs : bool, optional
+ When ``True``, collate homogeneous trajectories into one PyG
+ ``Batch`` and evaluate reconstruction (and forward, when weighted)
+ on the disconnected union. Default is ``False`` (per-sequence
+ Python loop). Mutually exclusive with windowed sampling at the
+ fit-loop layer.
Returns
-------
@@ -185,21 +193,24 @@ def train_one_epoch(
model.train()
optimizer.zero_grad(set_to_none=True)
- if amp_enabled and resolved_dtype is not None:
- with torch.amp.autocast("cuda", dtype=resolved_dtype):
- breakdowns = [
- compute_training_loss(
- model,
- sequence,
- loss_weights,
- extra_losses=extra_losses,
- rollout_horizon=rollout_horizon,
- rollout_start_indices=rollout_start_indices,
- )
- for sequence in trajectory_list
- ]
- breakdown = mean_training_loss_breakdown(breakdowns)
- else:
+
+ def _epoch_breakdown() -> TrainingLossBreakdown:
+ """Return the epoch loss, batched or per-sequence.
+
+ Returns
+ -------
+ TrainingLossBreakdown
+ Mean breakdown for this optimizer step.
+ """
+ if batch_graphs:
+ return compute_batched_training_loss(
+ model,
+ trajectory_list,
+ loss_weights,
+ extra_losses=extra_losses,
+ rollout_horizon=rollout_horizon,
+ rollout_start_indices=rollout_start_indices,
+ )
breakdowns = [
compute_training_loss(
model,
@@ -211,7 +222,13 @@ def train_one_epoch(
)
for sequence in trajectory_list
]
- breakdown = mean_training_loss_breakdown(breakdowns)
+ return mean_training_loss_breakdown(breakdowns)
+
+ if amp_enabled and resolved_dtype is not None:
+ with torch.amp.autocast("cuda", dtype=resolved_dtype):
+ breakdown = _epoch_breakdown()
+ else:
+ breakdown = _epoch_breakdown()
_backward_optimizer_step(
model,
optimizer,
@@ -364,6 +381,8 @@ def train_windowed_epoch(
"sparsity",
"worst_case",
"vamp2",
+ "topology",
+ "presence",
"total",
)
}
@@ -388,6 +407,7 @@ def eval_one_epoch(
extra_losses: ExtraLosses | None = None,
rollout_horizon: int | None = None,
rollout_start_indices: Sequence[int] | None = None,
+ batch_graphs: bool = False,
) -> TrainingLossBreakdown:
"""Compute validation loss for one epoch without parameter updates.
@@ -405,6 +425,9 @@ def eval_one_epoch(
Rollout origin indices for this epoch.
extra_losses : ExtraLosses | None
See the function signature / summary for ``extra_losses``.
+ batch_graphs : bool, optional
+ When ``True``, use the collated multi-graph loss path. Default is
+ ``False``.
Returns
-------
@@ -420,6 +443,15 @@ def eval_one_epoch(
model.eval()
try:
with torch.no_grad():
+ if batch_graphs:
+ return compute_batched_training_loss(
+ model,
+ trajectory_list,
+ loss_weights,
+ extra_losses=extra_losses,
+ rollout_horizon=rollout_horizon,
+ rollout_start_indices=rollout_start_indices,
+ )
breakdowns = [
compute_training_loss(
model,
diff --git a/src/koopman_graph/training/history.py b/src/koopman_graph/training/history.py
index 13299ae..1e175bb 100644
--- a/src/koopman_graph/training/history.py
+++ b/src/koopman_graph/training/history.py
@@ -129,6 +129,10 @@ class TrainingLossBreakdown:
Worst-case (max over nodes) reconstruction loss.
vamp2 : Tensor
Topology-blind VAMP-2 precursor loss (``-vamp2_score``).
+ topology : Tensor
+ Unweighted structural BCE on predicted next-step edges.
+ presence : Tensor
+ Unweighted presence BCE (zero when masks are absent).
total : Tensor
Weighted sum of all active loss terms.
"""
@@ -144,6 +148,8 @@ class TrainingLossBreakdown:
sparsity: Tensor = field(default_factory=lambda: torch.tensor(0.0))
worst_case: Tensor = field(default_factory=lambda: torch.tensor(0.0))
vamp2: Tensor = field(default_factory=lambda: torch.tensor(0.0))
+ topology: Tensor = field(default_factory=lambda: torch.tensor(0.0))
+ presence: Tensor = field(default_factory=lambda: torch.tensor(0.0))
@classmethod
def zeros(cls, device: torch.device) -> TrainingLossBreakdown:
@@ -172,6 +178,8 @@ def zeros(cls, device: torch.device) -> TrainingLossBreakdown:
sparsity=zero,
worst_case=zero,
vamp2=zero,
+ topology=zero,
+ presence=zero,
)
def to_floats(self) -> dict[str, float]:
@@ -182,7 +190,8 @@ def to_floats(self) -> dict[str, float]:
dict of str to float
Mapping with keys ``reconstruction``, ``forward``, ``backward``,
``rollout``, ``eigenvalue``, ``lie``, ``pde``, ``sparsity``,
- ``worst_case``, ``vamp2``, and ``total``.
+ ``worst_case``, ``vamp2``, ``topology``, ``presence``, and
+ ``total``.
"""
return {
"reconstruction": float(self.reconstruction.detach().cpu()),
@@ -195,6 +204,8 @@ def to_floats(self) -> dict[str, float]:
"sparsity": float(self.sparsity.detach().cpu()),
"worst_case": float(self.worst_case.detach().cpu()),
"vamp2": float(self.vamp2.detach().cpu()),
+ "topology": float(self.topology.detach().cpu()),
+ "presence": float(self.presence.detach().cpu()),
"total": float(self.total.detach().cpu()),
}
@@ -234,6 +245,8 @@ def mean_training_loss_breakdown(
sparsity=sum(b.sparsity for b in breakdowns) / count,
worst_case=sum(b.worst_case for b in breakdowns) / count,
vamp2=sum(b.vamp2 for b in breakdowns) / count,
+ topology=sum(b.topology for b in breakdowns) / count,
+ presence=sum(b.presence for b in breakdowns) / count,
total=sum(b.total for b in breakdowns) / count,
)
@@ -272,6 +285,10 @@ class FitHistory:
Per-epoch unweighted worst-case reconstruction loss.
vamp2_loss : tuple of float
Per-epoch unweighted VAMP-2 precursor loss (``-vamp2_score``).
+ topology_loss : tuple of float
+ Per-epoch unweighted structural topology BCE.
+ presence_loss : tuple of float
+ Per-epoch unweighted presence BCE.
val_loss : tuple of float or None
Per-epoch validation loss when a validation sequence is provided.
val_reconstruction_loss : tuple of float or None
@@ -294,6 +311,10 @@ class FitHistory:
Per-epoch unweighted validation worst-case reconstruction loss.
val_vamp2_loss : tuple of float or None
Per-epoch unweighted validation VAMP-2 precursor loss.
+ val_topology_loss : tuple of float or None
+ Per-epoch unweighted validation structural topology BCE.
+ val_presence_loss : tuple of float or None
+ Per-epoch unweighted validation presence BCE.
stopped_early : bool
Whether training stopped before the requested epoch count.
best_epoch : int or None
@@ -316,6 +337,8 @@ class FitHistory:
sparsity_loss: tuple[float, ...] = ()
worst_case_loss: tuple[float, ...] = ()
vamp2_loss: tuple[float, ...] = ()
+ topology_loss: tuple[float, ...] = ()
+ presence_loss: tuple[float, ...] = ()
val_loss: tuple[float, ...] | None = None
val_reconstruction_loss: tuple[float, ...] | None = None
val_forward_loss: tuple[float, ...] | None = None
@@ -327,6 +350,8 @@ class FitHistory:
val_sparsity_loss: tuple[float, ...] | None = None
val_worst_case_loss: tuple[float, ...] | None = None
val_vamp2_loss: tuple[float, ...] | None = None
+ val_topology_loss: tuple[float, ...] | None = None
+ val_presence_loss: tuple[float, ...] | None = None
stopped_early: bool = False
best_epoch: int | None = None
best_loss: float | None = None
diff --git a/src/koopman_graph/training/loop.py b/src/koopman_graph/training/loop.py
index d52d036..169f073 100644
--- a/src/koopman_graph/training/loop.py
+++ b/src/koopman_graph/training/loop.py
@@ -28,6 +28,7 @@
from koopman_graph.graph_utils import snapshot_hyperedge_index
from koopman_graph.nn.heterogeneous import resolve_multiplex_relation_inputs
from koopman_graph.protocols import TrainableKoopmanModel
+from koopman_graph.training.batched_objectives import validate_graph_batching_request
from koopman_graph.training.callbacks import FitCallback
from koopman_graph.training.device import resolve_device, sequence_to_device
from koopman_graph.training.epochs import (
@@ -60,6 +61,8 @@ def _build_fit_history(
sparsity_losses: list[float],
worst_case_losses: list[float],
vamp2_losses: list[float],
+ topology_losses: list[float],
+ presence_losses: list[float],
val_losses: list[float] | None,
val_reconstruction_losses: list[float] | None,
val_forward_losses: list[float] | None,
@@ -71,6 +74,8 @@ def _build_fit_history(
val_sparsity_losses: list[float] | None,
val_worst_case_losses: list[float] | None,
val_vamp2_losses: list[float] | None,
+ val_topology_losses: list[float] | None,
+ val_presence_losses: list[float] | None,
stopped_early: bool,
best_epoch: int | None,
best_loss: float | None,
@@ -81,12 +86,14 @@ def _build_fit_history(
----------
losses, reconstruction_losses, forward_losses, backward_losses,
rollout_losses, eigenvalue_losses, lie_losses, pde_losses,
- sparsity_losses, worst_case_losses, vamp2_losses : list of float
+ sparsity_losses, worst_case_losses, vamp2_losses, topology_losses,
+ presence_losses : list of float
Per-epoch training loss series (same length as ``losses``).
val_losses, val_reconstruction_losses, val_forward_losses,
val_backward_losses, val_rollout_losses, val_eigenvalue_losses,
val_lie_losses, val_pde_losses, val_sparsity_losses,
- val_worst_case_losses, val_vamp2_losses : list of float or None
+ val_worst_case_losses, val_vamp2_losses, val_topology_losses,
+ val_presence_losses : list of float or None
Optional per-epoch validation series; ``None`` when validation
was not run.
stopped_early : bool
@@ -114,6 +121,8 @@ def _build_fit_history(
sparsity_loss=tuple(sparsity_losses),
worst_case_loss=tuple(worst_case_losses),
vamp2_loss=tuple(vamp2_losses),
+ topology_loss=tuple(topology_losses),
+ presence_loss=tuple(presence_losses),
val_loss=None if val_losses is None else tuple(val_losses),
val_reconstruction_loss=(
None
@@ -141,12 +150,270 @@ def _build_fit_history(
None if val_worst_case_losses is None else tuple(val_worst_case_losses)
),
val_vamp2_loss=(None if val_vamp2_losses is None else tuple(val_vamp2_losses)),
+ val_topology_loss=(
+ None if val_topology_losses is None else tuple(val_topology_losses)
+ ),
+ val_presence_loss=(
+ None if val_presence_losses is None else tuple(val_presence_losses)
+ ),
stopped_early=stopped_early,
best_epoch=best_epoch,
best_loss=best_loss,
)
+def _validate_identification_fit_request(
+ model: TrainableKoopmanModel,
+ train_sequences: Sequence[SnapshotSequence],
+ identification: object,
+ *,
+ window_sampler: object | None,
+ batch_graphs: bool,
+) -> None:
+ """Reject identification layouts that this increment does not support.
+
+ Lazy-imports identification types so ``import koopman_graph.training``
+ does not load that package.
+
+ Parameters
+ ----------
+ model : TrainableKoopmanModel
+ Fit target.
+ train_sequences : sequence of SnapshotSequence
+ Training trajectories.
+ identification : object
+ Expected :class:`~koopman_graph.identification.IdentificationConfig`.
+ window_sampler : object or None
+ Resolved window sampler; must be ``None``.
+ batch_graphs : bool
+ Multi-graph collate flag; must be ``False``.
+
+ Raises
+ ------
+ TypeError
+ If ``identification`` is not an ``IdentificationConfig``.
+ ValueError
+ If the operator, delays, controls, topology, or sampling path is
+ outside the per-node dense discrete MVP.
+ """
+ from koopman_graph.identification import IdentificationConfig
+ from koopman_graph.operators import KoopmanOperator
+
+ if not isinstance(identification, IdentificationConfig):
+ msg = (
+ "identification must be IdentificationConfig or None, got "
+ f"{type(identification).__name__}"
+ )
+ raise TypeError(msg)
+ if window_sampler is not None:
+ msg = (
+ "fit(..., identification=...) does not support windowed sampling; "
+ "use full-sequence fit (window_length=None, sampler=None)"
+ )
+ raise ValueError(msg)
+ if batch_graphs:
+ msg = (
+ "fit(..., identification=...) is mutually exclusive with batch_graphs=True"
+ )
+ raise ValueError(msg)
+ if any(
+ isinstance(sequence, HeteroGraphSnapshotSequence)
+ for sequence in train_sequences
+ ):
+ msg = "identification does not support HeteroGraphSnapshotSequence"
+ raise ValueError(msg)
+ if any(
+ getattr(sequence, "allow_node_churn", False) for sequence in train_sequences
+ ):
+ msg = "identification does not support sequences with allow_node_churn=True"
+ raise ValueError(msg)
+ kind = getattr(model, "koopman_kind", "pernode")
+ if kind != "pernode":
+ msg = (
+ "identification currently supports discrete per-node "
+ f"KoopmanOperator only, got koopman_kind={kind!r}"
+ )
+ raise ValueError(msg)
+ if getattr(model, "dynamics_mode", "discrete") != "discrete":
+ msg = "identification requires dynamics_mode='discrete'"
+ raise ValueError(msg)
+ if getattr(model, "n_delays", 1) != 1:
+ msg = (
+ "identification does not support delay embeddings "
+ f"(n_delays={getattr(model, 'n_delays', None)})"
+ )
+ raise ValueError(msg)
+ if int(getattr(model, "control_dim", 0)) != 0:
+ msg = (
+ "identification does not support controlled models "
+ f"(control_dim={getattr(model, 'control_dim', None)})"
+ )
+ raise ValueError(msg)
+ if getattr(model, "adaptive_topology", None) is not None:
+ msg = "identification does not support learned pairwise topology"
+ raise ValueError(msg)
+ koopman = model.koopman
+ if not isinstance(koopman, KoopmanOperator):
+ msg = (
+ "identification currently supports discrete per-node "
+ f"KoopmanOperator only, got {type(koopman).__name__}"
+ )
+ raise ValueError(msg)
+
+
+def _encode_sequence_latents(
+ model: TrainableKoopmanModel,
+ sequence: SnapshotSequence,
+) -> Tensor:
+ """Stack per-timestep encodings for one trajectory.
+
+ Parameters
+ ----------
+ model : TrainableKoopmanModel
+ Model exposing ``encode_at`` or ``encode``.
+ sequence : SnapshotSequence
+ Training trajectory.
+
+ Returns
+ -------
+ Tensor
+ Latents with shape ``(T, N, d)``.
+ """
+ encode_at = getattr(model, "encode_at", None)
+ stacked = []
+ for index in range(sequence.num_timesteps):
+ if encode_at is not None:
+ stacked.append(encode_at(sequence, index))
+ else:
+ stacked.append(model.encode(sequence[index]))
+ return torch.stack(stacked, dim=0)
+
+
+def _observe_callback_encodings(
+ callbacks: Sequence[object],
+ model: TrainableKoopmanModel,
+ sequence: SnapshotSequence,
+) -> None:
+ """Push a frozen latent stack to callbacks that observe encodings.
+
+ Encodes the first training sequence under ``eval`` / ``no_grad``.
+ Layout matches ``evaluate(..., include_resdmd=True)``: time-major
+ ``(T, N, d)``. No-ops when no callback exposes
+ ``observe_encodings``.
+
+ Parameters
+ ----------
+ callbacks : sequence of object
+ Active fit callbacks.
+ model : TrainableKoopmanModel
+ Encoder source.
+ sequence : SnapshotSequence
+ Trajectory to encode (typically ``train_sequences[0]``).
+ """
+ observers = [
+ callback
+ for callback in callbacks
+ if callable(getattr(callback, "observe_encodings", None))
+ ]
+ if not observers:
+ return
+ was_training = model.training
+ model.eval()
+ try:
+ with torch.no_grad():
+ encodings = _encode_sequence_latents(model, sequence)
+ finally:
+ model.train(was_training)
+ for callback in observers:
+ callback.observe_encodings(encodings)
+
+
+def _collect_identification_pairs(
+ model: TrainableKoopmanModel,
+ train_sequences: Sequence[SnapshotSequence],
+):
+ """Encode frozen latent pairs for closed-form identification.
+
+ Parameters
+ ----------
+ model : TrainableKoopmanModel
+ Encoder source.
+ train_sequences : sequence of SnapshotSequence
+ Trajectories to encode.
+
+ Returns
+ -------
+ tuple
+ ``(joint_pairs, per_sequence_pairs)`` as
+ :class:`~koopman_graph.identification.LatentPairs` objects.
+
+ Raises
+ ------
+ ValueError
+ If a trajectory has fewer than two snapshots.
+ """
+ from koopman_graph.identification import LatentPairs
+
+ was_training = model.training
+ model.eval()
+ per_sequence: list[LatentPairs] = []
+ try:
+ with torch.no_grad():
+ for sequence in train_sequences:
+ if sequence.num_timesteps < 2:
+ msg = "identification requires at least two snapshots"
+ raise ValueError(msg)
+ latents = _encode_sequence_latents(model, sequence)
+ per_sequence.append(LatentPairs(z_t=latents[:-1], z_next=latents[1:]))
+ finally:
+ model.train(was_training)
+ width = per_sequence[0].z_t.shape[-1]
+ z_t = torch.cat([pairs.z_t.reshape(-1, width) for pairs in per_sequence], dim=0)
+ z_next = torch.cat(
+ [pairs.z_next.reshape(-1, width) for pairs in per_sequence],
+ dim=0,
+ )
+ return LatentPairs(z_t=z_t, z_next=z_next), tuple(per_sequence)
+
+
+def _run_identification_step(
+ model: TrainableKoopmanModel,
+ train_sequences: Sequence[SnapshotSequence],
+ identification: object,
+ *,
+ attach_report: bool,
+) -> None:
+ """Closed-form ``K`` update from the current encoder.
+
+ Parameters
+ ----------
+ model : TrainableKoopmanModel
+ Fit target; ``koopman`` is overwritten in place.
+ train_sequences : sequence of SnapshotSequence
+ Trajectories encoded under ``eval`` / ``no_grad``.
+ identification : object
+ :class:`~koopman_graph.identification.IdentificationConfig`.
+ attach_report : bool
+ When ``True``, store :class:`~koopman_graph.identification.IdentificationReport`
+ on ``model.identification_report``.
+ """
+ from koopman_graph.identification import (
+ apply_operator_snapshot,
+ build_identification_report,
+ identify_operator,
+ )
+
+ joint_pairs, per_sequence = _collect_identification_pairs(model, train_sequences)
+ snapshot = identify_operator(joint_pairs, identification)
+ apply_operator_snapshot(model.koopman, snapshot)
+ if attach_report:
+ model.identification_report = build_identification_report( # type: ignore[attr-defined]
+ per_sequence,
+ snapshot,
+ gate_resdmd=bool(getattr(identification, "gate_resdmd", False)),
+ )
+
+
__all__ = [
"bind_pending_orbit_ties",
"eval_one_epoch",
@@ -381,6 +648,8 @@ def run_fit_loop(
restore_best_weights: bool = False,
checkpoint_path: str | Path | None = None,
callbacks: Sequence[FitCallback] | None = None,
+ batch_graphs: bool = False,
+ identification: object | None = None,
**optimizer_kwargs: Any,
) -> FitHistory:
"""Run the multi-epoch training loop for a trainable Koopman model.
@@ -398,12 +667,16 @@ def run_fit_loop(
* ``on_fit_start`` once after device / optimizer setup and before epoch 0
* ``on_epoch_end`` after each epoch's train (and optional val) losses are
- recorded and best-epoch tracking is updated, before early-stop ``break``
+ recorded and best-epoch tracking is updated, before early-stop ``break``.
+ Callbacks that expose ``observe_encodings`` receive a frozen time-major
+ latent stack from the first training sequence first (identity-dictionary
+ ResDMD layout).
* ``on_fit_end`` once with the final :class:`FitHistory`, after optional
- best-weight restore / checkpoint write
+ best-weight restore / checkpoint write. ``ResDMDFitCallback(mode="gate")``
+ may raise here without mutating parameters.
- Callbacks are observe-only: do not mutate model parameters or optimizer
- state. Default ``None`` preserves prior behavior.
+ Callbacks must not mutate model parameters or optimizer state. Default
+ ``None`` preserves prior behavior.
Parameters
----------
@@ -476,6 +749,20 @@ def run_fit_loop(
Write a checkpoint at the lowest-loss epoch when set.
callbacks : sequence of FitCallback or None, optional
Observe-only fit hooks. Default ``None`` skips all hook calls.
+ batch_graphs : bool, optional
+ When ``True``, collate homogeneous trajectories into one PyG
+ ``Batch`` for the full-sequence epoch path. Default is ``False``
+ (existing per-sequence ``MultiTrajectory`` loop). Mutually
+ exclusive with ``window_length``, ``sampler``, and DDP.
+ identification : IdentificationConfig or None, optional
+ Opt-in closed-form operator identification. ``None`` (default)
+ keeps the Adam path and does not import
+ :mod:`koopman_graph.identification`. When set, each epoch freezes
+ the encoder, fits dense per-node ``K`` (ridge / TLS / constrained
+ LS), then takes encoder/decoder Adam steps. Graph, hetero,
+ continuous, controlled, delay, windowed, and ``batch_graphs``
+ layouts raise. The report is stored on
+ ``model.identification_report``.
**optimizer_kwargs
Extra keyword arguments for the optimizer constructor.
@@ -487,10 +774,12 @@ def run_fit_loop(
Raises
------
ValueError
- If ``early_stopping_monitor="val"`` without ``val_sequences``, if both
- ``sampler`` and ``window_length`` are provided, or if a
+ If ``early_stopping_monitor="val"`` without ``val_sequences``, if both
+ ``sampler`` and ``window_length`` are provided, if ``batch_graphs``
+ is combined with windowed sampling, if a
:class:`~koopman_graph.data.NeighborWindowSampler` is used with
- hetero training sequences.
+ hetero training sequences, or if ``identification`` is set on an
+ unsupported operator / sampling layout.
"""
if early_stopping_monitor == "val" and val_sequences is None:
msg = 'early_stopping_monitor="val" requires val_sequences'
@@ -498,6 +787,9 @@ def run_fit_loop(
if sampler is not None and window_length is not None:
msg = "pass sampler or window_length, not both"
raise ValueError(msg)
+ if batch_graphs and (sampler is not None or window_length is not None):
+ msg = "batch_graphs=True is mutually exclusive with window_length and sampler"
+ raise ValueError(msg)
has_hetero_train = any(
isinstance(sequence, HeteroGraphSnapshotSequence)
for sequence in train_sequences
@@ -524,23 +816,14 @@ def run_fit_loop(
sequence_to_device(sequence, train_device) for sequence in val_sequences
]
+ if batch_graphs:
+ validate_graph_batching_request(model, train_sequences)
+ if val_sequences is not None:
+ validate_graph_batching_request(model, val_sequences)
+
# Allocate orbit-tied K_self before Adam sees module.parameters().
bind_pending_orbit_ties(module, train_sequences)
- optim = optimizer(module.parameters(), lr=lr, **optimizer_kwargs)
- scheduler = resolve_lr_scheduler(lr_scheduler, optim)
- amp_enabled, resolved_amp_dtype, grad_scaler = prepare_training_amp(
- use_amp,
- train_device,
- amp_dtype,
- )
- # Epoch helpers also accept use_amp; pass the resolved scaler so state
- # persists across epochs when AMP is active.
- amp_kwargs = {
- "use_amp": amp_enabled,
- "amp_dtype": resolved_amp_dtype,
- "grad_scaler": grad_scaler,
- }
if sampler is not None:
sampler.sequences = [
sequence_to_device(sequence, train_device) for sequence in sampler.sequences
@@ -554,6 +837,46 @@ def run_fit_loop(
sampler=sampler,
distributed=False,
)
+ if identification is not None:
+ _validate_identification_fit_request(
+ model,
+ train_sequences,
+ identification,
+ window_sampler=window_sampler,
+ batch_graphs=batch_graphs,
+ )
+ koopman_ids = {id(parameter) for parameter in model.koopman.parameters()}
+ trainable = [
+ parameter
+ for parameter in module.parameters()
+ if parameter.requires_grad and id(parameter) not in koopman_ids
+ ]
+ if trainable:
+ optim: Optimizer | None = optimizer(trainable, lr=lr, **optimizer_kwargs)
+ else:
+ if lr_scheduler is not None:
+ msg = (
+ "identification fit with no trainable encoder/decoder "
+ "parameters cannot use lr_scheduler"
+ )
+ raise ValueError(msg)
+ optim = None
+ else:
+ model.identification_report = None # type: ignore[attr-defined]
+ optim = optimizer(module.parameters(), lr=lr, **optimizer_kwargs)
+ scheduler = None if optim is None else resolve_lr_scheduler(lr_scheduler, optim)
+ amp_enabled, resolved_amp_dtype, grad_scaler = prepare_training_amp(
+ use_amp,
+ train_device,
+ amp_dtype,
+ )
+ # Epoch helpers also accept use_amp; pass the resolved scaler so state
+ # persists across epochs when AMP is active.
+ amp_kwargs = {
+ "use_amp": amp_enabled,
+ "amp_dtype": resolved_amp_dtype,
+ "grad_scaler": grad_scaler,
+ }
losses: list[float] = []
reconstruction_losses: list[float] = []
forward_losses: list[float] = []
@@ -565,6 +888,8 @@ def run_fit_loop(
sparsity_losses: list[float] = []
worst_case_losses: list[float] = []
vamp2_losses: list[float] = []
+ topology_losses: list[float] = []
+ presence_losses: list[float] = []
val_losses: list[float] | None = [] if val_sequences is not None else None
val_reconstruction_losses: list[float] | None = (
[] if val_sequences is not None else None
@@ -582,6 +907,8 @@ def run_fit_loop(
[] if val_sequences is not None else None
)
val_vamp2_losses: list[float] | None = [] if val_sequences is not None else None
+ val_topology_losses: list[float] | None = [] if val_sequences is not None else None
+ val_presence_losses: list[float] | None = [] if val_sequences is not None else None
best_loss_for_stop = float("inf")
best_loss: float | None = None
best_epoch: int | None = None
@@ -608,11 +935,20 @@ def run_fit_loop(
"restore_best_weights": restore_best_weights,
"checkpoint_path": checkpoint_path,
"has_val_sequences": val_sequences is not None,
+ "batch_graphs": batch_graphs,
+ "identification": identification is not None,
}
for callback in active_callbacks:
callback.on_fit_start(model=model, fit_kwargs=fit_kwargs)
for epoch in range(epochs):
+ if identification is not None:
+ _run_identification_step(
+ model,
+ train_sequences,
+ identification,
+ attach_report=False,
+ )
epoch_weights = resolve_loss_weights_for_epoch(
epoch,
loss_weights=loss_weights,
@@ -633,18 +969,33 @@ def run_fit_loop(
rollout_start_seed=rollout_start_seed,
epoch=epoch,
)
- breakdown = train_one_epoch(
- model,
- train_sequences,
- optim,
- epoch_weights,
- extra_losses=extra_losses,
- max_grad_norm=max_grad_norm,
- rollout_horizon=rollout_horizon,
- rollout_start_indices=epoch_rollout_starts,
- **amp_kwargs,
- )
+ if optim is None:
+ breakdown = eval_one_epoch(
+ model,
+ train_sequences,
+ epoch_weights,
+ extra_losses=extra_losses,
+ rollout_horizon=rollout_horizon,
+ rollout_start_indices=epoch_rollout_starts,
+ batch_graphs=batch_graphs,
+ )
+ else:
+ breakdown = train_one_epoch(
+ model,
+ train_sequences,
+ optim,
+ epoch_weights,
+ extra_losses=extra_losses,
+ max_grad_norm=max_grad_norm,
+ rollout_horizon=rollout_horizon,
+ rollout_start_indices=epoch_rollout_starts,
+ batch_graphs=batch_graphs,
+ **amp_kwargs,
+ )
else:
+ if optim is None:
+ msg = "windowed fit requires trainable parameters"
+ raise ValueError(msg)
breakdown = train_windowed_epoch(
model,
window_sampler,
@@ -674,6 +1025,8 @@ def run_fit_loop(
sparsity_losses.append(term_values["sparsity"])
worst_case_losses.append(term_values["worst_case"])
vamp2_losses.append(term_values["vamp2"])
+ topology_losses.append(term_values["topology"])
+ presence_losses.append(term_values["presence"])
monitored_loss = term_values["total"]
epoch_val_breakdown = None
@@ -685,6 +1038,7 @@ def run_fit_loop(
extra_losses=extra_losses,
rollout_horizon=rollout_horizon,
rollout_start_indices=epoch_rollout_starts,
+ batch_graphs=batch_graphs,
)
val_terms = epoch_val_breakdown.to_floats()
assert val_losses is not None
@@ -698,6 +1052,8 @@ def run_fit_loop(
assert val_sparsity_losses is not None
assert val_worst_case_losses is not None
assert val_vamp2_losses is not None
+ assert val_topology_losses is not None
+ assert val_presence_losses is not None
val_losses.append(val_terms["total"])
val_reconstruction_losses.append(val_terms["reconstruction"])
val_forward_losses.append(val_terms["forward"])
@@ -709,6 +1065,8 @@ def run_fit_loop(
val_sparsity_losses.append(val_terms["sparsity"])
val_worst_case_losses.append(val_terms["worst_case"])
val_vamp2_losses.append(val_terms["vamp2"])
+ val_topology_losses.append(val_terms["topology"])
+ val_presence_losses.append(val_terms["presence"])
if early_stopping_monitor == "val":
monitored_loss = val_terms["total"]
@@ -730,6 +1088,8 @@ def run_fit_loop(
sparsity_losses=sparsity_losses,
worst_case_losses=worst_case_losses,
vamp2_losses=vamp2_losses,
+ topology_losses=topology_losses,
+ presence_losses=presence_losses,
val_losses=val_losses,
val_reconstruction_losses=val_reconstruction_losses,
val_forward_losses=val_forward_losses,
@@ -741,10 +1101,13 @@ def run_fit_loop(
val_sparsity_losses=val_sparsity_losses,
val_worst_case_losses=val_worst_case_losses,
val_vamp2_losses=val_vamp2_losses,
+ val_topology_losses=val_topology_losses,
+ val_presence_losses=val_presence_losses,
stopped_early=False,
best_epoch=best_epoch,
best_loss=best_loss,
)
+ _observe_callback_encodings(active_callbacks, model, train_sequences[0])
for callback in active_callbacks:
callback.on_epoch_end(
epoch=epoch,
@@ -780,6 +1143,14 @@ def run_fit_loop(
if not restore_best_weights and last_state_dict is not None:
module.load_state_dict(last_state_dict)
+ if identification is not None:
+ _run_identification_step(
+ model,
+ train_sequences,
+ identification,
+ attach_report=True,
+ )
+
history = _build_fit_history(
losses=losses,
reconstruction_losses=reconstruction_losses,
@@ -792,6 +1163,8 @@ def run_fit_loop(
sparsity_losses=sparsity_losses,
worst_case_losses=worst_case_losses,
vamp2_losses=vamp2_losses,
+ topology_losses=topology_losses,
+ presence_losses=presence_losses,
val_losses=val_losses,
val_reconstruction_losses=val_reconstruction_losses,
val_forward_losses=val_forward_losses,
@@ -803,6 +1176,8 @@ def run_fit_loop(
val_sparsity_losses=val_sparsity_losses,
val_worst_case_losses=val_worst_case_losses,
val_vamp2_losses=val_vamp2_losses,
+ val_topology_losses=val_topology_losses,
+ val_presence_losses=val_presence_losses,
stopped_early=stopped_early,
best_epoch=best_epoch,
best_loss=best_loss,
diff --git a/src/koopman_graph/training/objectives.py b/src/koopman_graph/training/objectives.py
index ee80d57..0b19dfd 100644
--- a/src/koopman_graph/training/objectives.py
+++ b/src/koopman_graph/training/objectives.py
@@ -27,6 +27,13 @@
rollout_multi_start_loss,
rollout_sequence_loss,
)
+from koopman_graph.nn.predicted_topology import (
+ PredictedTopologyHead,
+ SparseCandidateTopologyHead,
+ build_supervision_index,
+ candidate_edge_labels,
+ dense_offdiag_index,
+)
from koopman_graph.operators import (
ContinuousGraphKoopmanOperator,
ContinuousHeteroGraphKoopmanOperator,
@@ -490,6 +497,110 @@ def _validate_hetero_fit_surface(
raise ValueError(msg)
+def compute_graph_dynamics_losses(
+ model: TrainableKoopmanModel,
+ sequence: SnapshotSequence,
+ *,
+ cache: SequenceLatentCache | None,
+ device: torch.device,
+) -> tuple[Tensor, Tensor]:
+ """Return unweighted topology and presence BCE terms.
+
+ Weights come from :class:`~koopman_graph.data.GraphDynamicsConfig`, not
+ :class:`~koopman_graph.training.LossWeights`. Presence is skipped when
+ the sequence has no presence masks.
+
+ Parameters
+ ----------
+ model : TrainableKoopmanModel
+ Model exposing ``graph_dynamics``, ``predicted_topology``, and
+ optional ``presence_head``.
+ sequence : GraphSnapshotSequence or HeteroGraphSnapshotSequence
+ Training snapshots.
+ cache : SequenceLatentCache or None
+ Teacher-forced latents. Required when a topology or presence
+ head is active on a homogeneous sequence.
+ device : torch.device
+ Device for zero scalars when the terms are inactive.
+
+ Returns
+ -------
+ topology : Tensor
+ Mean structural BCE (unweighted).
+ presence : Tensor
+ Mean presence BCE (unweighted).
+ """
+ zero = torch.zeros((), device=device)
+ config = getattr(model, "graph_dynamics", None)
+ if config is None or not isinstance(sequence, GraphSnapshotSequence):
+ return zero, zero
+ head = getattr(model, "predicted_topology", None)
+ presence_head = getattr(model, "presence_head", None)
+ topology_weight = float(config.topology_loss_weight)
+ presence_weight = float(config.presence_loss_weight)
+ need_topology = head is not None and topology_weight > 0.0
+ need_presence = (
+ presence_head is not None
+ and presence_weight > 0.0
+ and sequence.has_presence_masks
+ )
+ if not need_topology and not need_presence:
+ return zero, zero
+ if cache is None:
+ msg = "graph-state losses require a SequenceLatentCache of teacher latents"
+ raise ValueError(msg)
+ topology_terms: list[Tensor] = []
+ presence_terms: list[Tensor] = []
+ num_pairs = sequence.num_timesteps - 1
+ for timestep in range(num_pairs):
+ z_t = cache.z[timestep]
+ snapshot_t = sequence[timestep]
+ snapshot_t1 = sequence[timestep + 1]
+ if need_topology:
+ if isinstance(head, SparseCandidateTopologyHead):
+ candidates = build_supervision_index(
+ int(z_t.shape[0]),
+ head.candidate_k,
+ snapshot_t.edge_index,
+ snapshot_t1.edge_index,
+ device=z_t.device,
+ )
+ logits = head.pair_logits(z_t, candidates)
+ labels = candidate_edge_labels(
+ candidates,
+ snapshot_t1.edge_index,
+ int(z_t.shape[0]),
+ )
+ topology_terms.append(
+ torch.nn.functional.binary_cross_entropy_with_logits(logits, labels)
+ )
+ elif isinstance(head, PredictedTopologyHead):
+ logits = head.pairwise_logits(z_t)
+ index = dense_offdiag_index(int(z_t.shape[0]), device=z_t.device)
+ labels = candidate_edge_labels(
+ index,
+ snapshot_t1.edge_index,
+ int(z_t.shape[0]),
+ )
+ topology_terms.append(
+ torch.nn.functional.binary_cross_entropy_with_logits(
+ logits[index[0], index[1]],
+ labels,
+ )
+ )
+ if need_presence:
+ target = sequence.presence_mask_at(timestep + 1).to(
+ dtype=z_t.dtype, device=z_t.device
+ )
+ logits = presence_head(z_t)
+ presence_terms.append(
+ torch.nn.functional.binary_cross_entropy_with_logits(logits, target)
+ )
+ topology = torch.stack(topology_terms).mean() if topology_terms else zero
+ presence = torch.stack(presence_terms).mean() if presence_terms else zero
+ return topology, presence
+
+
def compute_rollout_loss(
model: TrainableKoopmanModel,
sequence: SnapshotSequence,
@@ -600,6 +711,7 @@ def compute_training_loss(
or loss_weights.backward != 0.0
or loss_weights.rollout != 0.0
or loss_weights.vamp2 != 0.0
+ or getattr(model, "graph_dynamics", None) is not None
)
cache = encode_sequence_latents(model, sequence) if needs_latent_cache else None
predictions = (
@@ -682,6 +794,16 @@ def compute_training_loss(
else:
rollout = torch.zeros((), device=device)
+ topology, presence = compute_graph_dynamics_losses(
+ model,
+ sequence,
+ cache=cache,
+ device=device,
+ )
+ config = getattr(model, "graph_dynamics", None)
+ topology_weight = 0.0 if config is None else float(config.topology_loss_weight)
+ presence_weight = 0.0 if config is None else float(config.presence_loss_weight)
+
total = (
loss_weights.reconstruction * reconstruction
+ loss_weights.forward * forward
@@ -693,6 +815,8 @@ def compute_training_loss(
+ loss_weights.sparsity * sparsity
+ loss_weights.worst_case * worst_case
+ loss_weights.vamp2 * vamp2
+ + topology_weight * topology
+ + presence_weight * presence
)
return TrainingLossBreakdown(
reconstruction=reconstruction,
@@ -705,6 +829,8 @@ def compute_training_loss(
sparsity=sparsity,
worst_case=worst_case,
vamp2=vamp2,
+ topology=topology,
+ presence=presence,
total=total,
)
@@ -713,6 +839,7 @@ def compute_training_loss(
"compute_backward_consistency_sequence_loss",
"compute_eigenvalue_regularization_loss",
"compute_forward_consistency_sequence_loss",
+ "compute_graph_dynamics_losses",
"compute_rollout_loss",
"compute_sequence_loss",
"compute_training_loss",
diff --git a/src/koopman_graph/training/pair_objectives.py b/src/koopman_graph/training/pair_objectives.py
index 5655114..9d1fd3e 100644
--- a/src/koopman_graph/training/pair_objectives.py
+++ b/src/koopman_graph/training/pair_objectives.py
@@ -9,6 +9,7 @@
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
+from inspect import Parameter, signature
from typing import TYPE_CHECKING
import torch
@@ -39,6 +40,10 @@
resolve_multiplex_relation_inputs,
resolve_typed_relation_inputs,
)
+from koopman_graph.nn.predicted_topology import (
+ decode_weighted_topology,
+ recursive_training_enabled,
+)
from koopman_graph.operators import (
GlobalLocalKoopmanOperator,
GraphKoopmanOperator,
@@ -648,6 +653,27 @@ def pair_control(sequence: SnapshotSequence, timestep: int) -> Tensor | None:
return control_at(timestep)
+def pair_parameters(sequence: SnapshotSequence, timestep: int) -> Tensor | None:
+ """Return the regime coordinate for transition ``timestep -> timestep + 1``.
+
+ Parameters
+ ----------
+ sequence : GraphSnapshotSequence or HeteroGraphSnapshotSequence
+ Snapshot sequence that may carry ``parameter_trajectory``.
+ timestep : int
+ Index of the source snapshot in the transition pair.
+
+ Returns
+ -------
+ Tensor or None
+ :math:`\\mu` with shape ``(d_mu,)`` when present, otherwise ``None``.
+ """
+ trajectory = getattr(sequence, "parameter_trajectory", None)
+ if trajectory is None:
+ return None
+ return trajectory[timestep]
+
+
def mean_pair_sequence_loss(
model: TrainableKoopmanModel,
sequence: SnapshotSequence,
@@ -888,6 +914,43 @@ def _backward_consistency_pair(
)
+def _homogeneous_operator_topology(
+ model: TrainableKoopmanModel,
+ z: Tensor,
+ snapshot_t: Data,
+ snapshot_t1: Data,
+) -> tuple[Tensor, Tensor | None]:
+ """Return operator topology for a homogeneous one-step pair.
+
+ Recursive graph-state uses predicted :math:`\\hat A_{t+1}=g_\\phi(z_t)`
+ with sigmoid weights. Otherwise the teacher target snapshot topology
+ is used.
+
+ Parameters
+ ----------
+ model : TrainableKoopmanModel
+ Model exposing ``predicted_topology`` when recursive.
+ z : Tensor
+ Encoded latents at time ``t``.
+ snapshot_t : Data
+ Source snapshot (candidate seed).
+ snapshot_t1 : Data
+ Teacher target snapshot.
+
+ Returns
+ -------
+ tuple[Tensor, Tensor or None]
+ COO index and optional weights for the operator step.
+ """
+ if recursive_training_enabled(model):
+ head = getattr(model, "predicted_topology", None)
+ if head is None:
+ msg = "recursive graph-state training requires a predicted topology head"
+ raise ValueError(msg)
+ return decode_weighted_topology(head, z, snapshot_t.edge_index)
+ return snapshot_t1.edge_index, getattr(snapshot_t1, "edge_weight", None)
+
+
def one_step_prediction(
model: TrainableKoopmanModel,
sequence: SnapshotSequence,
@@ -973,6 +1036,7 @@ def one_step_prediction(
cache is not None
or uses_global_local
or (n_delays > 1 and callable(getattr(model, "encode_at", None)))
+ or recursive_training_enabled(model)
)
if use_encode_path:
snapshot_t = sequence[timestep]
@@ -987,19 +1051,23 @@ def one_step_prediction(
timestep,
default_time_step=default_delta_t,
)
+ op_index, op_weight = _homogeneous_operator_topology(
+ model, z, snapshot_t, snapshot_t1
+ )
z_next = propagate_latent(
model.koopman,
z,
control=pair_control(sequence, timestep),
delta_t=delta_t,
default_delta_t=default_delta_t,
- edge_index=snapshot_t1.edge_index,
- edge_weight=getattr(snapshot_t1, "edge_weight", None),
+ edge_index=op_index,
+ edge_weight=op_weight,
hyperedge_index=snapshot_hyperedge_index(snapshot_t1),
hyperedge_weight=snapshot_hyperedge_weight(snapshot_t1),
tail_index=snapshot_tail_index(snapshot_t1),
head_index=snapshot_head_index(snapshot_t1),
latent_window=_pair_latent_window(model, sequence, timestep, cache=cache),
+ parameters=pair_parameters(sequence, timestep),
)
if isinstance(model.decoder, HypergraphDecoder):
hyperedge_index = snapshot_hyperedge_index(snapshot_t)
@@ -1011,21 +1079,37 @@ def one_step_prediction(
hyperedge_index,
snapshot_hyperedge_weight(snapshot_t),
)
+ decode_index = (
+ op_index if recursive_training_enabled(model) else snapshot_t.edge_index
+ )
+ decode_weight = (
+ op_weight
+ if recursive_training_enabled(model)
+ else getattr(snapshot_t, "edge_weight", None)
+ )
return model.decoder(
z_next,
- snapshot_t.edge_index,
- getattr(snapshot_t, "edge_weight", None),
+ decode_index,
+ decode_weight,
)
- return model(
- sequence[timestep],
- control=pair_control(sequence, timestep),
- delta_t=resolve_pair_delta_t(
+ kwargs: dict[str, object] = {
+ "control": pair_control(sequence, timestep),
+ "delta_t": resolve_pair_delta_t(
sequence,
timestep,
default_time_step=model_default_delta_t(model),
),
- )
+ }
+ forward = getattr(model, "forward", model)
+ try:
+ params = signature(forward).parameters
+ except (TypeError, ValueError):
+ params = {}
+ accepts_var_kw = any(item.kind == Parameter.VAR_KEYWORD for item in params.values())
+ if accepts_var_kw or "parameters" in params:
+ kwargs["parameters"] = pair_parameters(sequence, timestep)
+ return model(sequence[timestep], **kwargs) # type: ignore[arg-type]
def one_step_predictions(
diff --git a/src/koopman_graph/uq/__init__.py b/src/koopman_graph/uq/__init__.py
index 1154c91..dbc17ce 100644
--- a/src/koopman_graph/uq/__init__.py
+++ b/src/koopman_graph/uq/__init__.py
@@ -20,6 +20,14 @@
forecast with closed-form covariance propagation and optional Kalman
refinement, plus :class:`~koopman_graph.uq.LatentGaussianForecast` and
:func:`~koopman_graph.uq.propagate_gaussian_covariance`.
+``coverage``
+ :class:`~koopman_graph.uq.JointCoverageSpec` names the interval
+ estimand (default ``per_node_marginal``). Simultaneous / event
+ targets and temporal / graph blocks are named but not implemented.
+``scores``
+ Proper scores :func:`~koopman_graph.uq.gaussian_crps`,
+ :func:`~koopman_graph.uq.gaussian_nll`, and
+ :func:`~koopman_graph.uq.energy_score`. Not coverage certificates.
``conformal``
:class:`~koopman_graph.uq.ConformalKoopmanUQ` split / adaptive (ACI)
conformal intervals returning
@@ -27,7 +35,8 @@
``HeteroData`` bands). Hetero models score stacked decoded features
(``N = Σ_τ N_τ``). Calibration state is wrapper-local (not model
``FORMAT_VERSION``). Marginal coverage ``≥ 1 − α`` under
- exchangeability; prefer ACI under drift.
+ exchangeability; prefer ACI under drift. :attr:`coverage` always
+ names ``per_node_marginal``.
``bayesian``
:class:`~koopman_graph.uq.BayesianKoopmanUQ` diagonal Laplace posterior
over dense linear factors (``K`` / ``K_self``+``K_nbr``) with
@@ -50,8 +59,9 @@
Latent-Gaussian forecasts reuse
:meth:`~koopman_graph.model.GraphKoopmanModel.encode_rollout_origin` and
-:mod:`koopman_graph.graph_utils` hold-last topology / ``propagate_latent``
-scheduling; closed-form Gaussian moment updates remain local to this package.
+:mod:`koopman_graph.graph_utils` ``propagate_latent`` with the same
+``topology_policy`` as model ``predict``; closed-form Gaussian moment
+updates remain local to this package.
"""
from koopman_graph.uq.bayesian import (
@@ -66,6 +76,7 @@
snapshot_with_features,
)
from koopman_graph.uq.conformal import ConformalKoopmanUQ
+from koopman_graph.uq.coverage import JointCoverageSpec, require_shipped_coverage
from koopman_graph.uq.ensemble import (
EnsembleGraphKoopmanModel,
IntervalForecastModel,
@@ -77,12 +88,14 @@
dense_nodewise_transition,
propagate_gaussian_covariance,
)
+from koopman_graph.uq.scores import energy_score, gaussian_crps, gaussian_nll
__all__ = [
"BayesianKoopmanUQ",
"ConformalKoopmanUQ",
"EnsembleGraphKoopmanModel",
"IntervalForecastModel",
+ "JointCoverageSpec",
"LaplaceFactorSpec",
"LaplacePosterior",
"LatentGaussianForecast",
@@ -90,8 +103,12 @@
"PredictionInterval",
"dense_nodewise_transition",
"empirical_coverage",
+ "energy_score",
+ "gaussian_crps",
+ "gaussian_nll",
"hetero_snapshot_with_features",
"propagate_gaussian_covariance",
"quantile_levels",
+ "require_shipped_coverage",
"snapshot_with_features",
]
diff --git a/src/koopman_graph/uq/bayesian.py b/src/koopman_graph/uq/bayesian.py
index 237483f..81fc498 100644
--- a/src/koopman_graph/uq/bayesian.py
+++ b/src/koopman_graph/uq/bayesian.py
@@ -28,6 +28,7 @@
from koopman_graph.graph_utils import (
autoregressive_latent_rollout,
+ call_topology_at,
hold_last_topology_at,
pack_rollout_snapshots,
propagate_latent,
@@ -40,6 +41,7 @@
)
from koopman_graph.model import GraphKoopmanModel
from koopman_graph.model.validation import validate_controls
+from koopman_graph.nn.predicted_topology import resolve_rollout_topology_at
from koopman_graph.operators import GraphKoopmanOperator, KoopmanOperator
from koopman_graph.uq.common import (
PredictionInterval,
@@ -669,6 +671,7 @@ def sample_forecast(
controls: Sequence[Tensor] | None = None,
future_topologies: Sequence[Data] | None = None,
history: Sequence[Data] | None = None,
+ topology_policy: str = "auto",
**kwargs: Any,
) -> PredictionInterval:
"""Monte Carlo predictive interval from diagonal Laplace factor draws.
@@ -695,6 +698,9 @@ def sample_forecast(
edge_index, edge_weight, controls, future_topologies, history
Same semantics as
:meth:`~koopman_graph.model.GraphKoopmanModel.predict`.
+ topology_policy : {"auto", "recursive", "hold_last"}, optional
+ Same semantics as
+ :meth:`~koopman_graph.model.GraphKoopmanModel.predict`.
Returns
-------
@@ -779,10 +785,12 @@ def sample_forecast(
if isinstance(initial_graph, Data)
else None
)
- topology_at = hold_last_topology_at(
+ topology_at = resolve_rollout_topology_at(
+ self.model,
init_edge,
init_weight,
future_topologies,
+ topology_policy,
)
control_at = None if controls is None else (lambda step: controls[step])
@@ -883,7 +891,7 @@ def _rollout_with_decode_noise(
steps : int
Forecast horizon.
topology_at : callable
- Hold-last topology schedule.
+ Topology schedule (hold-last or predicted).
control_at : callable or None
Optional control schedule.
generator : torch.Generator or None
@@ -897,7 +905,7 @@ def _rollout_with_decode_noise(
snaps: list[Data] = []
latent = z0
for step in range(steps):
- edge_t, weight_t = topology_at(step)
+ edge_t, weight_t = call_topology_at(topology_at, step, latent)
control = None if control_at is None else control_at(step)
latent = propagate_latent(
self.model.koopman,
diff --git a/src/koopman_graph/uq/conformal.py b/src/koopman_graph/uq/conformal.py
index bd13da4..8e3b25b 100644
--- a/src/koopman_graph/uq/conformal.py
+++ b/src/koopman_graph/uq/conformal.py
@@ -79,6 +79,7 @@
hetero_snapshot_with_features,
snapshot_with_features,
)
+from koopman_graph.uq.coverage import JointCoverageSpec, require_shipped_coverage
CalibrationSequence = (
GraphSnapshotSequence
@@ -380,6 +381,11 @@ class ConformalKoopmanUQ:
``[0, 1]``. ``None`` (default) disables smoothing. ``0`` reproduces
unsmoothed ``node_wise`` exactly. Only valid with
``score="node_wise"``.
+ coverage : JointCoverageSpec or None, optional
+ Named estimand. Default ``None`` still reports
+ :attr:`coverage` as ``target="per_node_marginal"`` (not an
+ implied joint claim). Simultaneous / event targets and
+ ``block!="none"`` raise.
Notes
-----
@@ -392,7 +398,8 @@ class ConformalKoopmanUQ:
and test scores; graph time series typically violate that assumption, so
treat split coverage as approximate under temporal dependence (see the
module ``Coverage semantics`` section). ``node_wise`` calibration
- requires at least ``ceil(1 / alpha)`` sequences.
+ requires at least ``ceil(1 / alpha)`` sequences. The coverage
+ target is named by :attr:`coverage`; it is never implied.
Score diffusion follows Zargarbashi et al. (ICML 2023) for classification
conformity scores; using it on regression residuals is an adaptation with
@@ -412,12 +419,13 @@ def __init__(
score: ConformalScore = "aggregate",
gamma: float = 0.005,
neighbor_smoothing: float | None = None,
+ coverage: JointCoverageSpec | None = None,
) -> None:
"""Initialize conformal UQ settings.
Parameters
----------
- model, method, score, gamma, neighbor_smoothing
+ model, method, score, gamma, neighbor_smoothing, coverage
See the class docstring.
"""
if method not in {"split", "adaptive"}:
@@ -448,11 +456,30 @@ def __init__(
self.neighbor_smoothing = (
None if neighbor_smoothing is None else float(neighbor_smoothing)
)
+ self._coverage_spec = (
+ None if coverage is None else require_shipped_coverage(coverage)
+ )
self._quantiles: Tensor | None = None
self._alpha: float | None = None
self._n_calibration: int = 0
self._calibrated_steps: int = 0
+ @property
+ def coverage(self) -> JointCoverageSpec:
+ """Named coverage estimand (never an implied joint claim).
+
+ Returns
+ -------
+ JointCoverageSpec
+ ``target="per_node_marginal"`` and ``block="none"``. ``alpha``
+ is the constructor spec or the last calibrated rate (default
+ ``0.1`` before :meth:`calibrate`).
+ """
+ if self._coverage_spec is not None:
+ return self._coverage_spec
+ alpha = 0.1 if self._alpha is None else float(self._alpha)
+ return JointCoverageSpec(target="per_node_marginal", alpha=alpha, block="none")
+
@property
def _uses_hetero(self) -> bool:
"""Return whether the wrapped model uses hetero Koopman advance.
@@ -642,6 +669,14 @@ def calibrate(
if not 0.0 < alpha < 1.0:
msg = f"alpha must lie in (0, 1), got {alpha}"
raise ValueError(msg)
+ if self._coverage_spec is not None and float(alpha) != float(
+ self._coverage_spec.alpha
+ ):
+ msg = (
+ "calibrate alpha must match coverage.alpha "
+ f"{self._coverage_spec.alpha}; got {alpha}"
+ )
+ raise ValueError(msg)
if not calibration_sequences:
msg = "calibration_sequences must be non-empty"
raise ValueError(msg)
diff --git a/src/koopman_graph/uq/coverage.py b/src/koopman_graph/uq/coverage.py
new file mode 100644
index 0000000..4c7f4c8
--- /dev/null
+++ b/src/koopman_graph/uq/coverage.py
@@ -0,0 +1,127 @@
+"""Named coverage targets for forecast intervals.
+
+:class:`JointCoverageSpec` records the estimand. It does not itself
+compute intervals. Shipped conformal methods name
+``target="per_node_marginal"``; simultaneous and event targets are
+documented but not implemented (``Schlembach2025Conformal``).
+``dynamics_mode="stochastic"`` does not change this contract.
+
+This module must not import :mod:`koopman_graph.model` or
+:mod:`koopman_graph.operators`.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Literal
+
+CoverageTarget = Literal[
+ "per_node_marginal",
+ "simultaneous_node_feature_horizon",
+ "event",
+]
+CoverageBlock = Literal["none", "temporal", "graph"]
+
+DEFAULT_COVERAGE_ALPHA = 0.1
+DEFAULT_COVERAGE_TARGET: CoverageTarget = "per_node_marginal"
+DEFAULT_COVERAGE_BLOCK: CoverageBlock = "none"
+SHIPPED_COVERAGE_TARGET: CoverageTarget = "per_node_marginal"
+SHIPPED_COVERAGE_BLOCK: CoverageBlock = "none"
+
+__all__ = [
+ "DEFAULT_COVERAGE_ALPHA",
+ "DEFAULT_COVERAGE_BLOCK",
+ "DEFAULT_COVERAGE_TARGET",
+ "JointCoverageSpec",
+ "require_shipped_coverage",
+]
+
+
+@dataclass(frozen=True)
+class JointCoverageSpec:
+ """Named coverage estimand for interval UQ.
+
+ Attributes
+ ----------
+ target : {"per_node_marginal", "simultaneous_node_feature_horizon", "event"}
+ What the interval claims to cover. Default is
+ ``per_node_marginal``. Simultaneous node–feature–horizon
+ boxes and event coverage are named only.
+ alpha : float
+ Miscoverage rate in ``(0, 1)``. Default ``0.1``.
+ block : {"none", "temporal", "graph"}
+ Dependence assumption attached to the claim. Default ``none``.
+ Temporal and graph blocks are named only.
+
+ Notes
+ -----
+ Coverage is assumption-dependent (exchangeability or a named block
+ model). This record does not certify joint graph UQ.
+ """
+
+ target: CoverageTarget = DEFAULT_COVERAGE_TARGET
+ alpha: float = DEFAULT_COVERAGE_ALPHA
+ block: CoverageBlock = DEFAULT_COVERAGE_BLOCK
+
+ def __post_init__(self) -> None:
+ """Validate the named estimand.
+
+ Raises
+ ------
+ ValueError
+ If ``target``, ``alpha``, or ``block`` is invalid.
+ """
+ if self.target not in {
+ "per_node_marginal",
+ "simultaneous_node_feature_horizon",
+ "event",
+ }:
+ msg = (
+ "coverage target must be 'per_node_marginal', "
+ "'simultaneous_node_feature_horizon', or 'event', "
+ f"got {self.target!r}"
+ )
+ raise ValueError(msg)
+ if not 0.0 < float(self.alpha) < 1.0:
+ msg = f"coverage alpha must lie in (0, 1), got {self.alpha!r}"
+ raise ValueError(msg)
+ if self.block not in {"none", "temporal", "graph"}:
+ msg = (
+ "coverage block must be 'none', 'temporal', or 'graph', "
+ f"got {self.block!r}"
+ )
+ raise ValueError(msg)
+
+
+def require_shipped_coverage(spec: JointCoverageSpec) -> JointCoverageSpec:
+ """Refuse estimands that are named but not implemented.
+
+ Parameters
+ ----------
+ spec : JointCoverageSpec
+ Caller coverage record.
+
+ Returns
+ -------
+ JointCoverageSpec
+ The same spec when it is the shipped marginal / ``block="none"``
+ MVP.
+
+ Raises
+ ------
+ ValueError
+ If ``target`` or ``block`` is not the shipped pair.
+ """
+ if spec.target != SHIPPED_COVERAGE_TARGET:
+ msg = (
+ "shipped coverage target is 'per_node_marginal'; "
+ f"{spec.target!r} is named but not implemented"
+ )
+ raise ValueError(msg)
+ if spec.block != SHIPPED_COVERAGE_BLOCK:
+ msg = (
+ "shipped coverage block is 'none'; "
+ f"{spec.block!r} is named but not implemented"
+ )
+ raise ValueError(msg)
+ return spec
diff --git a/src/koopman_graph/uq/ensemble.py b/src/koopman_graph/uq/ensemble.py
index 410ff1c..cad082d 100644
--- a/src/koopman_graph/uq/ensemble.py
+++ b/src/koopman_graph/uq/ensemble.py
@@ -3,7 +3,7 @@
Composes independently seeded :class:`~koopman_graph.model.GraphKoopmanModel`
members without subclassing. Member forecasts call
:func:`~koopman_graph.graph_utils.autoregressive_latent_rollout` directly
-(same hold-last topology policy as ``predict``). Predictive uncertainty
+(same ``topology_policy`` as ``predict``). Predictive uncertainty
follows the deep-ensemble practice of Lakshminarayanan et al. (NeurIPS
2017): empirical mean and quantiles across member forecasts.
"""
@@ -21,13 +21,13 @@
from koopman_graph.graph_utils import (
autoregressive_latent_rollout,
- hold_last_topology_at,
pack_rollout_snapshots,
snapshot_hyperedge_index,
snapshot_hyperedge_weight,
)
from koopman_graph.model import GraphKoopmanModel
from koopman_graph.model.validation import validate_controls
+from koopman_graph.nn.predicted_topology import resolve_rollout_topology_at
from koopman_graph.training import FitHistory
from koopman_graph.uq.common import (
PredictionInterval,
@@ -158,11 +158,13 @@ def _member_autoregressive_rollout(
controls: Sequence[Tensor] | None,
future_topologies: Sequence[Data] | None,
history: Sequence[Data] | None,
+ topology_policy: str = "auto",
) -> list[Data]:
"""Roll out one ensemble member via the shared latent rollout primitive.
Mirrors :meth:`~koopman_graph.model.GraphKoopmanModel.predict` semantics
- (eval mode, no grad, hold-last topology, control validation) but calls
+ (eval mode, no grad, the same topology policy as
+ :meth:`~koopman_graph.model.GraphKoopmanModel.predict`) but calls
:func:`~koopman_graph.graph_utils.autoregressive_latent_rollout` directly
instead of going through ``member.predict``.
@@ -185,6 +187,8 @@ def _member_autoregressive_rollout(
See the function signature / summary for ``future_topologies``.
history : Sequence[Data] | None
See the function signature / summary for ``history``.
+ topology_policy : {"auto", "recursive", "hold_last"}, optional
+ Same semantics as :meth:`~koopman_graph.model.GraphKoopmanModel.predict`.
Returns
-------
@@ -227,10 +231,12 @@ def _member_autoregressive_rollout(
member.decoder,
z,
steps=steps,
- topology_at=hold_last_topology_at(
+ topology_at=resolve_rollout_topology_at(
+ member,
origin_edge,
origin_weight,
future_topologies,
+ topology_policy,
),
control_at=control_at,
default_delta_t=member.time_step,
@@ -286,7 +292,7 @@ class EnsembleGraphKoopmanModel:
Aggregates member autoregressive forecasts into an empirical mean and
quantile interval. Each member rollout uses
:func:`~koopman_graph.graph_utils.autoregressive_latent_rollout` with the
- same hold-last topology policy as
+ same ``topology_policy`` as
:meth:`~koopman_graph.model.GraphKoopmanModel.predict`. Members remain
ordinary :class:`~koopman_graph.model.GraphKoopmanModel` instances:
spectrum, regularization, and ``koopman="graph"`` topology contracts are
@@ -478,6 +484,8 @@ def predict(
controls: Sequence[Tensor] | None = None,
future_topologies: Sequence[Data] | None = None,
history: Sequence[Data] | None = None,
+ *,
+ topology_policy: str = "auto",
) -> list[Data]:
"""Return the ensemble-mean autoregressive forecast.
@@ -497,6 +505,8 @@ def predict(
Known future topologies for hold-last rollout scheduling.
history : sequence of Data or None, optional
Delay-embedding history for each member encode.
+ topology_policy : {"auto", "recursive", "hold_last"}, optional
+ Same semantics as :meth:`~koopman_graph.model.GraphKoopmanModel.predict`.
Returns
-------
@@ -512,6 +522,7 @@ def predict(
future_topologies=future_topologies,
history=history,
level=0.9,
+ topology_policy=topology_policy,
)
return interval.mean
@@ -526,6 +537,7 @@ def predict_interval(
history: Sequence[Data] | None = None,
*,
level: float = 0.9,
+ topology_policy: str = "auto",
) -> PredictionInterval:
"""Return mean and empirical quantile bounds across members.
@@ -548,6 +560,8 @@ def predict_interval(
level : float, optional
Nominal central coverage in ``(0, 1)``. Default ``0.9`` uses the
5th and 95th empirical percentiles across members.
+ topology_policy : {"auto", "recursive", "hold_last"}, optional
+ Same semantics as :meth:`~koopman_graph.model.GraphKoopmanModel.predict`.
Returns
-------
@@ -565,6 +579,7 @@ def predict_interval(
controls=controls,
future_topologies=future_topologies,
history=history,
+ topology_policy=topology_policy,
)
for member in self._members
]
diff --git a/src/koopman_graph/uq/latent_gaussian.py b/src/koopman_graph/uq/latent_gaussian.py
index 2aeb1ae..b937783 100644
--- a/src/koopman_graph/uq/latent_gaussian.py
+++ b/src/koopman_graph/uq/latent_gaussian.py
@@ -23,11 +23,12 @@
from torch_geometric.data import Data
from koopman_graph.graph_utils import (
- hold_last_topology_at,
+ call_topology_at,
propagate_latent,
resolve_delta_t,
)
from koopman_graph.model import GraphKoopmanModel
+from koopman_graph.nn.predicted_topology import resolve_rollout_topology_at
from koopman_graph.operators import (
ContinuousKoopmanOperator,
GraphKoopmanOperator,
@@ -223,6 +224,7 @@ def forecast_latents(
future_topologies: Sequence[Data] | None = None,
history: Sequence[Data] | None = None,
observations: Sequence[Data] | None = None,
+ topology_policy: str = "auto",
) -> LatentGaussianForecast:
"""Propagate latent means and covariances for ``steps`` forecasts.
@@ -240,6 +242,8 @@ def forecast_latents(
provided, each step applies a Kalman update after the predict
step using ``latent_encode`` measurements (heuristic under
nonlinear encoders).
+ topology_policy : {"auto", "recursive", "hold_last"}, optional
+ Same semantics as :meth:`~koopman_graph.model.GraphKoopmanModel.predict`.
Returns
-------
@@ -269,6 +273,7 @@ def forecast_latents(
future_topologies=future_topologies,
history=history,
observations=observations,
+ topology_policy=topology_policy,
)
finally:
self.model.train(was_training)
@@ -283,6 +288,7 @@ def predict(
future_topologies: Sequence[Data] | None = None,
history: Sequence[Data] | None = None,
observations: Sequence[Data] | None = None,
+ topology_policy: str = "auto",
) -> list[Data]:
"""Decode the latent-mean forecast.
@@ -305,6 +311,8 @@ def predict(
See the function signature / summary for ``history``.
observations : Sequence[Data] | None
See the function signature / summary for ``observations``.
+ topology_policy : {"auto", "recursive", "hold_last"}, optional
+ Same semantics as :meth:`~koopman_graph.model.GraphKoopmanModel.predict`.
Returns
-------
@@ -321,6 +329,7 @@ def predict(
history=history,
observations=observations,
level=0.9,
+ topology_policy=topology_policy,
)
return interval.mean
@@ -337,6 +346,7 @@ def predict_interval(
observations: Sequence[Data] | None = None,
level: float = 0.9,
generator: torch.Generator | None = None,
+ topology_policy: str = "auto",
**kwargs: Any,
) -> PredictionInterval:
"""Return mean decode plus Monte Carlo predictive quantiles.
@@ -358,6 +368,8 @@ def predict_interval(
Nominal central coverage in ``(0, 1)``. Default ``0.9``.
generator : torch.Generator or None, optional
RNG for latent Monte Carlo draws.
+ topology_policy : {"auto", "recursive", "hold_last"}, optional
+ Same semantics as :meth:`forecast_latents`.
Returns
-------
@@ -387,17 +399,20 @@ def predict_interval(
future_topologies=future_topologies,
history=history,
observations=observations,
+ topology_policy=topology_policy,
)
- _, init_edge, init_weight = self.model.encode_rollout_origin(
+ z0, init_edge, init_weight = self.model.encode_rollout_origin(
initial_graph,
edge_index=edge_index,
edge_weight=edge_weight,
history=history,
)
- topology_at = hold_last_topology_at(
+ topology_at = resolve_rollout_topology_at(
+ self.model,
init_edge,
init_weight,
- future_topologies=future_topologies,
+ future_topologies,
+ topology_policy,
)
was_training = self.model.training
@@ -407,8 +422,9 @@ def predict_interval(
mean_snaps: list[Data] = []
lower_snaps: list[Data] = []
upper_snaps: list[Data] = []
+ current_z = z0
for step in range(steps):
- edge_t, weight_t = topology_at(step)
+ edge_t, weight_t = call_topology_at(topology_at, step, current_z)
template = Data(x=forecast.means[step], edge_index=edge_t)
if weight_t is not None:
template.edge_weight = weight_t
@@ -441,6 +457,7 @@ def predict_interval(
mean_snaps.append(snapshot_with_features(template, mean_x))
lower_snaps.append(snapshot_with_features(template, lower_x))
upper_snaps.append(snapshot_with_features(template, upper_x))
+ current_z = forecast.means[step]
finally:
self.model.train(was_training)
@@ -463,6 +480,7 @@ def _forecast_latents_impl(
future_topologies: Sequence[Data] | None,
history: Sequence[Data] | None,
observations: Sequence[Data] | None,
+ topology_policy: str = "auto",
) -> LatentGaussianForecast:
"""Propagate latent moments while the caller manages evaluation mode.
@@ -485,6 +503,8 @@ def _forecast_latents_impl(
See the function signature / summary for ``history``.
observations : Sequence[Data] | None
See the function signature / summary for ``observations``.
+ topology_policy : {"auto", "recursive", "hold_last"}, optional
+ Same semantics as :meth:`~koopman_graph.model.GraphKoopmanModel.predict`.
Returns
-------
@@ -512,16 +532,19 @@ def _forecast_latents_impl(
means = torch.empty(steps, n_nodes, d, dtype=dtype, device=device)
covs = torch.empty(steps, state_dim, state_dim, dtype=dtype, device=device)
- topology_at = hold_last_topology_at(
+ topology_at = resolve_rollout_topology_at(
+ self.model,
init_edge,
init_weight,
- future_topologies=future_topologies,
+ future_topologies,
+ topology_policy,
)
control_at = None if controls is None else (lambda step: controls[step])
default_delta_t = self.model.time_step
for t in range(steps):
- edge_t, weight_t = topology_at(t)
+ z_t = mean.reshape(n_nodes, d)
+ edge_t, weight_t = call_topology_at(topology_at, t, z_t)
control = None if control_at is None else control_at(t)
transition, bias = self._transition_and_bias(
n_nodes=n_nodes,
diff --git a/src/koopman_graph/uq/scores.py b/src/koopman_graph/uq/scores.py
new file mode 100644
index 0000000..a290f20
--- /dev/null
+++ b/src/koopman_graph/uq/scores.py
@@ -0,0 +1,203 @@
+"""Proper scoring rules for Gaussian and ensemble forecasts.
+
+Closed-form univariate Gaussian CRPS / NLL and the energy score on
+iid samples. Scores are not coverage certificates and do not change
+``dynamics_mode="stochastic"``.
+
+This module must not import :mod:`koopman_graph.model` or
+:mod:`koopman_graph.operators`.
+"""
+
+from __future__ import annotations
+
+import math
+from typing import Literal
+
+import torch
+from torch import Tensor
+
+ScoreReduction = Literal["mean", "sum", "none"]
+_VARIANCE_FLOOR = 1e-12
+
+__all__ = [
+ "energy_score",
+ "gaussian_crps",
+ "gaussian_nll",
+]
+
+
+def _reduce(values: Tensor, reduction: ScoreReduction) -> Tensor:
+ """Reduce a score tensor.
+
+ Parameters
+ ----------
+ values : Tensor
+ Per-event scores.
+ reduction : {"mean", "sum", "none"}
+ Reduction over all elements.
+
+ Returns
+ -------
+ Tensor
+ Reduced scalar, or ``values`` when ``reduction="none"``.
+ """
+ if reduction == "none":
+ return values
+ if reduction == "sum":
+ return values.sum()
+ if reduction == "mean":
+ return values.mean()
+ msg = f"reduction must be 'mean', 'sum', or 'none', got {reduction!r}"
+ raise ValueError(msg)
+
+
+def _positive_variance(variance: Tensor) -> Tensor:
+ """Refuse non-finite or non-positive variances.
+
+ Parameters
+ ----------
+ variance : Tensor
+ Predictive variances (same unit squared as the observation).
+
+ Returns
+ -------
+ Tensor
+ Clamped positive variance.
+
+ Raises
+ ------
+ ValueError
+ If any variance is non-finite or non-positive before the floor.
+ """
+ if not bool(torch.all(torch.isfinite(variance))):
+ msg = "variance must be finite"
+ raise ValueError(msg)
+ if bool(torch.any(variance <= 0.0)):
+ msg = "variance must be strictly positive"
+ raise ValueError(msg)
+ return torch.clamp(variance, min=_VARIANCE_FLOOR)
+
+
+def gaussian_nll(
+ observation: Tensor,
+ mean: Tensor,
+ variance: Tensor,
+ *,
+ reduction: ScoreReduction = "mean",
+) -> Tensor:
+ """Univariate Gaussian negative log-likelihood.
+
+ :math:`\\frac{1}{2}\\bigl(\\log(2\\pi\\sigma^2) + (y-\\mu)^2/\\sigma^2\\bigr)`
+ per entry. Proper for a Gaussian predictive density.
+
+ Parameters
+ ----------
+ observation, mean : Tensor
+ Observation and predictive mean (same unit; broadcastable).
+ variance : Tensor
+ Predictive variance (unit squared). Must be positive and finite.
+ reduction : {"mean", "sum", "none"}, optional
+ Reduction over broadcast elements. Default ``mean``.
+
+ Returns
+ -------
+ Tensor
+ NLL in nats.
+ """
+ sigma2 = _positive_variance(variance)
+ residual = observation - mean
+ nll = 0.5 * (torch.log(2.0 * math.pi * sigma2) + (residual * residual) / sigma2)
+ return _reduce(nll, reduction)
+
+
+def gaussian_crps(
+ observation: Tensor,
+ mean: Tensor,
+ variance: Tensor,
+ *,
+ reduction: ScoreReduction = "mean",
+) -> Tensor:
+ """Closed-form univariate Gaussian CRPS.
+
+ For :math:`Z\\sim\\mathcal{N}(\\mu,\\sigma^2)` and residual
+ :math:`z=(y-\\mu)/\\sigma`,
+ :math:`\\mathrm{CRPS}=\\sigma\\bigl(z(2\\Phi(z)-1)+2\\varphi(z)-1/\\sqrt{\\pi}\\bigr)`.
+ Proper for a Gaussian predictive distribution.
+
+ Parameters
+ ----------
+ observation, mean : Tensor
+ Observation and predictive mean (same unit; broadcastable).
+ variance : Tensor
+ Predictive variance (unit squared). Must be positive and finite.
+ reduction : {"mean", "sum", "none"}, optional
+ Reduction over broadcast elements. Default ``mean``.
+
+ Returns
+ -------
+ Tensor
+ CRPS in the observation unit.
+ """
+ sigma2 = _positive_variance(variance)
+ sigma = torch.sqrt(sigma2)
+ z = (observation - mean) / sigma
+ cdf = 0.5 * (1.0 + torch.erf(z / math.sqrt(2.0)))
+ pdf = torch.exp(-0.5 * z * z) / math.sqrt(2.0 * math.pi)
+ crps = sigma * (z * (2.0 * cdf - 1.0) + 2.0 * pdf - 1.0 / math.sqrt(math.pi))
+ return _reduce(crps, reduction)
+
+
+def energy_score(
+ observation: Tensor,
+ samples: Tensor,
+ *,
+ reduction: ScoreReduction = "mean",
+) -> Tensor:
+ """Energy score of an iid ensemble against an observation.
+
+ :math:`\\mathbb{E}\\|y-x\\| - \\tfrac{1}{2}\\mathbb{E}\\|x-x'\\|` with
+ an unbiased pairwise term. Proper for a multivariate forecast.
+ ``samples`` lead with the ensemble axis.
+
+ Parameters
+ ----------
+ observation : Tensor
+ Observation with shape ``(..., d)``.
+ samples : Tensor
+ Ensemble with shape ``(S, ..., d)`` and ``S >= 2``.
+ reduction : {"mean", "sum", "none"}, optional
+ Reduction over event axes (not the ensemble). Default ``mean``.
+
+ Returns
+ -------
+ Tensor
+ Energy score in the observation unit.
+
+ Raises
+ ------
+ ValueError
+ If the ensemble axis has fewer than two draws or shapes disagree.
+ """
+ if samples.ndim < 2:
+ msg = "samples must have shape (S, ..., d) with S >= 2"
+ raise ValueError(msg)
+ ensemble = samples.shape[0]
+ if ensemble < 2:
+ msg = f"energy_score requires at least 2 samples, got {ensemble}"
+ raise ValueError(msg)
+ if observation.shape != samples.shape[1:]:
+ msg = (
+ "observation shape must match samples[1:], "
+ f"got {tuple(observation.shape)} vs {tuple(samples.shape[1:])}"
+ )
+ raise ValueError(msg)
+ residual = samples - observation.unsqueeze(0)
+ term_obs = torch.linalg.vector_norm(residual, dim=-1).mean(dim=0)
+ delta = samples.unsqueeze(1) - samples.unsqueeze(0)
+ pairwise = torch.linalg.vector_norm(delta, dim=-1)
+ off_diag = 1.0 - torch.eye(ensemble, dtype=pairwise.dtype, device=pairwise.device)
+ while off_diag.ndim < pairwise.ndim:
+ off_diag = off_diag.unsqueeze(-1)
+ term_pair = (pairwise * off_diag).sum(dim=(0, 1)) / float(ensemble * (ensemble - 1))
+ score = term_obs - 0.5 * term_pair
+ return _reduce(score, reduction)
diff --git a/tests/adaptation/test_joint_observer.py b/tests/adaptation/test_joint_observer.py
new file mode 100644
index 0000000..bdaf8fd
--- /dev/null
+++ b/tests/adaptation/test_joint_observer.py
@@ -0,0 +1,216 @@
+"""Joint state–topology observer: separable toy and theorem-flag guards."""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+
+import pytest
+import torch
+from torch_geometric.data import Data
+
+from koopman_graph import (
+ GNNDecoder,
+ GNNEncoder,
+ GraphKoopmanModel,
+ GraphSnapshotSequence,
+)
+from koopman_graph.adaptation import JointObserverResult, JointStateTopologyObserver
+from koopman_graph.nn import (
+ SeparableDictionaryDecoder,
+ SeparableDictionaryEncoder,
+)
+from koopman_graph.operators import GraphKoopmanOperator, KoopmanOperator
+
+
+def _path_edge_index(num_nodes: int) -> torch.Tensor:
+ """Return a bidirectional path.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count (at least 2).
+
+ Returns
+ -------
+ Tensor
+ COO index.
+ """
+ src = list(range(num_nodes - 1)) + list(range(1, num_nodes))
+ dst = list(range(1, num_nodes)) + list(range(num_nodes - 1))
+ return torch.tensor([src, dst], dtype=torch.long)
+
+
+def _sequence(
+ *,
+ in_channels: int = 2,
+ num_nodes: int = 4,
+ length: int = 4,
+) -> GraphSnapshotSequence:
+ """Build a short homogeneous path sequence.
+
+ Parameters
+ ----------
+ in_channels : int, optional
+ Feature width.
+ num_nodes : int, optional
+ Node count.
+ length : int, optional
+ Snapshot count.
+
+ Returns
+ -------
+ GraphSnapshotSequence
+ Path-graph trajectory.
+ """
+ torch.manual_seed(0)
+ edges = _path_edge_index(num_nodes)
+ snapshots = [
+ Data(x=torch.randn(num_nodes, in_channels), edge_index=edges)
+ for _ in range(length)
+ ]
+ return GraphSnapshotSequence(snapshots)
+
+
+def _separable_graph_model() -> GraphKoopmanModel:
+ """Return an untrained separable graph model.
+
+ Returns
+ -------
+ GraphKoopmanModel
+ Node-wise encoder/decoder with ``koopman="graph"``.
+ """
+ return GraphKoopmanModel(
+ SeparableDictionaryEncoder(2, 8, 3, num_layers=1),
+ SeparableDictionaryDecoder(3, 8, 2, num_layers=1),
+ latent_dim=3,
+ time_step=0.1,
+ koopman="graph",
+ )
+
+
+def test_separable_graph_observer_runs_on_toy() -> None:
+ """Filter plus group-sparse factor write-back returns finite factors."""
+ model = _separable_graph_model()
+ sequence = _sequence()
+ observer = JointStateTopologyObserver(model, claim_homomorphism=True)
+ result = observer.filter_and_adapt(sequence)
+ assert result.sparse_factors is not None
+ assert result.filter.latents.shape[0] == len(sequence)
+ assert torch.isfinite(result.sparse_factors.K_self).all()
+ assert torch.isfinite(result.sparse_factors.K_nbr).all()
+ assert isinstance(model.koopman, GraphKoopmanOperator)
+ torch.testing.assert_close(
+ model.koopman.K_self,
+ result.sparse_factors.K_self.to(
+ device=model.koopman.K_self.device,
+ dtype=model.koopman.K_self.dtype,
+ ),
+ atol=1e-6,
+ rtol=0.0,
+ )
+ assert result.rls_steps == ()
+
+
+def test_gnn_encoder_theorem_flag_raises() -> None:
+ """Neighbor-mixing GNN encoders cannot carry homomorphism claims."""
+ model = GraphKoopmanModel(
+ GNNEncoder(2, 8, 3, num_layers=1),
+ GNNDecoder(3, 8, 2, num_layers=1),
+ latent_dim=3,
+ time_step=0.1,
+ koopman="graph",
+ )
+ with pytest.raises(ValueError, match="encoder_kind='separable'"):
+ JointStateTopologyObserver(model, claim_homomorphism=True)
+ observer = JointStateTopologyObserver(model, claim_homomorphism=False)
+ result = observer.filter_and_adapt(_sequence())
+ assert result.sparse_factors is not None
+
+
+def test_dense_operator_theorem_flag_raises() -> None:
+ """Homomorphism claims require a graph operator, not dense per-node ``K``."""
+ model = GraphKoopmanModel(
+ SeparableDictionaryEncoder(2, 8, 3, num_layers=1),
+ SeparableDictionaryDecoder(3, 8, 2, num_layers=1),
+ latent_dim=3,
+ time_step=0.1,
+ koopman=None,
+ )
+ with pytest.raises(ValueError, match="GraphKoopmanOperator"):
+ JointStateTopologyObserver(model, claim_homomorphism=True)
+
+
+def test_dense_path_runs_rls() -> None:
+ """Per-node dense ``K`` uses RLS write-back without a theorem tag."""
+ model = GraphKoopmanModel(
+ SeparableDictionaryEncoder(2, 8, 3, num_layers=1),
+ SeparableDictionaryDecoder(3, 8, 2, num_layers=1),
+ latent_dim=3,
+ time_step=0.1,
+ koopman=None,
+ )
+ assert isinstance(model.koopman, KoopmanOperator)
+ observer = JointStateTopologyObserver(model, claim_homomorphism=False)
+ result = observer.filter_and_adapt(_sequence())
+ assert result.sparse_factors is None
+ assert len(result.rls_steps) == 3
+ assert all(torch.isfinite(step.operator_change_norm) for step in result.rls_steps)
+
+
+def test_short_sequence_raises() -> None:
+ """A single snapshot cannot form latent pairs."""
+ model = _separable_graph_model()
+ observer = JointStateTopologyObserver(model, claim_homomorphism=True)
+ with pytest.raises(ValueError, match="T >= 2"):
+ observer.filter_and_adapt(_sequence(length=1))
+
+
+def test_polynomial_and_dual_walk_graph_paths_raise() -> None:
+ """One-tap dense symmetric / random-walk factorization only."""
+ dual = GraphKoopmanModel(
+ SeparableDictionaryEncoder(2, 8, 3, num_layers=1),
+ SeparableDictionaryDecoder(3, 8, 2, num_layers=1),
+ latent_dim=3,
+ time_step=0.1,
+ koopman="graph",
+ koopman_adjacency="dual_random_walk",
+ )
+ with pytest.raises(ValueError, match="dual_random_walk"):
+ JointStateTopologyObserver(dual, claim_homomorphism=True)
+ polynomial = GraphKoopmanModel(
+ SeparableDictionaryEncoder(2, 8, 3, num_layers=1),
+ SeparableDictionaryDecoder(3, 8, 2, num_layers=1),
+ latent_dim=3,
+ time_step=0.1,
+ koopman="graph",
+ koopman_filter_degree=2,
+ )
+ with pytest.raises(ValueError, match="filter_degree=1"):
+ JointStateTopologyObserver(polynomial, claim_homomorphism=True)
+
+
+def test_rls_and_observer_import_does_not_load_identification() -> None:
+ """Package import of RLS / Kalman observer stays identification-free."""
+ script = (
+ "import sys\n"
+ "from koopman_graph.adaptation import (\n"
+ " KoopmanObserver, RecursiveKoopmanAdapter)\n"
+ "assert KoopmanObserver is not None\n"
+ "assert RecursiveKoopmanAdapter is not None\n"
+ "assert 'koopman_graph.identification' not in sys.modules, "
+ "sorted(k for k in sys.modules if 'identification' in k)\n"
+ )
+ completed = subprocess.run(
+ [sys.executable, "-c", script],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ assert completed.returncode == 0, completed.stderr
+
+
+def test_joint_observer_types_are_exported() -> None:
+ """Lazy package export resolves joint-observer types."""
+ assert JointObserverResult.__name__ == "JointObserverResult"
+ assert JointStateTopologyObserver.__name__ == "JointStateTopologyObserver"
diff --git a/tests/analysis/test_causal.py b/tests/analysis/test_causal.py
index 95796ca..bd40421 100644
--- a/tests/analysis/test_causal.py
+++ b/tests/analysis/test_causal.py
@@ -16,3 +16,10 @@ def test_causal_rejects_bad_rank_and_short_series() -> None:
granger_latent_influence(torch.randn(4, 3))
with pytest.raises(ValueError, match="T >= 3"):
granger_latent_influence(torch.randn(2, 3, 2))
+
+
+def test_granger_docstring_is_non_interventional() -> None:
+ """Acceptance: Granger remains labeled non-interventional."""
+ doc = granger_latent_influence.__doc__
+ assert doc is not None
+ assert "non-interventional" in doc
diff --git a/tests/analysis/test_causal_intervention.py b/tests/analysis/test_causal_intervention.py
new file mode 100644
index 0000000..b6efbbb
--- /dev/null
+++ b/tests/analysis/test_causal_intervention.py
@@ -0,0 +1,117 @@
+"""Labeled synthetic SCM interventions (TASK-2348)."""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+import pytest
+import torch
+
+import koopman_graph
+from koopman_graph.analysis import (
+ SyntheticInterventionReport,
+ SyntheticSCM,
+ granger_latent_influence,
+ recover_synthetic_interventional_edges,
+ teaching_three_node_scm,
+)
+from koopman_graph.analysis.causal_intervention import (
+ DEFAULT_ATE_THRESHOLD,
+ sample_synthetic_intervention,
+ sample_synthetic_observational,
+)
+
+
+def test_intervention_types_are_off_root_and_on_analysis_all() -> None:
+ """Synthetic SCM types stay off the root façade."""
+ exported = set(koopman_graph.__all__)
+ assert "SyntheticSCM" not in exported
+ assert "SyntheticInterventionReport" not in exported
+ assert "teaching_three_node_scm" not in exported
+ assert "recover_synthetic_interventional_edges" not in exported
+ assert "SyntheticSCM" in koopman_graph.analysis.__all__
+ assert "recover_synthetic_interventional_edges" in koopman_graph.analysis.__all__
+ assert "teaching_three_node_scm" in koopman_graph.analysis.__all__
+
+
+def test_granger_docstring_stays_non_interventional() -> None:
+ """Default Granger helper remains labeled non-interventional."""
+ doc = granger_latent_influence.__doc__
+ assert doc is not None
+ assert "non-interventional" in doc
+ assert "do-operator" in doc
+
+
+def test_causal_intervention_does_not_import_model() -> None:
+ """The intervention leaf must not import ``model``."""
+ import koopman_graph.analysis.causal_intervention as module
+
+ tree = ast.parse(Path(module.__file__).read_text(encoding="utf-8"))
+ imported: list[str] = []
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ imported.extend(alias.name for alias in node.names)
+ elif isinstance(node, ast.ImportFrom) and node.module:
+ imported.append(node.module)
+ assert all(not name.startswith("koopman_graph.model") for name in imported)
+
+
+def test_teaching_scm_recovers_known_interventional_edge() -> None:
+ """Seeded three-node SCM recovers the known do-edge ``0 → 1``."""
+ scm = teaching_three_node_scm(seed=0)
+ assert scm.labeled_synthetic is True
+ assert scm.true_edges == ((0, 1),)
+ report = recover_synthetic_interventional_edges(scm, seed=0)
+ assert isinstance(report, SyntheticInterventionReport)
+ assert report.labeled_synthetic is True
+ assert report.true_edges == ((0, 1),)
+ assert (0, 1) in report.recovered_edges
+ assert (1, 0) not in report.recovered_edges
+ assert (0, 2) not in report.recovered_edges
+ assert (2, 0) not in report.recovered_edges
+ assert float(report.scores[0, 1]) > DEFAULT_ATE_THRESHOLD
+ assert float(report.scores[1, 0]) < DEFAULT_ATE_THRESHOLD
+ assert float(report.scores[0, 2]) < DEFAULT_ATE_THRESHOLD
+
+
+def test_do_intervention_shifts_child_not_parent() -> None:
+ """``do(X_0 = 1)`` moves node 1 and leaves independent node 2 near 0."""
+ scm = teaching_three_node_scm(seed=1, noise_scale=0.01)
+ intervened = sample_synthetic_intervention(
+ scm,
+ source=0,
+ value=1.0,
+ n_samples=400,
+ generator=torch.Generator().manual_seed(7),
+ )
+ observational = sample_synthetic_observational(
+ scm,
+ 400,
+ generator=torch.Generator().manual_seed(8),
+ )
+ assert intervened.shape == (400, 3)
+ assert torch.allclose(intervened[:, 0], torch.ones(400, dtype=intervened.dtype))
+ assert abs(float(intervened[:, 1].mean()) - 0.8) < 0.05
+ assert abs(float(intervened[:, 2].mean())) < 0.05
+ assert abs(float(observational[:, 0].mean())) < 0.05
+
+
+def test_cyclic_weights_are_refused() -> None:
+ """Cyclic contemporaneous weights raise."""
+ weights = torch.zeros(2, 2)
+ weights[0, 1] = 0.4
+ weights[1, 0] = 0.4
+ with pytest.raises(ValueError, match="acyclic"):
+ SyntheticSCM(weights=weights, noise_scale=0.1, seed=0)
+
+
+def test_recover_requires_distinct_do_values() -> None:
+ """Paired interventions must use two distinct do-values."""
+ scm = teaching_three_node_scm()
+ with pytest.raises(ValueError, match="must differ"):
+ recover_synthetic_interventional_edges(
+ scm,
+ intervention_low=0.5,
+ intervention_high=0.5,
+ )
diff --git a/tests/analysis/test_criticality.py b/tests/analysis/test_criticality.py
new file mode 100644
index 0000000..8d72a12
--- /dev/null
+++ b/tests/analysis/test_criticality.py
@@ -0,0 +1,160 @@
+"""Tests for the sliding-window spectral-gap criticality monitor."""
+
+from __future__ import annotations
+
+from dataclasses import replace
+
+import pytest
+import torch
+
+import koopman_graph
+import koopman_graph.analysis as analysis
+from koopman_graph.analysis.criticality import (
+ CriticalityReport,
+ monitor_critical_transition,
+)
+from koopman_graph.spectrum_types import KoopmanSpectrum, compute_spectrum
+
+# Independent construction: diag(1, 1+δ_t) has gap δ_t. Float64 eig residual.
+_ORACLE_REL = 1e-10
+_ORACLE_ABS = 1e-12
+_N_TIMES = 11
+_WINDOW = 5
+_GAP0 = 1.0
+# Unit-spaced δ_t = 1 - t/10 ⇒ window-5 closure rate is 0.4/4 = 0.1.
+_UNIT_CLOSURE_RATE = 0.1
+
+
+def _diagonal_spectrum(primary: float, secondary: float) -> KoopmanSpectrum:
+ matrix = torch.diag(
+ torch.tensor([primary, secondary], dtype=torch.float64),
+ )
+ return compute_spectrum(matrix, time_step=1.0)
+
+
+def _closing_spectra() -> list[KoopmanSpectrum]:
+ spectra = []
+ for step in range(_N_TIMES):
+ gap = _GAP0 * (1.0 - step / (_N_TIMES - 1))
+ spectra.append(_diagonal_spectrum(1.0, 1.0 + gap))
+ return spectra
+
+
+def _stable_spectra() -> list[KoopmanSpectrum]:
+ return [_diagonal_spectrum(1.0, 1.0 + _GAP0) for _ in range(_N_TIMES)]
+
+
+def test_criticality_not_on_root_facade() -> None:
+ """Monitor types stay off the thin root façade."""
+ assert "CriticalityReport" not in koopman_graph.__all__
+ assert "monitor_critical_transition" not in koopman_graph.__all__
+ assert "CriticalityReport" in analysis.__all__
+ assert "monitor_critical_transition" in analysis.__all__
+ assert analysis.CriticalityReport is CriticalityReport
+ assert analysis.monitor_critical_transition is monitor_critical_transition
+
+
+def test_gap_closure_raises_score_versus_stable_control() -> None:
+ """Linearly closing δ_t raises the score relative to a constant gap.
+
+ Independent oracle: K_t = diag(1, 1+δ_t) with δ_t = 1 - t/10 on
+ unit sample times. Window 5 ⇒ (γ_{t-4} - γ_t)/4 = 0.1. The control
+ keeps δ ≡ 1 so the rate is 0.
+ """
+ closing = monitor_critical_transition(_closing_spectra(), window=_WINDOW)
+ control = monitor_critical_transition(_stable_spectra(), window=_WINDOW)
+ expected_gaps = [_GAP0 * (1.0 - step / (_N_TIMES - 1)) for step in range(_N_TIMES)]
+ assert closing.spectral_gap.tolist() == pytest.approx(
+ expected_gaps, rel=_ORACLE_REL, abs=_ORACLE_ABS
+ )
+ assert closing.gap_closure_rate.tolist() == pytest.approx(
+ [_UNIT_CLOSURE_RATE] * (_N_TIMES - _WINDOW + 1),
+ rel=_ORACLE_REL,
+ abs=_ORACLE_ABS,
+ )
+ assert closing.max_gap_closure_rate == pytest.approx(
+ _UNIT_CLOSURE_RATE, rel=_ORACLE_REL, abs=_ORACLE_ABS
+ )
+ assert control.max_gap_closure_rate == pytest.approx(0.0, abs=_ORACLE_ABS)
+ assert closing.max_gap_closure_rate > control.max_gap_closure_rate
+ assert not bool(closing.near_defective.any().item())
+ assert not bool(control.near_defective.any().item())
+
+
+def test_sample_times_scale_the_closure_rate() -> None:
+ """Doubling sample spacing halves the unit-step closure rate."""
+ times = [2.0 * step for step in range(_N_TIMES)]
+ report = monitor_critical_transition(
+ _closing_spectra(),
+ window=_WINDOW,
+ sample_times=times,
+ )
+ assert report.max_gap_closure_rate == pytest.approx(
+ _UNIT_CLOSURE_RATE / 2.0, rel=_ORACLE_REL, abs=_ORACLE_ABS
+ )
+
+
+def test_pairwise_gap_is_not_magnitude_consecutive() -> None:
+ """Closest pair need not be adjacent after |λ|-sort (1, -1, 1.05)."""
+ matrix = torch.diag(torch.tensor([1.0, -1.0, 1.05], dtype=torch.float64))
+ with pytest.warns(UserWarning, match="Nyquist"):
+ spectrum = compute_spectrum(matrix, time_step=1.0)
+ report = monitor_critical_transition([spectrum, spectrum], window=2)
+ assert report.spectral_gap[0].item() == pytest.approx(
+ 0.05, rel=_ORACLE_REL, abs=_ORACLE_ABS
+ )
+
+
+def test_near_defective_flags_singular_eigenbasis_without_raising() -> None:
+ """A zero column sets the flag; the monitor does not raise."""
+ eigenvalues = torch.tensor([1.0 + 0.0j, 0.5 + 0.0j])
+ vectors = torch.tensor(
+ [[1.0 + 0.0j, 0.0 + 0.0j], [0.0 + 0.0j, 0.0 + 0.0j]],
+ )
+ spectrum = KoopmanSpectrum(
+ eigenvalues=eigenvalues,
+ eigenvectors=vectors,
+ magnitudes=eigenvalues.abs(),
+ growth_rates=torch.log(eigenvalues.abs()),
+ frequencies=torch.zeros(2),
+ time_step=1.0,
+ )
+ assert spectrum.diagnostics is None
+ report = monitor_critical_transition([spectrum, spectrum], window=2)
+ assert report.near_defective.tolist() == [True, True]
+
+
+def test_near_defective_uses_stored_diagnostics() -> None:
+ """Stored κ(V)=+∞ flags even when the live basis is well conditioned."""
+ well = _diagonal_spectrum(1.0, 2.0)
+ assert well.diagnostics is not None
+ inf_diag = replace(well.diagnostics, eigenvector_condition=float("inf"))
+ defective = replace(well, diagnostics=inf_diag)
+ report = monitor_critical_transition([defective, well], window=2)
+ assert report.near_defective.tolist() == [True, False]
+
+
+def test_monitor_rejects_invalid_inputs() -> None:
+ """Boundary validation mentions the broken constraint."""
+ pair = _stable_spectra()[:2]
+ with pytest.raises(ValueError, match="nonempty sequence"):
+ monitor_critical_transition([], window=2)
+ with pytest.raises(ValueError, match="window must be an int >= 2"):
+ monitor_critical_transition(pair, window=1)
+ with pytest.raises(ValueError, match="window must be an int >= 2"):
+ monitor_critical_transition(pair, window=True) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="window must be <= len"):
+ monitor_critical_transition(pair, window=3)
+ scalar = compute_spectrum(torch.ones(1, 1, dtype=torch.float64), time_step=1.0)
+ with pytest.raises(ValueError, match="at least two eigenvalues"):
+ monitor_critical_transition([scalar, scalar], window=2)
+ wide = _diagonal_spectrum(1.0, 2.0)
+ tall = compute_spectrum(torch.eye(3, dtype=torch.float64), time_step=1.0)
+ with pytest.raises(ValueError, match="share latent_dim"):
+ monitor_critical_transition([wide, tall], window=2)
+ with pytest.raises(ValueError, match="strictly increasing"):
+ monitor_critical_transition(pair, window=2, sample_times=[0.0, 0.0])
+ with pytest.raises(ValueError, match="sample_times must have length"):
+ monitor_critical_transition(pair, window=2, sample_times=[0.0])
+ with pytest.raises(ValueError, match="sample_times must be finite"):
+ monitor_critical_transition(pair, window=2, sample_times=[0.0, float("nan")])
diff --git a/tests/analysis/test_dispersion.py b/tests/analysis/test_dispersion.py
index 49f49b3..d847aa2 100644
--- a/tests/analysis/test_dispersion.py
+++ b/tests/analysis/test_dispersion.py
@@ -53,7 +53,7 @@ def test_dispersion_rejects_ineligible_and_growth_fallback(
operator = GraphKoopmanOperator(
2, init_mode="identity", adjacency="dual_random_walk"
)
- with pytest.raises(ValueError, match="not eligible"):
+ with pytest.raises(ValueError, match="Dual random-walk|not eligible"):
graph_dispersion(operator, _path_edges(4), 4)
import koopman_graph.analysis.dispersion as disp_mod
diff --git a/tests/analysis/test_hodge_modes.py b/tests/analysis/test_hodge_modes.py
new file mode 100644
index 0000000..baa8538
--- /dev/null
+++ b/tests/analysis/test_hodge_modes.py
@@ -0,0 +1,342 @@
+"""Combinatorial Hodge split of stored Koopman mode shapes."""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+import pytest
+import torch
+
+import koopman_graph
+import koopman_graph.analysis as analysis
+from koopman_graph.analysis.hodge_modes import (
+ DEFAULT_HODGE_ATOL,
+ HodgeModeComponents,
+ hodge_decompose_modes,
+)
+from koopman_graph.observables import boundary_incidence_b1
+from koopman_graph.spectrum_types import KoopmanSpectrum, compute_spectrum
+
+# Independent construction: oriented cycle circulation is ker(B_1).
+_ORACLE_ABS = DEFAULT_HODGE_ATOL
+_CYCLE_N = 6
+_SEED = 0
+
+
+def _cycle_edge_index(num_nodes: int) -> torch.Tensor:
+ """One oriented column per cycle edge ``i → i+1`` (last wraps)."""
+ tails = torch.arange(num_nodes, dtype=torch.long)
+ heads = torch.roll(tails, -1)
+ return torch.stack((tails, heads), dim=0)
+
+
+def _path_edge_index(num_nodes: int) -> torch.Tensor:
+ """Oriented path ``0→1→…→N-1`` (acyclic 1-skeleton)."""
+ tails = torch.arange(num_nodes - 1, dtype=torch.long)
+ heads = tails + 1
+ return torch.stack((tails, heads), dim=0)
+
+
+def _spectrum_from_columns(columns: torch.Tensor) -> KoopmanSpectrum:
+ """Minimal spectrum whose eigenvector columns are the given cochains."""
+ modes = columns.to(dtype=torch.complex128)
+ n_modes = int(modes.shape[1])
+ eigenvalues = torch.ones(n_modes, dtype=torch.complex128)
+ return KoopmanSpectrum(
+ eigenvalues=eigenvalues,
+ eigenvectors=modes,
+ magnitudes=eigenvalues.abs(),
+ growth_rates=torch.zeros(n_modes, dtype=torch.float64),
+ frequencies=torch.zeros(n_modes, dtype=torch.float64),
+ time_step=1.0,
+ )
+
+
+def _relative_energy(part: torch.Tensor, mode: torch.Tensor) -> float:
+ """``||part||_2 / ||mode||_2`` for one column."""
+ mode_norm = float(torch.linalg.vector_norm(mode).item())
+ part_norm = float(torch.linalg.vector_norm(part).item())
+ return part_norm / mode_norm
+
+
+def test_package_and_analysis_export_hodge_modes_off_root() -> None:
+ """Hodge split lives on ``analysis.__all__``, not the root façade."""
+ assert "hodge_decompose_modes" in analysis.__all__
+ assert "HodgeModeComponents" in analysis.__all__
+ assert "hodge_decompose_modes" not in koopman_graph.__all__
+ assert "HodgeModeComponents" not in koopman_graph.__all__
+ assert analysis.hodge_decompose_modes is hodge_decompose_modes
+ assert analysis.HodgeModeComponents is HodgeModeComponents
+
+
+def test_hodge_modes_module_does_not_import_model() -> None:
+ """L2 analysis helper must not import L4 model."""
+ source = Path(__file__).resolve().parents[2] / (
+ "src/koopman_graph/analysis/hodge_modes.py"
+ )
+ tree = ast.parse(source.read_text(encoding="utf-8"))
+ imported: list[str] = []
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ imported.extend(alias.name for alias in node.names)
+ elif isinstance(node, ast.ImportFrom) and node.module is not None:
+ imported.append(node.module)
+ offenders = [
+ name
+ for name in imported
+ if name == "koopman_graph.model"
+ or name.startswith("koopman_graph.model.")
+ or name == "koopman_graph.nn"
+ or name.startswith("koopman_graph.nn.")
+ ]
+ assert not offenders
+
+
+def test_cycle_circulation_is_harmonic_1_cochain() -> None:
+ """All-ones 1-cochain on an oriented cycle is identified as harmonic.
+
+ Independent oracle: column ``e`` of ``B_1`` is ``+1`` at the tail and
+ ``-1`` at the head, so ``B_1 1_E = 0``. The combinatorial 1-Laplacian
+ ``L_1 = B_1^T B_1`` therefore annihilates the circulation. This is
+ not a physical current.
+ """
+ edge_index = _cycle_edge_index(_CYCLE_N)
+ circulation = torch.ones(_CYCLE_N, dtype=torch.float64)
+ spectrum = _spectrum_from_columns(circulation.unsqueeze(1))
+ parts = hodge_decompose_modes(
+ spectrum,
+ edge_index,
+ num_nodes=_CYCLE_N,
+ degree=1,
+ )
+ mode = spectrum.eigenvectors[:, 0]
+ assert _relative_energy(parts.harmonic[:, 0], mode) == pytest.approx(
+ 1.0, abs=_ORACLE_ABS
+ )
+ assert _relative_energy(parts.gradient[:, 0], mode) == pytest.approx(
+ 0.0, abs=_ORACLE_ABS
+ )
+ assert torch.equal(parts.curl, torch.zeros_like(parts.curl))
+ incidence = boundary_incidence_b1(edge_index, num_nodes=_CYCLE_N).to(
+ dtype=torch.float64
+ )
+ laplacian = incidence.T @ incidence
+ leftover = laplacian @ parts.harmonic[:, 0].real
+ assert torch.linalg.vector_norm(leftover).item() == pytest.approx(
+ 0.0, abs=_ORACLE_ABS
+ )
+
+
+def test_cycle_all_ones_without_degree_infers_k0() -> None:
+ """When ``N=E``, omitted ``degree`` infers a 0-cochain split.
+
+ The all-ones vector is harmonic as both ``1_N`` and ``1_E``. The
+ inferred path is ``k=0``: gradient is structurally zero and curl
+ is a least-squares residual, not the structural zero of a
+ face-free ``k=1`` split.
+ """
+ edge_index = _cycle_edge_index(_CYCLE_N)
+ spectrum = _spectrum_from_columns(torch.ones(_CYCLE_N, 1))
+ inferred = hodge_decompose_modes(spectrum, edge_index, num_nodes=_CYCLE_N)
+ forced = hodge_decompose_modes(
+ spectrum,
+ edge_index,
+ num_nodes=_CYCLE_N,
+ degree=1,
+ )
+ assert torch.equal(inferred.gradient, torch.zeros_like(inferred.gradient))
+ assert not torch.equal(inferred.curl, torch.zeros_like(inferred.curl))
+ assert torch.equal(forced.curl, torch.zeros_like(forced.curl))
+ assert not torch.equal(forced.gradient, torch.zeros_like(forced.gradient))
+ mode = spectrum.eigenvectors[:, 0]
+ assert _relative_energy(inferred.harmonic[:, 0], mode) == pytest.approx(
+ 1.0, abs=_ORACLE_ABS
+ )
+ assert _relative_energy(inferred.curl[:, 0], mode) == pytest.approx(
+ 0.0, abs=_ORACLE_ABS
+ )
+
+
+def test_cycle_gradient_flow_is_not_harmonic() -> None:
+ """``B_1^T φ`` on a cycle is a gradient 1-cochain, not harmonic."""
+ edge_index = _cycle_edge_index(_CYCLE_N)
+ incidence = boundary_incidence_b1(edge_index, num_nodes=_CYCLE_N).to(
+ dtype=torch.float64
+ )
+ potential = torch.tensor(
+ [1.0, -0.5, 0.25, 0.0, -0.75, 1.0],
+ dtype=torch.float64,
+ )
+ flow = incidence.T @ potential
+ spectrum = _spectrum_from_columns(flow.unsqueeze(1))
+ parts = hodge_decompose_modes(
+ spectrum,
+ edge_index,
+ num_nodes=_CYCLE_N,
+ degree=1,
+ )
+ mode = spectrum.eigenvectors[:, 0]
+ assert _relative_energy(parts.gradient[:, 0], mode) == pytest.approx(
+ 1.0, abs=_ORACLE_ABS
+ )
+ assert _relative_energy(parts.harmonic[:, 0], mode) == pytest.approx(
+ 0.0, abs=_ORACLE_ABS
+ )
+ reconstructed = parts.gradient + parts.curl + parts.harmonic
+ assert torch.allclose(
+ reconstructed,
+ spectrum.eigenvectors,
+ atol=_ORACLE_ABS,
+ rtol=0.0,
+ )
+
+
+def test_compute_spectrum_cycle_mode_is_harmonic() -> None:
+ """Largest-magnitude eigenmode of a cycle circulation map is harmonic."""
+ torch.manual_seed(_SEED)
+ edge_index = _cycle_edge_index(_CYCLE_N)
+ raw = torch.randn(_CYCLE_N, _CYCLE_N, dtype=torch.float64)
+ raw[:, 0] = 1.0
+ basis, _ = torch.linalg.qr(raw)
+ eigenvalues = torch.tensor(
+ [0.9, 0.5, 0.3, 0.2, 0.1, 0.05],
+ dtype=torch.float64,
+ )
+ operator = basis @ torch.diag(eigenvalues) @ basis.T
+ spectrum = compute_spectrum(operator, time_step=1.0)
+ parts = hodge_decompose_modes(
+ spectrum,
+ edge_index,
+ num_nodes=_CYCLE_N,
+ degree=1,
+ )
+ leading = spectrum.eigenvectors[:, 0]
+ assert _relative_energy(parts.harmonic[:, 0], leading) == pytest.approx(
+ 1.0, abs=_ORACLE_ABS
+ )
+ assert _relative_energy(parts.gradient[:, 0], leading) == pytest.approx(
+ 0.0, abs=_ORACLE_ABS
+ )
+
+
+def test_path_ones_1_cochain_is_gradient() -> None:
+ """An acyclic path has trivial ``ker L_1``; all-ones is gradient."""
+ num_nodes = 4
+ edge_index = _path_edge_index(num_nodes)
+ ones = torch.ones(num_nodes - 1, dtype=torch.float64)
+ spectrum = _spectrum_from_columns(ones.unsqueeze(1))
+ parts = hodge_decompose_modes(
+ spectrum,
+ edge_index,
+ num_nodes=num_nodes,
+ )
+ mode = spectrum.eigenvectors[:, 0]
+ assert _relative_energy(parts.harmonic[:, 0], mode) == pytest.approx(
+ 0.0, abs=_ORACLE_ABS
+ )
+ assert _relative_energy(parts.gradient[:, 0], mode) == pytest.approx(
+ 1.0, abs=_ORACLE_ABS
+ )
+
+
+def test_constant_0_cochain_is_harmonic_on_connected_graph() -> None:
+ """Constants span ``ker L_0``; a mean-zero node vector is curl."""
+ edge_index = _cycle_edge_index(_CYCLE_N)
+ constant = torch.ones(_CYCLE_N, dtype=torch.float64)
+ incidence = boundary_incidence_b1(edge_index, num_nodes=_CYCLE_N).to(
+ dtype=torch.float64
+ )
+ flow = torch.arange(_CYCLE_N, dtype=torch.float64)
+ mean_zero = incidence @ flow
+ spectrum = _spectrum_from_columns(torch.stack((constant, mean_zero), dim=1))
+ parts = hodge_decompose_modes(
+ spectrum,
+ edge_index,
+ num_nodes=_CYCLE_N,
+ degree=0,
+ )
+ assert _relative_energy(
+ parts.harmonic[:, 0],
+ spectrum.eigenvectors[:, 0],
+ ) == pytest.approx(1.0, abs=_ORACLE_ABS)
+ assert _relative_energy(
+ parts.curl[:, 0],
+ spectrum.eigenvectors[:, 0],
+ ) == pytest.approx(0.0, abs=_ORACLE_ABS)
+ assert _relative_energy(
+ parts.curl[:, 1],
+ spectrum.eigenvectors[:, 1],
+ ) == pytest.approx(1.0, abs=_ORACLE_ABS)
+ assert _relative_energy(
+ parts.harmonic[:, 1],
+ spectrum.eigenvectors[:, 1],
+ ) == pytest.approx(0.0, abs=_ORACLE_ABS)
+ assert torch.equal(parts.gradient, torch.zeros_like(parts.gradient))
+
+
+def test_complex_circulation_splits_real_and_imag_independently() -> None:
+ """Real combinatorial Hodge is applied to each part of a complex column."""
+ edge_index = _cycle_edge_index(_CYCLE_N)
+ circulation = (1.0 + 2.0j) * torch.ones(_CYCLE_N, dtype=torch.complex128)
+ spectrum = _spectrum_from_columns(circulation.unsqueeze(1))
+ parts = hodge_decompose_modes(
+ spectrum,
+ edge_index,
+ num_nodes=_CYCLE_N,
+ degree=1,
+ )
+ mode = spectrum.eigenvectors[:, 0]
+ assert _relative_energy(parts.harmonic[:, 0], mode) == pytest.approx(
+ 1.0, abs=_ORACLE_ABS
+ )
+ assert torch.equal(parts.curl, torch.zeros_like(parts.curl))
+ assert _relative_energy(parts.gradient[:, 0], mode) == pytest.approx(
+ 0.0, abs=_ORACLE_ABS
+ )
+
+
+def test_hodge_decompose_modes_rejects_invalid_inputs() -> None:
+ """Row-count, degree, and non-finite columns raise ``ValueError``."""
+ edge_index = _cycle_edge_index(_CYCLE_N)
+ valid = _spectrum_from_columns(torch.ones(_CYCLE_N, 1))
+ with pytest.raises(TypeError, match="KoopmanSpectrum"):
+ hodge_decompose_modes(
+ object(), # type: ignore[arg-type]
+ edge_index,
+ num_nodes=_CYCLE_N,
+ )
+ with pytest.raises(ValueError, match="num_nodes"):
+ hodge_decompose_modes(valid, edge_index, num_nodes=True)
+ with pytest.raises(ValueError, match="infer"):
+ flat = _spectrum_from_columns(torch.ones(_CYCLE_N * 2, 1))
+ hodge_decompose_modes(flat, edge_index, num_nodes=_CYCLE_N)
+ with pytest.raises(ValueError, match="k=2"):
+ hodge_decompose_modes(valid, edge_index, num_nodes=_CYCLE_N, degree=2)
+ with pytest.raises(ValueError, match="degree=1 requires"):
+ nodes = _spectrum_from_columns(torch.ones(_CYCLE_N, 1))
+ hodge_decompose_modes(
+ nodes,
+ _path_edge_index(_CYCLE_N),
+ num_nodes=_CYCLE_N,
+ degree=1,
+ )
+ nan_columns = torch.ones(_CYCLE_N, 1, dtype=torch.complex128)
+ nan_columns[0, 0] = torch.nan
+ with pytest.raises(ValueError, match="finite"):
+ hodge_decompose_modes(
+ _spectrum_from_columns(nan_columns),
+ edge_index,
+ num_nodes=_CYCLE_N,
+ )
+
+
+def test_hodge_mode_components_require_matching_shapes() -> None:
+ """Frozen report rejects mismatched gradient / curl / harmonic blocks."""
+ good = torch.zeros(3, 2)
+ with pytest.raises(ValueError, match="share shape"):
+ HodgeModeComponents(
+ gradient=good,
+ curl=torch.zeros(3, 1),
+ harmonic=good,
+ )
diff --git a/tests/analysis/test_implied_timescales.py b/tests/analysis/test_implied_timescales.py
index c52935c..a20bc86 100644
--- a/tests/analysis/test_implied_timescales.py
+++ b/tests/analysis/test_implied_timescales.py
@@ -20,6 +20,7 @@ def test_analytic_diagonal_oracle_in_steps() -> None:
assert report.tau == 1.0
assert report.timestep is None
assert report.valid.tolist() == [True, True, False, False]
+ assert report.aliasing_warning.tolist() == [False, False, False, False]
expected_0 = -1.0 / math.log(0.9)
expected_1 = -1.0 / math.log(0.5)
@@ -65,10 +66,27 @@ def test_near_unit_and_near_zero_marked_invalid() -> None:
)
report = implied_timescales(eigenvalues, lag_steps=3, magnitude_atol=eps)
assert report.valid.tolist() == [False, False, False, True]
+ assert report.aliasing_warning.tolist() == [False, False, False, True]
assert all(math.isinf(float(v)) for v in report.timescales[:3].tolist())
assert report.timescales[3].item() == pytest.approx(-3.0 / math.log(0.7))
+def test_negative_and_positive_share_timescale_only_negative_flags() -> None:
+ """λ=-0.9 flags aliasing; λ=+0.9 does not; both remain valid.
+
+ Independent oracle: t = -1 / ln(0.9) for both magnitudes.
+ """
+ report = implied_timescales(
+ torch.tensor([-0.9, 0.9], dtype=torch.float64),
+ lag_steps=1,
+ )
+ assert report.aliasing_warning.tolist() == [True, False]
+ assert report.valid.tolist() == [True, True]
+ expected = -1.0 / math.log(0.9)
+ assert report.timescales[0].item() == pytest.approx(expected)
+ assert report.timescales[1].item() == pytest.approx(expected)
+
+
def test_argument_validation() -> None:
"""Inconsistent lag / timestep / unit arguments raise clearly."""
eigenvalues = torch.tensor([0.5], dtype=torch.float64)
diff --git a/tests/analysis/test_memory.py b/tests/analysis/test_memory.py
new file mode 100644
index 0000000..e314a4b
--- /dev/null
+++ b/tests/analysis/test_memory.py
@@ -0,0 +1,280 @@
+"""Innovation-whiteness diagnostic and finite-memory convolution tests."""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+import pytest
+import torch
+from torch import nn
+
+import koopman_graph
+import koopman_graph.analysis as analysis
+from koopman_graph.analysis.memory import (
+ DEFAULT_ALPHA,
+ LAG_UNIT,
+ FiniteMemoryKoopman,
+ MarkovClosureReport,
+ markov_closure_report,
+)
+from koopman_graph.model.factory import parse_koopman_arg
+from koopman_graph.nn.delay import DelayEmbeddingEncoder
+
+# Independent AR(2) construction: residual energy of a known memory tap.
+_ORACLE_REL = 1e-6
+_ORACLE_ABS = 1e-8
+_SERIES_LEN = 512
+_MAX_LAG = 8
+_LATENT_DIM = 2
+_MEMORY_ORDER = 1
+_SEED = 0
+# 4 / sqrt(T) is a conservative envelope on |ρ̂| for i.i.d. N(0, 1).
+_WHITE_ACF_BOUND = 4.0 / (_SERIES_LEN**0.5)
+# Colored residuals of the hidden-memory oracle reject far below 5%.
+_COLORED_P_CEILING = 1e-8
+# Negative-control α is loose so a 5% portmanteau false reject cannot flake.
+_WHITE_ALPHA = 1e-4
+
+
+def _hidden_memory_trajectory() -> tuple[
+ torch.Tensor, torch.Tensor, FiniteMemoryKoopman
+]:
+ """Latent AR(2) driven by a known convolution plus white noise.
+
+ Returns
+ -------
+ tuple[Tensor, Tensor, FiniteMemoryKoopman]
+ Latents ``(T, d)``, injected noise ``(T - 1, d)``, and the
+ operator that generated the Markov-plus-memory step.
+ """
+ torch.manual_seed(_SEED)
+ operator = FiniteMemoryKoopman(
+ latent_dim=_LATENT_DIM,
+ memory_order=_MEMORY_ORDER,
+ )
+ with torch.no_grad():
+ operator.omega.copy_(0.5 * torch.eye(_LATENT_DIM))
+ operator.kernels.copy_(0.35 * torch.eye(_LATENT_DIM).unsqueeze(0))
+ noise = 0.15 * torch.randn(_SERIES_LEN - 1, _LATENT_DIM)
+ latents = torch.zeros(_SERIES_LEN, _LATENT_DIM)
+ latents[0] = torch.randn(_LATENT_DIM)
+ for step in range(_SERIES_LEN - 1):
+ if step == 0:
+ history = latents[0].unsqueeze(0)
+ else:
+ history = latents[step - 1].unsqueeze(0)
+ latents[step + 1] = operator.advance(latents[step], history) + noise[step]
+ return latents, noise, operator
+
+
+def test_package_and_analysis_export_memory_off_root() -> None:
+ """Memory helpers live on ``analysis.__all__``, not the root façade."""
+ assert "markov_closure_report" in analysis.__all__
+ assert "MarkovClosureReport" in analysis.__all__
+ assert "FiniteMemoryKoopman" in analysis.__all__
+ assert "markov_closure_report" not in koopman_graph.__all__
+ assert "FiniteMemoryKoopman" not in koopman_graph.__all__
+ assert analysis.markov_closure_report is markov_closure_report
+ assert analysis.FiniteMemoryKoopman is FiniteMemoryKoopman
+
+
+def test_memory_module_does_not_import_model() -> None:
+ """L2 analysis helper must not import L4 model."""
+ source = Path(__file__).resolve().parents[2] / (
+ "src/koopman_graph/analysis/memory.py"
+ )
+ tree = ast.parse(source.read_text(encoding="utf-8"))
+ imported: list[str] = []
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ imported.extend(alias.name for alias in node.names)
+ elif isinstance(node, ast.ImportFrom) and node.module is not None:
+ imported.append(node.module)
+ offenders = [
+ name
+ for name in imported
+ if name == "koopman_graph.model" or name.startswith("koopman_graph.model.")
+ ]
+ assert not offenders
+
+
+def test_memory_does_not_import_scipy_at_module_level() -> None:
+ """χ² is function-local so CLI and docs extras do not need SciPy."""
+ source = Path(__file__).resolve().parents[2] / (
+ "src/koopman_graph/analysis/memory.py"
+ )
+ tree = ast.parse(source.read_text(encoding="utf-8"))
+ offenders: list[str] = []
+ for node in tree.body:
+ if isinstance(node, ast.Import):
+ offenders.extend(
+ alias.name
+ for alias in node.names
+ if alias.name == "scipy" or alias.name.startswith("scipy.")
+ )
+ elif isinstance(node, ast.ImportFrom) and node.module is not None:
+ module = node.module
+ if module == "scipy" or module.startswith("scipy."):
+ offenders.append(module)
+ assert not offenders
+
+
+def test_markov_closure_report_requires_scipy(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """The χ² tail is gated on SciPy; the CLI extra must stay importable."""
+ import builtins
+
+ real_import = builtins.__import__
+
+ def blocked(name: str, *args: object, **kwargs: object) -> object:
+ if name == "scipy" or name.startswith("scipy."):
+ raise ImportError("blocked scipy")
+ return real_import(name, *args, **kwargs)
+
+ monkeypatch.setattr(builtins, "__import__", blocked)
+ with pytest.raises(ImportError, match="requires SciPy"):
+ markov_closure_report(torch.randn(_SERIES_LEN), max_lag=_MAX_LAG)
+
+
+def test_white_innovations_do_not_flag_markov_mismatch() -> None:
+ """i.i.d. residual energy fails to reject whiteness at a loose α."""
+ torch.manual_seed(_SEED)
+ innovations = torch.randn(_SERIES_LEN)
+ report = markov_closure_report(
+ innovations,
+ max_lag=_MAX_LAG,
+ alpha=_WHITE_ALPHA,
+ )
+ assert report.white
+ assert report.lag_unit == LAG_UNIT
+ assert report.max_lag == _MAX_LAG
+ assert report.n_timesteps == _SERIES_LEN
+ assert report.max_abs_autocorrelation < _WHITE_ACF_BOUND
+ assert report.ljung_box_pvalue > _WHITE_ALPHA
+
+
+def test_hidden_memory_oracle_flags_markov_mismatch() -> None:
+ """Markov residuals of an AR(2) latent fail the whiteness flag.
+
+ The process is generated by :class:`FiniteMemoryKoopman` plus white
+ noise. Subtracting only the Markov term leaves the memory tap in
+ the residual, so Ljung–Box must reject. Injected noise is the
+ negative control.
+ """
+ latents, noise, operator = _hidden_memory_trajectory()
+ markov_residuals = latents[1:] - latents[:-1] @ operator.omega.detach().T
+ colored = markov_closure_report(
+ markov_residuals,
+ max_lag=_MAX_LAG,
+ alpha=DEFAULT_ALPHA,
+ )
+ white = markov_closure_report(
+ noise,
+ max_lag=_MAX_LAG,
+ alpha=_WHITE_ALPHA,
+ )
+ assert colored.white is False
+ assert colored.ljung_box_pvalue < _COLORED_P_CEILING
+ assert white.white is True
+ assert white.ljung_box_pvalue > colored.ljung_box_pvalue
+ assert colored.max_abs_autocorrelation > white.max_abs_autocorrelation
+
+
+def test_convolution_matches_independent_ar2_step() -> None:
+ """One step equals ``z Ωᵀ + z_{t-1} K₁ᵀ`` at tight float64 tolerance."""
+ operator = FiniteMemoryKoopman(latent_dim=3, memory_order=1).double()
+ with torch.no_grad():
+ operator.omega.copy_(
+ torch.diag(torch.tensor([0.4, -0.2, 0.1], dtype=torch.float64))
+ )
+ operator.kernels.copy_(
+ torch.diag(
+ torch.tensor([0.25, 0.15, -0.05], dtype=torch.float64)
+ ).unsqueeze(0)
+ )
+ current = torch.tensor([[1.0, -0.5, 0.25], [0.0, 0.75, -1.0]], dtype=torch.float64)
+ previous = torch.tensor([[0.5, 0.0, 1.0], [-0.25, 0.5, 0.0]], dtype=torch.float64)
+ expected = (
+ current @ operator.omega.detach().T + previous @ operator.kernels.detach()[0].T
+ )
+ got = operator.advance(current, previous.unsqueeze(0))
+ torch.testing.assert_close(got, expected, rtol=_ORACLE_REL, atol=_ORACLE_ABS)
+ torch.testing.assert_close(
+ operator(current, previous.unsqueeze(0)),
+ expected,
+ rtol=_ORACLE_REL,
+ atol=_ORACLE_ABS,
+ )
+
+
+def test_delay_encoder_matched_width_is_not_memory() -> None:
+ """Matched latent width does not make delay embedding a memory kernel."""
+
+ class _TinyEncoder(nn.Module):
+ def __init__(self) -> None:
+ super().__init__()
+ self.in_channels = 6
+ self.latent_dim = 4
+
+ def forward(
+ self,
+ x: torch.Tensor,
+ edge_index: torch.Tensor,
+ edge_weight: torch.Tensor | None = None,
+ ) -> torch.Tensor:
+ del edge_index, edge_weight
+ return x.new_zeros(x.size(0), self.latent_dim)
+
+ delay = DelayEmbeddingEncoder(_TinyEncoder(), n_delays=2)
+ memory = FiniteMemoryKoopman(latent_dim=4, memory_order=2)
+ assert delay.latent_dim == memory.latent_dim
+ assert delay.in_channels == delay.n_delays * delay.feature_dim
+ assert memory.kernels.shape == (2, 4, 4)
+ with pytest.raises(ValueError, match="finite_memory"):
+ parse_koopman_arg("finite_memory")
+
+
+def test_report_and_operator_reject_invalid_inputs() -> None:
+ """Boundary validation names the broken constraint."""
+ series = torch.randn(40)
+ with pytest.raises(ValueError, match="nonempty"):
+ markov_closure_report(torch.empty(0))
+ with pytest.raises(ValueError, match="finite"):
+ markov_closure_report(torch.tensor([1.0, float("nan"), 0.0]))
+ with pytest.raises(ValueError, match="real"):
+ markov_closure_report(torch.ones(8, dtype=torch.complex64))
+ with pytest.raises(ValueError, match="positive residual energy"):
+ markov_closure_report(torch.ones(20))
+ with pytest.raises(ValueError, match="alpha"):
+ markov_closure_report(series, alpha=0.0)
+ with pytest.raises(ValueError, match="max_lag"):
+ markov_closure_report(series, max_lag=0)
+ with pytest.raises(ValueError, match="n_timesteps must exceed"):
+ markov_closure_report(series[:5], max_lag=8)
+ with pytest.raises(ValueError, match="n_fit_parameters"):
+ markov_closure_report(series, max_lag=4, n_fit_parameters=4)
+ with pytest.raises(ValueError, match="latent_dim"):
+ FiniteMemoryKoopman(latent_dim=0)
+ with pytest.raises(ValueError, match="memory_order"):
+ FiniteMemoryKoopman(latent_dim=2, memory_order=0)
+ operator = FiniteMemoryKoopman(latent_dim=2, memory_order=1)
+ z = torch.ones(3, 2)
+ with pytest.raises(ValueError, match="history"):
+ operator.advance(z, torch.ones(1, 2))
+ with pytest.raises(ValueError, match="trailing dimension"):
+ operator.advance(torch.ones(3, 3), torch.ones(1, 3, 3))
+ with pytest.raises(ValueError, match="lag_unit"):
+ MarkovClosureReport(
+ autocorrelation=torch.zeros(2),
+ ljung_box_statistic=1.0,
+ ljung_box_pvalue=0.5,
+ max_abs_autocorrelation=0.1,
+ max_lag=2,
+ n_timesteps=20,
+ n_fit_parameters=0,
+ alpha=0.05,
+ white=True,
+ lag_unit="second",
+ )
diff --git a/tests/analysis/test_spectral_diagnostics.py b/tests/analysis/test_spectral_diagnostics.py
new file mode 100644
index 0000000..aad77bf
--- /dev/null
+++ b/tests/analysis/test_spectral_diagnostics.py
@@ -0,0 +1,378 @@
+"""Tests for SpectralDiagnostics attached to compute_spectrum."""
+
+from __future__ import annotations
+
+import math
+import warnings
+from dataclasses import replace
+
+import pytest
+import torch
+
+import koopman_graph
+import koopman_graph.analysis as analysis
+from koopman_graph.spectrum_types import (
+ ALIASING_ARG_ATOL,
+ CONDITION_WARN,
+ DefectiveSpectrumError,
+ KoopmanSpectrum,
+ SpectralDiagnostics,
+ compute_generator_spectrum,
+ compute_spectral_diagnostics,
+ compute_spectrum,
+)
+
+# Closed-form 2×2 triangular toy: eigenvalues 1 and 2; both κ_i = sqrt(1+a²).
+_NONNORMAL_SHEAR = 10.0
+_NONNORMAL_KAPPA = math.sqrt(1.0 + _NONNORMAL_SHEAR**2)
+_NONNORMAL_DEPARTURE = _NONNORMAL_SHEAR * math.sqrt(2.0 * (_NONNORMAL_SHEAR**2 + 1.0))
+_NONNORMAL_RELATIVE = _NONNORMAL_DEPARTURE / (_NONNORMAL_SHEAR**2 + 5.0)
+# Independent algebraic identities on float64 eigendecomposition.
+_ORACLE_REL = 1e-10
+_ORACLE_ABS = 1e-12
+_NORMAL_ABS = 1e-8
+
+
+def _nonnormal_operator() -> torch.Tensor:
+ return torch.tensor(
+ [[1.0, _NONNORMAL_SHEAR], [0.0, 2.0]],
+ dtype=torch.float64,
+ )
+
+
+def test_spectral_diagnostics_not_on_root_facade() -> None:
+ """Conditioning types stay off the thin root façade."""
+ assert "SpectralDiagnostics" not in koopman_graph.__all__
+ assert "compute_spectral_diagnostics" not in koopman_graph.__all__
+ assert "DefectiveSpectrumError" not in koopman_graph.__all__
+ assert "CONDITION_WARN" not in koopman_graph.__all__
+ assert "SpectralDiagnostics" in analysis.__all__
+ assert "compute_spectral_diagnostics" in analysis.__all__
+ assert "DefectiveSpectrumError" in analysis.__all__
+ assert "CONDITION_WARN" in analysis.__all__
+ assert analysis.SpectralDiagnostics is SpectralDiagnostics
+ assert analysis.compute_spectral_diagnostics is compute_spectral_diagnostics
+ assert analysis.DefectiveSpectrumError is DefectiveSpectrumError
+ assert analysis.CONDITION_WARN == CONDITION_WARN == 1e6
+
+
+def test_compute_spectrum_attaches_diagnostics() -> None:
+ """Discrete assembly always fills optional diagnostics."""
+ spectrum = compute_spectrum(torch.eye(2, dtype=torch.float64), time_step=1.0)
+ assert spectrum.diagnostics is not None
+ assert isinstance(spectrum.diagnostics, SpectralDiagnostics)
+
+
+def test_identity_is_normal_with_unit_condition() -> None:
+ """The identity is normal: κ(V)≈1, κ_i≈1, both departures ≈0."""
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", UserWarning)
+ spectrum = compute_spectrum(torch.eye(3, dtype=torch.float64), time_step=1.0)
+ diag = spectrum.diagnostics
+ assert diag is not None
+ assert diag.eigenvector_condition == pytest.approx(1.0, rel=1e-6, abs=_NORMAL_ABS)
+ assert diag.eigenvalue_sensitivities.tolist() == pytest.approx(
+ [1.0, 1.0, 1.0],
+ rel=1e-6,
+ abs=_NORMAL_ABS,
+ )
+ assert diag.departure_from_normality == pytest.approx(0.0, abs=_NORMAL_ABS)
+ assert diag.departure_from_normality_relative == pytest.approx(0.0, abs=_NORMAL_ABS)
+ assert diag.nyquist_frequency == pytest.approx(
+ 0.5, rel=_ORACLE_REL, abs=_ORACLE_ABS
+ )
+ assert diag.aliasing_warning.tolist() == [False, False, False]
+ assert diag.sign_of_eigenvalue.tolist() == pytest.approx([1.0, 1.0, 1.0])
+
+
+def test_hermitian_matrix_is_normal() -> None:
+ """A real symmetric map is unitarily diagonalizable (departure ≈0)."""
+ operator = torch.tensor([[2.0, 1.0], [1.0, 2.0]], dtype=torch.float64)
+ spectrum = compute_spectrum(operator, time_step=0.5)
+ diag = spectrum.diagnostics
+ assert diag is not None
+ assert diag.eigenvector_condition == pytest.approx(1.0, rel=1e-5, abs=_NORMAL_ABS)
+ assert diag.eigenvalue_sensitivities.tolist() == pytest.approx(
+ [1.0, 1.0],
+ rel=1e-5,
+ abs=_NORMAL_ABS,
+ )
+ assert diag.departure_from_normality == pytest.approx(0.0, abs=_NORMAL_ABS)
+ assert diag.departure_from_normality_relative == pytest.approx(0.0, abs=_NORMAL_ABS)
+ assert diag.nyquist_frequency == pytest.approx(
+ 1.0, rel=_ORACLE_REL, abs=_ORACLE_ABS
+ )
+ assert not bool(diag.aliasing_warning.any().item())
+
+
+def test_nonnormal_triangular_matches_closed_form() -> None:
+ """Shear [[1, a], [0, 2]] has κ_i=sqrt(1+a²) and known Frobenius departure.
+
+ Algebraic identities for this 2×2 (not a literature table). Tolerances
+ are independent-oracle float64 residuals after ``torch.linalg.eig``.
+ """
+ operator = _nonnormal_operator()
+ spectrum = compute_spectrum(operator, time_step=1.0)
+ diag = spectrum.diagnostics
+ assert diag is not None
+ expected = [_NONNORMAL_KAPPA, _NONNORMAL_KAPPA]
+ assert diag.eigenvalue_sensitivities.tolist() == pytest.approx(
+ expected, rel=_ORACLE_REL, abs=_ORACLE_ABS
+ )
+ assert diag.departure_from_normality == pytest.approx(
+ _NONNORMAL_DEPARTURE, rel=_ORACLE_REL, abs=_ORACLE_ABS
+ )
+ assert diag.departure_from_normality_relative == pytest.approx(
+ _NONNORMAL_RELATIVE, rel=_ORACLE_REL, abs=_ORACLE_ABS
+ )
+ assert diag.eigenvector_condition > 1.0
+ vectors = spectrum.eigenvectors
+ col_norms = torch.linalg.vector_norm(vectors, dim=0)
+ unit = vectors / col_norms.to(dtype=vectors.dtype)
+ oracle_cond = float(torch.linalg.cond(unit).real.item())
+ assert diag.eigenvector_condition == pytest.approx(
+ oracle_cond, rel=_ORACLE_REL, abs=_ORACLE_ABS
+ )
+
+
+def test_zero_matrix_relative_departure_is_zero() -> None:
+ """The zero map is normal; relative departure is defined as 0."""
+ spectrum = compute_spectrum(torch.zeros(2, 2, dtype=torch.float64), time_step=1.0)
+ diag = spectrum.diagnostics
+ assert diag is not None
+ assert diag.departure_from_normality == pytest.approx(0.0, abs=_NORMAL_ABS)
+ assert diag.departure_from_normality_relative == pytest.approx(0.0, abs=_NORMAL_ABS)
+ assert diag.sign_of_eigenvalue.tolist() == pytest.approx([0.0, 0.0])
+ assert not bool(diag.aliasing_warning.any().item())
+
+
+def test_generator_spectrum_populates_diagnostics() -> None:
+ """Continuous assembly uses the same diagnostic helper."""
+ generator = torch.tensor([[-1.0, 0.2], [0.2, -2.0]], dtype=torch.float64)
+ spectrum = compute_generator_spectrum(generator)
+ assert spectrum.diagnostics is not None
+ assert spectrum.diagnostics.departure_from_normality == pytest.approx(
+ 0.0, abs=_NORMAL_ABS
+ )
+
+
+def test_koopman_spectrum_without_diagnostics_remains_valid() -> None:
+ """Manual constructors stay backward compatible (diagnostics optional)."""
+ eigenvalues = torch.tensor([0.5 + 0.0j, 0.1 + 0.0j])
+ spectrum = KoopmanSpectrum(
+ eigenvalues=eigenvalues,
+ eigenvectors=torch.eye(2, dtype=torch.complex128),
+ magnitudes=eigenvalues.abs(),
+ growth_rates=torch.log(eigenvalues.abs()),
+ frequencies=torch.zeros(2),
+ time_step=1.0,
+ )
+ assert spectrum.diagnostics is None
+
+
+def test_singular_eigenvectors_yield_infinite_condition() -> None:
+ """A zero column in V cannot be normalized; departure is still finite."""
+ matrix = torch.eye(2, dtype=torch.float64)
+ vectors = torch.tensor(
+ [[1.0 + 0.0j, 0.0 + 0.0j], [0.0 + 0.0j, 0.0 + 0.0j]],
+ )
+ eigenvalues = torch.tensor([1.0 + 0.0j, 0.5 + 0.0j])
+ diag = compute_spectral_diagnostics(
+ matrix,
+ vectors,
+ eigenvalues=eigenvalues,
+ time_step=1.0,
+ )
+ assert math.isinf(diag.eigenvector_condition)
+ assert torch.isinf(diag.eigenvalue_sensitivities).all()
+ assert math.isfinite(diag.departure_from_normality)
+ assert diag.nyquist_frequency == pytest.approx(
+ 0.5, rel=_ORACLE_REL, abs=_ORACLE_ABS
+ )
+ assert diag.aliasing_warning.tolist() == [False, False]
+ assert diag.sign_of_eigenvalue.tolist() == pytest.approx([1.0, 1.0])
+
+
+def test_negative_real_eigenvalue_flags_nyquist_aliasing() -> None:
+ """Diagonal λ=-1 is at Nyquist; discrete assembly warns once.
+
+ Independent oracle: frequency = arg(λ)/(2π Δt) = 1/(2 Δt).
+ """
+ time_step = 0.25
+ expected_nyquist = 1.0 / (2.0 * time_step)
+ operator = torch.diag(torch.tensor([-1.0, 0.5], dtype=torch.float64))
+ with pytest.warns(UserWarning, match="Nyquist"):
+ spectrum = compute_spectrum(operator, time_step=time_step)
+ diag = spectrum.diagnostics
+ assert diag is not None
+ assert diag.nyquist_frequency == pytest.approx(
+ expected_nyquist, rel=_ORACLE_REL, abs=_ORACLE_ABS
+ )
+ # |λ| sort: 1 then 0.5.
+ assert diag.aliasing_warning.tolist() == [True, False]
+ assert diag.sign_of_eigenvalue.tolist() == pytest.approx([-1.0, 1.0])
+ assert spectrum.frequencies[0].item() == pytest.approx(
+ expected_nyquist, rel=_ORACLE_REL, abs=_ORACLE_ABS
+ )
+ assert spectrum.frequencies[1].item() == pytest.approx(0.0, abs=_ORACLE_ABS)
+
+
+def test_opposite_real_signs_share_log_magnitude_growth() -> None:
+ """λ=+0.9 and λ=-0.9 share log|λ| rates; only the negative-real flags."""
+ operator = torch.diag(torch.tensor([0.9, -0.9], dtype=torch.float64))
+ with pytest.warns(UserWarning, match="Nyquist"):
+ spectrum = compute_spectrum(operator, time_step=1.0)
+ diag = spectrum.diagnostics
+ assert diag is not None
+ expected_rate = math.log(0.9)
+ assert spectrum.growth_rates.tolist() == pytest.approx(
+ [expected_rate, expected_rate],
+ rel=_ORACLE_REL,
+ abs=_ORACLE_ABS,
+ )
+ real = spectrum.eigenvalues.real
+ positive = real > 0
+ negative = real < 0
+ assert int(positive.sum().item()) == 1
+ assert int(negative.sum().item()) == 1
+ assert not bool(diag.aliasing_warning[positive].item())
+ assert bool(diag.aliasing_warning[negative].item())
+ assert diag.sign_of_eigenvalue[positive].item() == pytest.approx(1.0)
+ assert diag.sign_of_eigenvalue[negative].item() == pytest.approx(-1.0)
+ assert diag.nyquist_frequency == pytest.approx(
+ 0.5, rel=_ORACLE_REL, abs=_ORACLE_ABS
+ )
+
+
+def test_generator_spectrum_has_no_nyquist() -> None:
+ """Generator placeholder Δt is not a sampling interval."""
+ generator = torch.diag(torch.tensor([-1.0, -2.0], dtype=torch.float64))
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", UserWarning)
+ spectrum = compute_generator_spectrum(generator)
+ diag = spectrum.diagnostics
+ assert diag is not None
+ assert diag.nyquist_frequency is None
+ assert diag.aliasing_warning.tolist() == [False, False]
+ assert diag.sign_of_eigenvalue.tolist() == pytest.approx([-1.0, -1.0])
+
+
+def test_frequency_units_and_related_sampling_citation() -> None:
+ """Nyquist docs use cycles per unit time; Zeng is related literature."""
+ doc = SpectralDiagnostics.__doc__
+ assert doc is not None
+ assert "cycles per unit time" in doc
+ assert "Zeng2022Sampling" in doc
+ assert "does not implement that identification theorem" in doc
+ assert ALIASING_ARG_ATOL == 1e-3
+
+
+def test_compute_spectral_diagnostics_validates_eigenvalues_and_time_step() -> None:
+ """Helper rejects mismatched eigenvalues and non-positive Δt."""
+ matrix = torch.eye(2, dtype=torch.float64)
+ vectors = torch.eye(2, dtype=torch.complex128)
+ with pytest.raises(ValueError, match="eigenvalues must have shape"):
+ compute_spectral_diagnostics(
+ matrix,
+ vectors,
+ eigenvalues=torch.ones(3, dtype=torch.float64),
+ time_step=1.0,
+ )
+ with pytest.raises(ValueError, match="time_step must be a finite value > 0"):
+ compute_spectral_diagnostics(
+ matrix,
+ vectors,
+ eigenvalues=torch.ones(2, dtype=torch.float64),
+ time_step=0.0,
+ )
+
+
+def _spectrum_from_vectors(vectors: torch.Tensor) -> KoopmanSpectrum:
+ """Build a spectrum with prescribed eigenvectors and no diagnostics."""
+ n = int(vectors.shape[0])
+ eigenvalues = torch.ones(n, dtype=torch.complex128)
+ return KoopmanSpectrum(
+ eigenvalues=eigenvalues,
+ eigenvectors=vectors.to(dtype=torch.complex128),
+ magnitudes=eigenvalues.abs(),
+ growth_rates=torch.zeros(n, dtype=torch.float64),
+ frequencies=torch.zeros(n, dtype=torch.float64),
+ time_step=1.0,
+ )
+
+
+def test_well_conditioned_amplitudes_match_linalg_solve() -> None:
+ """Va=z stays torch.linalg.solve on a well-conditioned identity basis.
+
+ Independent oracle: the same solve on the stored V. Float64 residuals.
+ """
+ spectrum = compute_spectrum(torch.eye(3, dtype=torch.float64), time_step=1.0)
+ states = torch.tensor(
+ [[1.0, -0.5, 0.25], [0.0, 2.0, -1.0]],
+ dtype=torch.float64,
+ )
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", UserWarning)
+ amplitudes = spectrum.mode_amplitudes(states)
+ vectors = spectrum.eigenvectors
+ oracle = torch.linalg.solve(
+ vectors,
+ states.to(dtype=vectors.dtype).reshape(-1, 3).T,
+ ).T.reshape(states.shape)
+ assert torch.allclose(amplitudes, oracle, rtol=_ORACLE_REL, atol=_ORACLE_ABS)
+
+
+def test_moderate_nonnormal_shear_does_not_warn_on_amplitudes() -> None:
+ """The 2306 a=10 shear has κ(V) well below CONDITION_WARN."""
+ spectrum = compute_spectrum(_nonnormal_operator(), time_step=1.0)
+ states = torch.ones(2, dtype=torch.float64)
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", UserWarning)
+ spectrum.mode_amplitudes(states)
+
+
+def test_ill_conditioned_eigenvectors_warn_then_solve() -> None:
+ """Nearly parallel columns exceed CONDITION_WARN but remain solvable."""
+ scale = 1e-8
+ vectors = torch.tensor(
+ [[1.0 + 0.0j, 1.0 + 0.0j], [0.0 + 0.0j, scale]],
+ dtype=torch.complex128,
+ )
+ spectrum = _spectrum_from_vectors(vectors)
+ states = torch.tensor([1.0, 0.5], dtype=torch.float64)
+ with pytest.warns(UserWarning, match="CONDITION_WARN"):
+ amplitudes = spectrum.mode_amplitudes(states)
+ oracle = torch.linalg.solve(
+ vectors,
+ states.to(dtype=vectors.dtype).reshape(-1, 2).T,
+ ).T.reshape(states.shape)
+ assert torch.allclose(amplitudes, oracle, rtol=_ORACLE_REL, atol=_ORACLE_ABS)
+
+
+def test_singular_eigenvectors_raise_defective_spectrum_error() -> None:
+ """A zero column cannot be inverted; the error hints at a Schur subspace."""
+ vectors = torch.tensor(
+ [[1.0 + 0.0j, 0.0 + 0.0j], [0.0 + 0.0j, 0.0 + 0.0j]],
+ )
+ spectrum = _spectrum_from_vectors(vectors)
+ with pytest.raises(DefectiveSpectrumError, match="Schur") as exc_info:
+ spectrum.mode_amplitudes(torch.ones(2, dtype=torch.float64))
+ assert isinstance(exc_info.value, torch.linalg.LinAlgError)
+ assert isinstance(exc_info.value, RuntimeError)
+
+
+def test_infinite_stored_condition_raises_before_solve() -> None:
+ """Stored κ(V)=+∞ raises even if diagnostics were attached separately."""
+ vectors = torch.tensor(
+ [[1.0 + 0.0j, 0.0 + 0.0j], [0.0 + 0.0j, 0.0 + 0.0j]],
+ )
+ diag = compute_spectral_diagnostics(
+ torch.eye(2, dtype=torch.float64),
+ vectors,
+ eigenvalues=torch.ones(2, dtype=torch.complex128),
+ time_step=1.0,
+ )
+ assert math.isinf(diag.eigenvector_condition)
+ spectrum = replace(_spectrum_from_vectors(vectors), diagnostics=diag)
+ with pytest.raises(DefectiveSpectrumError, match="Schur"):
+ spectrum.mode_amplitudes(torch.ones(2, dtype=torch.float64))
diff --git a/tests/baselines/test_gedmd.py b/tests/baselines/test_gedmd.py
new file mode 100644
index 0000000..111a07a
--- /dev/null
+++ b/tests/baselines/test_gedmd.py
@@ -0,0 +1,198 @@
+"""Generator EDMD baseline versus discrete EDMD and derivative-mode SINDy."""
+
+from __future__ import annotations
+
+import pytest
+import torch
+from tests.helpers import REPO_ROOT
+from torch_geometric.data import Data
+
+import koopman_graph
+import koopman_graph.baselines as baselines_pkg
+from koopman_graph import GraphSnapshotSequence
+from koopman_graph.baselines import GEDMDBaseline
+from koopman_graph.protocols import ForecastModel, UncontrolledForecastModel
+from koopman_graph.spectrum_types import compute_generator_spectrum
+
+
+def _sequence_from_states(
+ states: list[torch.Tensor],
+ edge_index: torch.Tensor,
+ *,
+ num_nodes: int,
+ in_channels: int,
+ derivatives: list[torch.Tensor] | None = None,
+ timestamps: torch.Tensor | None = None,
+) -> GraphSnapshotSequence:
+ """Build a graph snapshot sequence from flattened states.
+
+ Parameters
+ ----------
+ states : list of Tensor
+ Flattened states.
+ edge_index : Tensor
+ Shared COO edges.
+ num_nodes, in_channels : int
+ Snapshot layout.
+ derivatives : list of Tensor or None, optional
+ Flattened ``dx/dt`` stored as ``Data.dx_dt``.
+ timestamps : Tensor or None, optional
+ Optional strictly increasing times.
+
+ Returns
+ -------
+ GraphSnapshotSequence
+ Homogeneous snapshots.
+ """
+ snapshots = []
+ for index, state in enumerate(states):
+ payload: dict[str, torch.Tensor] = {
+ "x": state.reshape(num_nodes, in_channels),
+ "edge_index": edge_index,
+ }
+ if derivatives is not None:
+ payload["dx_dt"] = derivatives[index].reshape(num_nodes, in_channels)
+ snapshots.append(Data(**payload))
+ return GraphSnapshotSequence(snapshots, timestamps=timestamps)
+
+
+def test_gedmd_exported_and_not_on_root_all() -> None:
+ """``GEDMDBaseline`` is a baselines export, not a root façade symbol."""
+ assert "GEDMDBaseline" in baselines_pkg.__all__
+ assert "GEDMDBaseline" not in set(koopman_graph.__all__)
+ assert not hasattr(koopman_graph, "GEDMDBaseline")
+ source = REPO_ROOT / "src" / "koopman_graph" / "baselines" / "gedmd.py"
+ text = source.read_text(encoding="utf-8")
+ assert "10.1016/j.physd.2020.132416" in text
+ assert "Klus2020gEDMD" in text
+ assert "identify_sparse_dynamics" in text
+
+
+def test_gedmd_satisfies_forecast_protocols() -> None:
+ """gEDMD is an uncontrolled Data-only ``predict`` peer."""
+ baseline = GEDMDBaseline(polynomial_degree=1)
+ assert isinstance(baseline, ForecastModel)
+ assert isinstance(baseline, UncontrolledForecastModel)
+
+
+def test_gedmd_recovers_linear_generator(
+ synthetic_edge_index: torch.Tensor,
+) -> None:
+ """Identity-dictionary gEDMD recovers a supplied linear generator.
+
+ Independent ``(x, \\dot x)`` samples of ``\\dot x = x @ A^\\top``;
+ construction residual, so ``rtol`` ``1e-8`` / ``atol`` ``1e-10`` on
+ float64.
+ """
+ generator = torch.tensor(
+ [[-0.2, 1.0], [-1.0, -0.3]],
+ dtype=torch.float64,
+ )
+ torch.manual_seed(0)
+ states = [torch.randn(2, dtype=torch.float64) for _ in range(12)]
+ derivatives = [state @ generator.T for state in states]
+ sequence = _sequence_from_states(
+ states,
+ synthetic_edge_index,
+ num_nodes=2,
+ in_channels=1,
+ derivatives=derivatives,
+ )
+ baseline = GEDMDBaseline(polynomial_degree=1, time_step=0.25).fit(sequence)
+ assert baseline.K is not None
+ torch.testing.assert_close(baseline.K, generator, rtol=1e-8, atol=1e-10)
+ expected_spectrum = compute_generator_spectrum(generator)
+ fitted_spectrum = baseline.spectrum()
+ torch.testing.assert_close(
+ fitted_spectrum.eigenvalues,
+ expected_spectrum.eigenvalues,
+ rtol=1e-8,
+ atol=1e-10,
+ )
+ torch.testing.assert_close(
+ fitted_spectrum.growth_rates,
+ expected_spectrum.growth_rates,
+ rtol=1e-8,
+ atol=1e-10,
+ )
+ initial = sequence[0]
+ predicted = baseline.predict(initial, steps=3)[-1]
+ step = torch.linalg.matrix_exp(generator * 0.25)
+ expected_state = states[0]
+ for _ in range(3):
+ expected_state = expected_state @ step.T
+ torch.testing.assert_close(
+ predicted.x.reshape(-1),
+ expected_state,
+ rtol=1e-8,
+ atol=1e-10,
+ )
+
+
+def test_gedmd_fit_derivatives_kwarg_and_irregular_timestamps(
+ synthetic_edge_index: torch.Tensor,
+) -> None:
+ """``derivatives=`` recovers ``A`` even when timestamps are irregular.
+
+ Timestamps are unused at fit; they do not create ``L``. Construction
+ residual, so ``rtol`` ``1e-8`` / ``atol`` ``1e-10`` on float64.
+ """
+ generator = torch.diag(torch.tensor([-0.5, -1.2], dtype=torch.float64))
+ torch.manual_seed(1)
+ states = torch.randn(10, 2, dtype=torch.float64)
+ derivatives = states @ generator.T
+ timestamps = torch.tensor(
+ [0.0, 0.05, 0.2, 0.21, 0.5, 0.9, 1.0, 1.7, 1.75, 3.0],
+ dtype=torch.float64,
+ )
+ sequence = _sequence_from_states(
+ list(states),
+ synthetic_edge_index,
+ num_nodes=2,
+ in_channels=1,
+ timestamps=timestamps,
+ )
+ baseline = GEDMDBaseline(polynomial_degree=1).fit(
+ sequence,
+ derivatives=derivatives.reshape(10, 2, 1),
+ )
+ assert baseline.K is not None
+ torch.testing.assert_close(baseline.K, generator, rtol=1e-8, atol=1e-10)
+
+
+def test_gedmd_refuses_timestamps_without_derivatives(
+ synthetic_edge_index: torch.Tensor,
+) -> None:
+ """Irregular timestamps alone do not identify the generator."""
+ states = [
+ torch.tensor([1.0, 0.3], dtype=torch.float64),
+ torch.tensor([0.8, 0.1], dtype=torch.float64),
+ torch.tensor([0.5, -0.2], dtype=torch.float64),
+ ]
+ timestamps = torch.tensor([0.0, 0.1, 0.4], dtype=torch.float64)
+ sequence = _sequence_from_states(
+ states,
+ synthetic_edge_index,
+ num_nodes=2,
+ in_channels=1,
+ timestamps=timestamps,
+ )
+ with pytest.raises(ValueError, match="generator-action data"):
+ GEDMDBaseline(polynomial_degree=1).fit(sequence)
+
+
+def test_gedmd_refuses_rbf_dictionary(
+ synthetic_edge_index: torch.Tensor,
+) -> None:
+ """RBF / kernel dictionaries are out of scope for this generator path."""
+ state = torch.tensor([1.0, -0.5], dtype=torch.float64)
+ derivative = torch.tensor([-0.2, 0.1], dtype=torch.float64)
+ sequence = _sequence_from_states(
+ [state, state],
+ synthetic_edge_index,
+ num_nodes=2,
+ in_channels=1,
+ derivatives=[derivative, derivative],
+ )
+ with pytest.raises(ValueError, match="dictionary='polynomial' only"):
+ GEDMDBaseline(dictionary="rbf", polynomial_degree=1).fit(sequence)
diff --git a/tests/baselines/test_hankel_havok.py b/tests/baselines/test_hankel_havok.py
new file mode 100644
index 0000000..bc1adbb
--- /dev/null
+++ b/tests/baselines/test_hankel_havok.py
@@ -0,0 +1,261 @@
+"""Hankel-DMD and HAVOK teaching baselines versus delay-encoder stacking."""
+
+from __future__ import annotations
+
+import math
+
+import pytest
+import torch
+from tests.helpers import REPO_ROOT
+from torch_geometric.data import Data
+
+import koopman_graph
+import koopman_graph.baselines as baselines_pkg
+from koopman_graph import GraphSnapshotSequence
+from koopman_graph.baselines import HankelDMDBaseline, HAVOKBaseline
+from koopman_graph.baselines.base import ClassicalBaseline
+from koopman_graph.baselines.hankel_dmd import delay_embed_rows
+from koopman_graph.nn import DelayEmbeddingEncoder, GNNEncoder
+from koopman_graph.protocols import ForecastModel, UncontrolledForecastModel
+
+_HANKEL_SRC = REPO_ROOT / "src" / "koopman_graph" / "baselines" / "hankel_dmd.py"
+_HAVOK_SRC = REPO_ROOT / "src" / "koopman_graph" / "baselines" / "havok.py"
+_SCALAR_EDGE_INDEX = torch.tensor([[0], [0]], dtype=torch.long)
+
+
+def _sequence_from_states(
+ states: list[torch.Tensor],
+ edge_index: torch.Tensor,
+ *,
+ num_nodes: int,
+ in_channels: int,
+) -> GraphSnapshotSequence:
+ """Build a graph snapshot sequence from flattened states.
+
+ Parameters
+ ----------
+ states : list of Tensor
+ Flattened states.
+ edge_index : Tensor
+ Shared COO edges.
+ num_nodes, in_channels : int
+ Snapshot layout.
+
+ Returns
+ -------
+ GraphSnapshotSequence
+ Homogeneous snapshots.
+ """
+ snapshots = [
+ Data(x=state.reshape(num_nodes, in_channels), edge_index=edge_index)
+ for state in states
+ ]
+ return GraphSnapshotSequence(snapshots)
+
+
+def _scalar_rotation_orbit(*, n_times: int, dtype: torch.dtype) -> list[torch.Tensor]:
+ """Return the first coordinate of a planar rotation as 1-D states.
+
+ Parameters
+ ----------
+ n_times : int
+ Number of samples.
+ dtype : torch.dtype
+ Floating type.
+
+ Returns
+ -------
+ list of Tensor
+ Scalar states with shape ``(1,)``.
+ """
+ angle = 2.0 * math.pi / 16.0
+ cosine = math.cos(angle)
+ sine = math.sin(angle)
+ rotation = torch.tensor([[cosine, -sine], [sine, cosine]], dtype=dtype)
+ state = torch.tensor([1.0, 0.3], dtype=dtype)
+ orbit: list[torch.Tensor] = []
+ for _ in range(n_times):
+ orbit.append(state[0:1].clone())
+ state = state @ rotation.T
+ return orbit
+
+
+def test_hankel_havok_exported_and_not_on_root_all() -> None:
+ """Hankel-DMD and HAVOK are baselines exports, not root façade symbols."""
+ for name in ("HankelDMDBaseline", "HAVOKBaseline"):
+ assert name in baselines_pkg.__all__
+ assert name not in set(koopman_graph.__all__)
+ assert not hasattr(koopman_graph, name)
+ assert "delay_embed_rows" not in baselines_pkg.__all__
+ assert "assemble_delay_state" not in baselines_pkg.__all__
+ hankel_text = _HANKEL_SRC.read_text(encoding="utf-8")
+ havok_text = _HAVOK_SRC.read_text(encoding="utf-8")
+ assert "10.1137/17M1125236" in hankel_text
+ assert "Arbabi2017HankelDMD" in hankel_text
+ assert "10.1038/s41467-017-00030-8" in havok_text
+ assert "Brunton2017HAVOK" in havok_text
+
+
+def test_hankel_havok_satisfy_forecast_protocols() -> None:
+ """Both delay-row solvers are uncontrolled Data-only ``predict`` peers."""
+ for baseline in (
+ HankelDMDBaseline(n_delays=2),
+ HAVOKBaseline(n_delays=4, havok_rank=3),
+ ):
+ assert isinstance(baseline, ForecastModel)
+ assert isinstance(baseline, UncontrolledForecastModel)
+ assert isinstance(baseline, ClassicalBaseline)
+
+
+def test_delay_embed_rows_oldest_to_newest() -> None:
+ """Window ``i`` concatenates ``states[i:i+n_delays]`` oldest → newest.
+
+ Exact integer-valued construction, so equality is bitwise.
+ """
+ states = torch.tensor(
+ [[1.0, 10.0], [2.0, 20.0], [3.0, 30.0], [4.0, 40.0]],
+ dtype=torch.float64,
+ )
+ rows = delay_embed_rows(states, n_delays=2)
+ expected = torch.tensor(
+ [
+ [1.0, 10.0, 2.0, 20.0],
+ [2.0, 20.0, 3.0, 30.0],
+ [3.0, 30.0, 4.0, 40.0],
+ ],
+ dtype=torch.float64,
+ )
+ torch.testing.assert_close(rows, expected, rtol=0.0, atol=0.0)
+
+
+def test_hankel_dmd_predicts_scalar_rotation_with_history() -> None:
+ """Hankel-DMD one-step forecast matches a held-out scalar rotation sample.
+
+ The measurement is the first coordinate of a planar 16-gon rotation.
+ ``n_delays=4`` with supplied history is an exact delay IC; float64
+ residual of the linear delay map, so ``rtol`` ``1e-8`` / ``atol``
+ ``1e-10``. Dominant eigenvalues lie on the unit circle to the same
+ construction residual.
+ """
+ states = _scalar_rotation_orbit(n_times=33, dtype=torch.float64)
+ sequence = _sequence_from_states(
+ states[:-1],
+ _SCALAR_EDGE_INDEX,
+ num_nodes=1,
+ in_channels=1,
+ )
+ baseline = HankelDMDBaseline(n_delays=4, time_step=1.0).fit(sequence)
+ assert baseline.K is not None
+ radii = torch.linalg.eigvals(baseline.K).abs()
+ dominant = torch.sort(radii, descending=True).values[:2]
+ torch.testing.assert_close(
+ dominant,
+ torch.ones_like(dominant),
+ rtol=1e-8,
+ atol=1e-10,
+ )
+ predicted = baseline.predict(
+ sequence[-1],
+ steps=1,
+ history=list(sequence[-4:-1]),
+ )[-1]
+ torch.testing.assert_close(
+ predicted.x.reshape(-1),
+ states[-1],
+ rtol=1e-8,
+ atol=1e-10,
+ )
+
+
+def test_havok_energy_concentrates_on_leading_modes() -> None:
+ """A sampled sinusoid Hankel is numerically rank-2; ``A`` stays finite.
+
+ Exact 2-D oscillator coordinate; ``σ_3 / σ_1`` is a float64 SVD
+ residual (``1e-8`` relative). Autonomous ``predict`` uses ``u=0``.
+ """
+ states = _scalar_rotation_orbit(n_times=40, dtype=torch.float64)
+ sequence = _sequence_from_states(
+ states,
+ _SCALAR_EDGE_INDEX,
+ num_nodes=1,
+ in_channels=1,
+ )
+ baseline = HAVOKBaseline(n_delays=4, havok_rank=3, time_step=1.0).fit(sequence)
+ assert baseline.K is not None
+ assert baseline.B is not None
+ assert baseline.singular_values is not None
+ assert baseline.K.shape == (2, 2)
+ assert baseline.B.shape == (2, 1)
+ assert torch.isfinite(baseline.K).all()
+ leading = baseline.singular_values[0]
+ third = baseline.singular_values[2]
+ assert float(third / leading) == pytest.approx(0.0, abs=1e-8)
+ forecasts = baseline.predict(
+ sequence[-1],
+ steps=2,
+ history=list(sequence[-4:-1]),
+ )
+ assert len(forecasts) == 2
+ assert forecasts[0].x.shape == (1, 1)
+ spectrum = baseline.spectrum()
+ assert spectrum.eigenvalues.shape == (2,)
+
+
+def test_hankel_havok_distinct_from_delay_encoder() -> None:
+ """Delay-encoder stacking is a GNN wrapper, not a delay-row DMD/HAVOK fit."""
+ encoder = DelayEmbeddingEncoder(
+ GNNEncoder(in_channels=4, hidden_channels=4, latent_dim=2, num_layers=1),
+ n_delays=2,
+ )
+ assert not isinstance(encoder, ClassicalBaseline)
+ assert not hasattr(HankelDMDBaseline(), "base_encoder")
+ assert not hasattr(HAVOKBaseline(n_delays=4), "base_encoder")
+ assert not isinstance(HankelDMDBaseline(), DelayEmbeddingEncoder)
+
+
+def test_delay_embed_rows_rejects_invalid_window() -> None:
+ """``n_delays < 1`` and short sequences raise with the constraint."""
+ states = torch.ones(3, 2, dtype=torch.float64)
+ with pytest.raises(ValueError, match="n_delays must be >= 1"):
+ delay_embed_rows(states, n_delays=0)
+ with pytest.raises(ValueError, match="need at least n_delays=4"):
+ delay_embed_rows(states, n_delays=4)
+
+
+def test_hankel_dmd_rejects_short_sequence() -> None:
+ """Fit requires ``T >= n_delays + 1`` consecutive snapshots."""
+ states = [torch.ones(1, dtype=torch.float64) for _ in range(3)]
+ sequence = _sequence_from_states(
+ states,
+ _SCALAR_EDGE_INDEX,
+ num_nodes=1,
+ in_channels=1,
+ )
+ with pytest.raises(ValueError, match="n_delays\\+1=4"):
+ HankelDMDBaseline(n_delays=3).fit(sequence)
+ with pytest.raises(ValueError, match="n_delays must be >= 1"):
+ HankelDMDBaseline(n_delays=0)
+
+
+def test_havok_rank_guards() -> None:
+ """``havok_rank`` must be at least 2 and fit inside the Hankel bound."""
+ with pytest.raises(ValueError, match="havok_rank must be >= 2"):
+ HAVOKBaseline(havok_rank=1)
+ states = [torch.tensor([float(index)], dtype=torch.float64) for index in range(8)]
+ sequence = _sequence_from_states(
+ states,
+ _SCALAR_EDGE_INDEX,
+ num_nodes=1,
+ in_channels=1,
+ )
+ with pytest.raises(ValueError, match="havok_rank=3 exceeds Hankel rank bound"):
+ HAVOKBaseline(n_delays=2, havok_rank=3).fit(sequence)
+ with pytest.raises(ValueError, match="n_delays\\+1=5"):
+ HAVOKBaseline(n_delays=4, havok_rank=3).fit(
+ _sequence_from_states(
+ states[:4],
+ _SCALAR_EDGE_INDEX,
+ num_nodes=1,
+ in_channels=1,
+ )
+ )
diff --git a/tests/baselines/test_mpedmd.py b/tests/baselines/test_mpedmd.py
new file mode 100644
index 0000000..0b8d8b2
--- /dev/null
+++ b/tests/baselines/test_mpedmd.py
@@ -0,0 +1,232 @@
+"""Measure-preserving EDMD baseline versus unconstrained EDMD."""
+
+from __future__ import annotations
+
+import math
+
+import pytest
+import torch
+from tests.helpers import REPO_ROOT
+from torch_geometric.data import Data
+
+import koopman_graph
+import koopman_graph.baselines as baselines_pkg
+from koopman_graph import GraphSnapshotSequence
+from koopman_graph.analysis._galerkin import assemble_galerkin_grams
+from koopman_graph.baselines import EDMDBaseline, MpEDMDBaseline
+from koopman_graph.baselines.mpedmd import fit_mpedmd_row_operator
+from koopman_graph.protocols import ForecastModel, UncontrolledForecastModel
+
+
+def _sequence_from_states(
+ states: list[torch.Tensor],
+ edge_index: torch.Tensor,
+ *,
+ num_nodes: int,
+ in_channels: int,
+) -> GraphSnapshotSequence:
+ """Build a graph snapshot sequence from flattened states.
+
+ Parameters
+ ----------
+ states : list of Tensor
+ Flattened states.
+ edge_index : Tensor
+ Shared COO edges.
+ num_nodes, in_channels : int
+ Snapshot layout.
+
+ Returns
+ -------
+ GraphSnapshotSequence
+ Homogeneous snapshots.
+ """
+ snapshots = [
+ Data(x=state.reshape(num_nodes, in_channels), edge_index=edge_index)
+ for state in states
+ ]
+ return GraphSnapshotSequence(snapshots)
+
+
+def _orbit(
+ operator: torch.Tensor,
+ initial_state: torch.Tensor,
+ *,
+ n_times: int,
+) -> list[torch.Tensor]:
+ """Advance ``x_{t+1} = x_t @ K^\\top``.
+
+ Parameters
+ ----------
+ operator : Tensor
+ Row-convention map.
+ initial_state : Tensor
+ Flattened initial state.
+ n_times : int
+ Trajectory length.
+
+ Returns
+ -------
+ list of Tensor
+ Flattened states.
+ """
+ states = [initial_state]
+ for _ in range(n_times - 1):
+ states.append(states[-1] @ operator.T)
+ return states
+
+
+def test_mpedmd_exported_and_not_on_root_all() -> None:
+ """``MpEDMDBaseline`` is a baselines export, not a root façade symbol."""
+ assert "MpEDMDBaseline" in baselines_pkg.__all__
+ assert "MpEDMDBaseline" not in set(koopman_graph.__all__)
+ assert not hasattr(koopman_graph, "MpEDMDBaseline")
+ source = REPO_ROOT / "src" / "koopman_graph" / "baselines" / "mpedmd.py"
+ text = source.read_text(encoding="utf-8")
+ assert "10.1137/22M1521407" in text
+ assert "Colbrook2023mpEDMD" in text
+
+
+def test_mpedmd_satisfies_forecast_protocols() -> None:
+ """mpEDMD is an uncontrolled Data-only ``predict`` peer."""
+ baseline = MpEDMDBaseline(polynomial_degree=1)
+ assert isinstance(baseline, ForecastModel)
+ assert isinstance(baseline, UncontrolledForecastModel)
+
+
+def test_mpedmd_recovers_rotation_like_edmd(
+ synthetic_edge_index: torch.Tensor,
+) -> None:
+ """Identity-dictionary mpEDMD matches EDMD on a regular polygonal rotation.
+
+ A closed regular polygon makes the empirical dictionary Gram a multiple
+ of the identity, so the Euclidean rotation is G-unitary and the polar
+ factor agrees with unconstrained EDMD. Synthetic construction; ``rtol``
+ ``1e-8`` / ``atol`` ``1e-10`` is float64 residual from that orbit.
+ """
+ n_pairs = 16
+ angle = 2.0 * math.pi / n_pairs
+ cosine = math.cos(angle)
+ sine = math.sin(angle)
+ rotation = torch.tensor(
+ [[cosine, -sine], [sine, cosine]],
+ dtype=torch.float64,
+ )
+ states = _orbit(
+ rotation,
+ torch.tensor([1.0, 0.3], dtype=torch.float64),
+ n_times=n_pairs + 1,
+ )
+ sequence = _sequence_from_states(
+ states, synthetic_edge_index, num_nodes=2, in_channels=1
+ )
+ edmd = EDMDBaseline(polynomial_degree=1, time_step=1.0).fit(sequence)
+ mpedmd = MpEDMDBaseline(polynomial_degree=1, time_step=1.0).fit(sequence)
+ assert edmd.K is not None
+ assert mpedmd.K is not None
+ torch.testing.assert_close(edmd.K, rotation, rtol=1e-8, atol=1e-10)
+ torch.testing.assert_close(mpedmd.K, rotation, rtol=1e-8, atol=1e-10)
+ radii = torch.linalg.eigvals(mpedmd.K).abs()
+ torch.testing.assert_close(
+ radii,
+ torch.ones_like(radii),
+ rtol=1e-8,
+ atol=1e-10,
+ )
+ prediction = mpedmd.predict(sequence[0], steps=3)[-1]
+ torch.testing.assert_close(
+ prediction.x.reshape(-1),
+ states[3],
+ rtol=1e-8,
+ atol=1e-10,
+ )
+
+
+def test_mpedmd_is_unitary_in_the_gram_inner_product() -> None:
+ """On a non-isotropic orbit, ``K`` stays G-unitary with unit-circle eigenvalues.
+
+ The Euclidean rotation is not G-unitary when the sample Gram is
+ anisotropic, so ``K`` need not equal the rotation. Colbrook Algorithm 4.1
+ still yields ``K^\\top G K = G``. Synthetic construction; ``rtol`` ``1e-8``
+ / ``atol`` ``1e-10`` is float64 residual of that identity.
+ """
+ angle = 0.4
+ cosine = math.cos(angle)
+ sine = math.sin(angle)
+ rotation = torch.tensor(
+ [[cosine, -sine], [sine, cosine]],
+ dtype=torch.float64,
+ )
+ states = torch.stack(
+ _orbit(
+ rotation,
+ torch.tensor([1.0, 0.3], dtype=torch.float64),
+ n_times=16,
+ )
+ )
+ left, right = states[:-1], states[1:]
+ fitted = fit_mpedmd_row_operator(left, right, rank=None)
+ assert not torch.allclose(fitted, rotation, rtol=1e-3, atol=1e-3)
+ gram = assemble_galerkin_grams(left, right).g00
+ column_map = fitted.T
+ torch.testing.assert_close(
+ column_map.T @ gram @ column_map,
+ gram,
+ rtol=1e-8,
+ atol=1e-10,
+ )
+ radii = torch.linalg.eigvals(fitted).abs()
+ torch.testing.assert_close(
+ radii,
+ torch.ones_like(radii),
+ rtol=1e-8,
+ atol=1e-10,
+ )
+
+
+def test_mpedmd_does_not_recover_dissipative_map(
+ synthetic_edge_index: torch.Tensor,
+) -> None:
+ """On a contraction, EDMD recovers ``K`` while mpEDMD stays on the circle."""
+ dissipative = torch.diag(torch.tensor([0.7, 0.4], dtype=torch.float64))
+ states = _orbit(
+ dissipative,
+ torch.tensor([1.2, -0.8], dtype=torch.float64),
+ n_times=12,
+ )
+ sequence = _sequence_from_states(
+ states, synthetic_edge_index, num_nodes=2, in_channels=1
+ )
+ edmd = EDMDBaseline(polynomial_degree=1).fit(sequence)
+ mpedmd = MpEDMDBaseline(polynomial_degree=1).fit(sequence)
+ assert edmd.K is not None
+ assert mpedmd.K is not None
+ torch.testing.assert_close(edmd.K, dissipative, rtol=1e-8, atol=1e-10)
+ assert not torch.allclose(mpedmd.K, dissipative, rtol=1e-2, atol=1e-2)
+ radii = torch.linalg.eigvals(mpedmd.K).abs()
+ torch.testing.assert_close(
+ radii,
+ torch.ones_like(radii),
+ rtol=1e-6,
+ atol=1e-8,
+ )
+
+
+def test_fit_mpedmd_row_operator_empty_gram_raises() -> None:
+ """A zero dictionary Gram is refused."""
+ left = torch.zeros(4, 2, dtype=torch.float64)
+ right = torch.zeros(4, 2, dtype=torch.float64)
+ with pytest.raises(ValueError, match="Gram square-root is empty"):
+ fit_mpedmd_row_operator(left, right, rank=None)
+
+
+def test_mpedmd_requires_two_snapshots(synthetic_edge_index: torch.Tensor) -> None:
+ """Fewer than two snapshots raise like EDMD."""
+ sequence = _sequence_from_states(
+ [torch.tensor([1.0, 0.0], dtype=torch.float64)],
+ synthetic_edge_index,
+ num_nodes=2,
+ in_channels=1,
+ )
+ with pytest.raises(ValueError, match="at least two snapshots"):
+ MpEDMDBaseline(polynomial_degree=1).fit(sequence)
diff --git a/tests/benchmark/test_import_isolation.py b/tests/benchmark/test_import_isolation.py
new file mode 100644
index 0000000..85b7910
--- /dev/null
+++ b/tests/benchmark/test_import_isolation.py
@@ -0,0 +1,149 @@
+"""Import-cycle guards for ``koopman_graph.benchmark``."""
+
+from __future__ import annotations
+
+import ast
+import subprocess
+import sys
+from pathlib import Path
+
+from tests.helpers import REPO_ROOT
+
+_PACKAGE_ROOT = REPO_ROOT / "src" / "koopman_graph"
+_FORBIDDEN_FROM_BENCHMARK = (
+ "koopman_graph.training",
+ "koopman_graph.model",
+)
+
+
+def _is_type_checking(node: ast.AST) -> bool:
+ """Return whether ``node`` is ``if TYPE_CHECKING:``.
+
+ Parameters
+ ----------
+ node : ast.AST
+ Statement to inspect.
+
+ Returns
+ -------
+ bool
+ ``True`` for a ``TYPE_CHECKING`` guard.
+ """
+ if not isinstance(node, ast.If):
+ return False
+ test = node.test
+ return isinstance(test, ast.Name) and test.id == "TYPE_CHECKING"
+
+
+def _import_names(node: ast.AST) -> list[str]:
+ """Return imported module names on an import statement.
+
+ Parameters
+ ----------
+ node : ast.AST
+ Import node.
+
+ Returns
+ -------
+ list of str
+ Absolute module names.
+ """
+ if isinstance(node, ast.Import):
+ return [alias.name for alias in node.names]
+ if isinstance(node, ast.ImportFrom) and node.module:
+ return [node.module]
+ return []
+
+
+def _module_import_offenders(
+ root: Path,
+ prefixes: tuple[str, ...],
+ *,
+ module_level_only: bool = False,
+) -> list[str]:
+ """Return ``path:module`` entries that import a forbidden prefix.
+
+ Parameters
+ ----------
+ root : Path
+ Package directory to walk.
+ prefixes : tuple of str
+ Forbidden module prefixes (exact or dotted children).
+ module_level_only : bool, optional
+ When ``True``, ignore function-body lazy imports and
+ ``if TYPE_CHECKING:`` blocks. Default is ``False`` (walk all).
+
+ Returns
+ -------
+ list of str
+ Offending ``filename:module`` labels.
+ """
+ offenders: list[str] = []
+ for path in sorted(root.rglob("*.py")):
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
+ if module_level_only:
+ nodes: list[ast.AST] = [
+ node for node in tree.body if not _is_type_checking(node)
+ ]
+ else:
+ nodes = list(ast.walk(tree))
+ for node in nodes:
+ for name in _import_names(node):
+ if any(
+ name == prefix or name.startswith(prefix + ".")
+ for prefix in prefixes
+ ):
+ offenders.append(f"{path.relative_to(_PACKAGE_ROOT)}:{name}")
+ return offenders
+
+
+def test_no_inbound_imports_of_benchmark() -> None:
+ """Other packages may not import ``benchmark`` except lazy CLI handlers."""
+ offenders: list[str] = []
+ for path in sorted(_PACKAGE_ROOT.rglob("*.py")):
+ relative = path.relative_to(_PACKAGE_ROOT)
+ if relative.parts[0] == "benchmark":
+ continue
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
+ module_level_only = relative.parts[0] == "cli"
+ if module_level_only:
+ nodes: list[ast.AST] = [
+ node for node in tree.body if not _is_type_checking(node)
+ ]
+ else:
+ nodes = list(ast.walk(tree))
+ for node in nodes:
+ for name in _import_names(node):
+ if name == "koopman_graph.benchmark" or name.startswith(
+ "koopman_graph.benchmark."
+ ):
+ offenders.append(f"{relative}:{name}")
+ assert offenders == []
+
+
+def test_benchmark_sources_do_not_import_training_or_model() -> None:
+ """``benchmark/`` must not import ``training`` or ``model``."""
+ assert (
+ _module_import_offenders(
+ _PACKAGE_ROOT / "benchmark",
+ _FORBIDDEN_FROM_BENCHMARK,
+ )
+ == []
+ )
+
+
+def test_importing_cli_does_not_load_benchmark() -> None:
+ """``import koopman_graph.cli.main`` must not load ``benchmark``."""
+ script = (
+ "import sys\n"
+ "import koopman_graph.cli.main\n"
+ "assert 'koopman_graph.benchmark' not in sys.modules, "
+ "sorted(k for k in sys.modules if 'benchmark' in k)\n"
+ )
+ completed = subprocess.run(
+ [sys.executable, "-c", script],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ assert completed.returncode == 0, completed.stderr
diff --git a/tests/benchmark/test_io.py b/tests/benchmark/test_io.py
new file mode 100644
index 0000000..bb7628c
--- /dev/null
+++ b/tests/benchmark/test_io.py
@@ -0,0 +1,151 @@
+"""JSON/YAML round-trip and dataset SHA-256 mismatch tests."""
+
+from __future__ import annotations
+
+import builtins
+import hashlib
+import json
+import sys
+from pathlib import Path
+from types import ModuleType
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from koopman_graph.benchmark import (
+ SCHEMA_VERSION,
+ ComputeBudget,
+ DatasetRef,
+ ExperimentManifest,
+ ManifestError,
+ MethodSpec,
+ PreprocessingSpec,
+ SplitSpec,
+ dump_manifest,
+ load_manifest,
+ verify_dataset_hash,
+)
+
+_BYTES = b"fixture-bytes"
+_DIGEST = hashlib.sha256(_BYTES).hexdigest()
+
+
+def _manifest() -> ExperimentManifest:
+ """Return a valid telemetry manifest.
+
+ Returns
+ -------
+ ExperimentManifest
+ Toy record.
+ """
+ return ExperimentManifest(
+ manifest_id="smoke-telemetry",
+ schema_version=SCHEMA_VERSION,
+ track="telemetry",
+ dataset=DatasetRef(
+ name="toy-path",
+ version="1",
+ sha256=_DIGEST,
+ card="docs/data/toy.md",
+ ),
+ split=SplitSpec(0.7, 0.1, 0.2, history_len=12),
+ preprocessing=PreprocessingSpec(zscore=True),
+ methods=(MethodSpec(name="graph_koopman", role="koopman"),),
+ seeds=(0, 1, 2),
+ horizons=(1, 3, 12),
+ metrics=("mae", "rmse"),
+ compute_budget=ComputeBudget(max_epochs=2),
+ controls=("pernode",),
+ )
+
+
+def test_json_round_trip(tmp_path: Path) -> None:
+ """JSON dump then load recovers the frozen dataclass."""
+ path = tmp_path / "manifest.json"
+ original = _manifest()
+ dump_manifest(original, path)
+ restored = load_manifest(path)
+ assert restored == original
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ assert payload["schema_version"] == SCHEMA_VERSION
+ assert payload["seeds"] == [0, 1, 2]
+
+
+def test_yaml_round_trip_when_installed(tmp_path: Path) -> None:
+ """YAML dump then load recovers the frozen dataclass when PyYAML is present."""
+ pytest.importorskip("yaml")
+ path = tmp_path / "manifest.yaml"
+ original = _manifest()
+ dump_manifest(original, path)
+ restored = load_manifest(path)
+ assert restored == original
+
+
+def test_yaml_requires_pyyaml(tmp_path: Path) -> None:
+ """Missing PyYAML raises ImportError with the ``[cli]`` install hint."""
+ path = tmp_path / "manifest.yaml"
+ path.write_text("manifest_id: x\n", encoding="utf-8")
+ real_import = builtins.__import__
+
+ def blocked_import(
+ name: str,
+ globals: object = None,
+ locals: object = None,
+ fromlist: tuple[str, ...] = (),
+ level: int = 0,
+ ) -> object:
+ if name == "yaml":
+ raise ImportError("simulated missing yaml")
+ return real_import(name, globals, locals, fromlist, level)
+
+ with (
+ patch.object(builtins, "__import__", side_effect=blocked_import),
+ pytest.raises(ImportError, match="YAML manifests require PyYAML"),
+ ):
+ load_manifest(path)
+
+
+def test_invalid_yaml_and_suffix(tmp_path: Path) -> None:
+ """Malformed YAML and unknown suffixes raise ManifestError."""
+ bad_suffix = tmp_path / "manifest.txt"
+ bad_suffix.write_text("{}", encoding="utf-8")
+ with pytest.raises(ManifestError, match="Unsupported manifest suffix"):
+ load_manifest(bad_suffix)
+ path = tmp_path / "invalid.yaml"
+ path.write_text("invalid: [", encoding="utf-8")
+
+ class FakeYamlError(Exception):
+ """Synthetic YAML parser error."""
+
+ fake_yaml = ModuleType("yaml")
+ fake_yaml.YAMLError = FakeYamlError # type: ignore[attr-defined]
+ fake_yaml.safe_load = MagicMock(side_effect=FakeYamlError("bad yaml")) # type: ignore[attr-defined]
+ with (
+ patch.dict(sys.modules, {"yaml": fake_yaml}),
+ pytest.raises(ManifestError, match="Invalid YAML"),
+ ):
+ load_manifest(path)
+
+
+def test_dataset_hash_mismatch_rejected(tmp_path: Path) -> None:
+ """Tampered bytes and files fail the declared SHA-256."""
+ dataset = _manifest().dataset
+ verify_dataset_hash(dataset, _BYTES)
+ payload = tmp_path / "payload.bin"
+ payload.write_bytes(_BYTES)
+ verify_dataset_hash(dataset, payload)
+ with pytest.raises(ManifestError, match="SHA256 mismatch"):
+ verify_dataset_hash(dataset, b"tampered")
+ payload.write_bytes(b"tampered")
+ with pytest.raises(ManifestError, match="SHA256 mismatch"):
+ verify_dataset_hash(dataset, payload)
+
+
+def test_missing_file_and_invalid_json(tmp_path: Path) -> None:
+ """Missing paths and malformed JSON raise ManifestError."""
+ with pytest.raises(ManifestError, match="manifest file not found"):
+ load_manifest(tmp_path / "missing.json")
+ invalid = tmp_path / "invalid.json"
+ invalid.write_text("{", encoding="utf-8")
+ with pytest.raises(ManifestError, match="Invalid JSON"):
+ load_manifest(invalid)
diff --git a/tests/benchmark/test_manifest.py b/tests/benchmark/test_manifest.py
new file mode 100644
index 0000000..e9f4cbe
--- /dev/null
+++ b/tests/benchmark/test_manifest.py
@@ -0,0 +1,167 @@
+"""Schema guards for frozen experiment manifests."""
+
+from __future__ import annotations
+
+import hashlib
+
+import pytest
+
+from koopman_graph.benchmark import (
+ SCHEMA_VERSION,
+ ComputeBudget,
+ DatasetRef,
+ EmptyMethodDeviationsError,
+ ExperimentManifest,
+ ManifestError,
+ MethodSpec,
+ OODShiftSpec,
+ PreprocessingSpec,
+ SplitSpec,
+ UQSpec,
+ manifest_from_mapping,
+ manifest_to_mapping,
+)
+
+_DIGEST = hashlib.sha256(b"fixture-bytes").hexdigest()
+
+
+def _dataset() -> DatasetRef:
+ """Return a valid dataset reference.
+
+ Returns
+ -------
+ DatasetRef
+ Toy payload identity.
+ """
+ return DatasetRef(
+ name="toy-path",
+ version="1",
+ sha256=_DIGEST,
+ card="docs/data/toy.md",
+ )
+
+
+def _manifest(**overrides: object) -> ExperimentManifest:
+ """Build a valid telemetry manifest.
+
+ Parameters
+ ----------
+ **overrides : object
+ Field replacements.
+
+ Returns
+ -------
+ ExperimentManifest
+ Validated record.
+ """
+ payload: dict[str, object] = {
+ "manifest_id": "smoke-telemetry",
+ "schema_version": SCHEMA_VERSION,
+ "track": "telemetry",
+ "dataset": _dataset(),
+ "split": SplitSpec(0.7, 0.1, 0.2, history_len=12),
+ "preprocessing": PreprocessingSpec(zscore=True),
+ "methods": (MethodSpec(name="graph_koopman", role="koopman"),),
+ "seeds": (0, 1, 2),
+ "horizons": (1, 3, 12),
+ "metrics": ("mae", "rmse"),
+ "compute_budget": ComputeBudget(max_epochs=2),
+ "controls": ("pernode",),
+ }
+ payload.update(overrides)
+ return ExperimentManifest(**payload) # type: ignore[arg-type]
+
+
+def test_telemetry_manifest_accepts_pernode_control() -> None:
+ """A minimal telemetry manifest with a per-node control is valid."""
+ report = _manifest()
+ assert report.schema_version == SCHEMA_VERSION
+ assert report.dataset.sha256 == _DIGEST
+ assert report.controls == ("pernode",)
+ assert report.uq is None
+ assert report.ood_shifts == ()
+
+
+def test_topology_transfer_requires_three_controls() -> None:
+ """Topology-transfer manifests must list hold-last, per-node, and joint LS."""
+ with pytest.raises(ManifestError, match="topology_transfer controls must include"):
+ _manifest(track="topology_transfer", controls=("pernode", "joint_ls"))
+ report = _manifest(
+ track="topology_transfer",
+ controls=("hold_last", "pernode", "joint_ls"),
+ )
+ assert report.track == "topology_transfer"
+
+
+def test_multiphysics_requires_factorization_control() -> None:
+ """Multiphysics rejects a hold-last-only control list."""
+ with pytest.raises(ManifestError, match="pernode and/or joint_ls"):
+ _manifest(track="multiphysics", controls=("hold_last",))
+
+
+def test_teaching_gnn_requires_deviations() -> None:
+ """Teaching GNN methods cannot claim an empty deviation list."""
+ with pytest.raises(EmptyMethodDeviationsError, match="non-empty deviations"):
+ MethodSpec(name="stgcn", role="teaching_gnn")
+ method = MethodSpec(
+ name="stgcn",
+ role="teaching_gnn",
+ deviations=("teaching-scale channels",),
+ )
+ assert method.deviations == ("teaching-scale channels",)
+
+
+def test_leaderboard_may_have_empty_deviations() -> None:
+ """Leaderboard adapters may record an empty deviation tuple."""
+ method = MethodSpec(name="metr-la-adapter", role="leaderboard")
+ assert method.deviations == ()
+
+
+def test_dedicated_library_requires_version() -> None:
+ """Dedicated-library methods must pin a version."""
+ with pytest.raises(ManifestError, match="version pin"):
+ MethodSpec(name="libcity-dcrnn", role="dedicated_library")
+ pinned = MethodSpec(name="libcity-dcrnn", role="dedicated_library", version="0.9.0")
+ assert pinned.version == "0.9.0"
+
+
+def test_malformed_sha256_and_seed_grid_rejected() -> None:
+ """Digests must be 64 hex characters; seed grids must be unique and long enough."""
+ with pytest.raises(ManifestError, match="64-character hex"):
+ DatasetRef(name="toy", version="1", sha256="abcd", card="card.md")
+ with pytest.raises(ManifestError, match="at least 3 unique"):
+ _manifest(seeds=(0, 1))
+ with pytest.raises(ManifestError, match="must not contain duplicates"):
+ _manifest(seeds=(0, 1, 1))
+ with pytest.raises(ManifestError, match="schema_version must be"):
+ _manifest(schema_version="v0")
+
+
+def test_unknown_keys_and_metrics_rejected() -> None:
+ """Mappings reject extra keys; metrics are allowlisted."""
+ mapping = manifest_to_mapping(_manifest())
+ mapping["unexpected"] = True
+ with pytest.raises(ManifestError, match="unknown manifest keys"):
+ manifest_from_mapping(mapping)
+ with pytest.raises(ManifestError, match="metrics must be a subset"):
+ _manifest(metrics=("mae", "crps"))
+ with pytest.raises(ManifestError, match="controls must be a subset"):
+ _manifest(controls=("pernode", "sota"))
+
+
+def test_mapping_round_trip_preserves_nested_records() -> None:
+ """``asdict`` lists round-trip through ``manifest_from_mapping``."""
+ original = _manifest(
+ uq=UQSpec(method="conformal", coverage=0.9),
+ ood_shifts=(OODShiftSpec(name="rewire", kind="rewire", description="p=0.1"),),
+ methods=(
+ MethodSpec(name="graph_koopman", role="koopman"),
+ MethodSpec(
+ name="stgcn",
+ role="teaching_gnn",
+ deviations=("teaching-scale channels",),
+ ),
+ ),
+ )
+ restored = manifest_from_mapping(manifest_to_mapping(original))
+ assert restored == original
diff --git a/tests/benchmark/test_package.py b/tests/benchmark/test_package.py
new file mode 100644
index 0000000..fad4b80
--- /dev/null
+++ b/tests/benchmark/test_package.py
@@ -0,0 +1,61 @@
+"""Package surface for ``koopman_graph.benchmark``."""
+
+from __future__ import annotations
+
+import pytest
+
+import koopman_graph
+import koopman_graph.benchmark as benchmark
+from koopman_graph.benchmark import (
+ ExperimentManifest,
+ ManifestError,
+ SummaryError,
+ dump_manifest,
+ load_manifest,
+ run_manifest,
+ verify_dataset_hash,
+ verify_summary,
+)
+
+
+def test_benchmark_not_on_root_facade() -> None:
+ """Manifest types stay off the thin root façade."""
+ for name in (
+ "ExperimentManifest",
+ "ManifestError",
+ "SummaryError",
+ "load_manifest",
+ "dump_manifest",
+ "verify_dataset_hash",
+ "run_manifest",
+ "verify_summary",
+ ):
+ assert name not in koopman_graph.__all__
+ assert not hasattr(koopman_graph, name)
+ assert name in benchmark.__all__
+ with pytest.raises(ImportError):
+ exec("from koopman_graph import ExperimentManifest")
+
+
+def test_package_reexports_match_submodules() -> None:
+ """Package ``__all__`` names resolve to the submodule objects."""
+ assert benchmark.ExperimentManifest is ExperimentManifest
+ assert benchmark.ManifestError is ManifestError
+ assert benchmark.SummaryError is SummaryError
+ assert benchmark.load_manifest is load_manifest
+ assert benchmark.dump_manifest is dump_manifest
+ assert benchmark.verify_dataset_hash is verify_dataset_hash
+ assert benchmark.run_manifest is run_manifest
+ assert benchmark.verify_summary is verify_summary
+ assert benchmark.SCHEMA_VERSION == "benchmark_manifest_v1"
+ assert benchmark.SUMMARY_SCHEMA_VERSION == "benchmark_summary_v1"
+ assert benchmark.MIN_MANIFEST_SEEDS == 3
+
+
+def test_benchmark_extra_is_empty() -> None:
+ """``[benchmark]`` is an empty extra; YAML still uses ``[cli]``."""
+ from tests.helpers import REPO_ROOT
+
+ text = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")
+ assert "benchmark = []" in text
+ assert '"pyyaml>=6"' in text
diff --git a/tests/benchmark/test_runner.py b/tests/benchmark/test_runner.py
new file mode 100644
index 0000000..c58d2f0
--- /dev/null
+++ b/tests/benchmark/test_runner.py
@@ -0,0 +1,319 @@
+"""Identity-bound summary hashing for ``benchmark run`` / ``verify``."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from pathlib import Path
+
+import pytest
+
+from koopman_graph import __version__
+from koopman_graph.benchmark import (
+ SCHEMA_VERSION,
+ SUMMARY_FILENAME,
+ SUMMARY_SCHEMA_VERSION,
+ BenchmarkSummary,
+ ComputeBudget,
+ DatasetRef,
+ ExperimentManifest,
+ ManifestError,
+ MethodSpec,
+ PreprocessingSpec,
+ SplitSpec,
+ SummaryError,
+ SummaryMethodRef,
+ build_summary,
+ canonical_sha256,
+ dump_manifest,
+ load_summary,
+ run_manifest,
+ summary_from_mapping,
+ summary_to_mapping,
+ verify_summary,
+)
+
+_BYTES = b"fixture-bytes"
+_DIGEST = hashlib.sha256(_BYTES).hexdigest()
+
+
+def _manifest() -> ExperimentManifest:
+ """Return a valid telemetry manifest.
+
+ Returns
+ -------
+ ExperimentManifest
+ Toy record.
+ """
+ return ExperimentManifest(
+ manifest_id="smoke-telemetry",
+ schema_version=SCHEMA_VERSION,
+ track="telemetry",
+ dataset=DatasetRef(
+ name="toy-path",
+ version="1",
+ sha256=_DIGEST,
+ card="docs/data/toy.md",
+ ),
+ split=SplitSpec(0.7, 0.1, 0.2, history_len=12),
+ preprocessing=PreprocessingSpec(zscore=True),
+ methods=(MethodSpec(name="graph_koopman", role="koopman"),),
+ seeds=(0, 1, 2),
+ horizons=(1, 3, 12),
+ metrics=("mae", "rmse"),
+ compute_budget=ComputeBudget(max_epochs=2),
+ controls=("pernode",),
+ )
+
+
+def _write_inputs(tmp_path: Path) -> tuple[Path, Path, Path]:
+ """Write a manifest and matching payload.
+
+ Parameters
+ ----------
+ tmp_path : Path
+ Temporary directory.
+
+ Returns
+ -------
+ tuple of Path
+ ``(manifest, data, out_dir)``.
+ """
+ manifest_path = tmp_path / "manifest.json"
+ data_path = tmp_path / "payload.bin"
+ out_dir = tmp_path / "artifacts"
+ dump_manifest(_manifest(), manifest_path)
+ data_path.write_bytes(_BYTES)
+ return manifest_path, data_path, out_dir
+
+
+def test_run_writes_unexecuted_hashed_summary(tmp_path: Path) -> None:
+ """``run_manifest`` writes identity fields and ``executed=False``."""
+ manifest_path, data_path, out_dir = _write_inputs(tmp_path)
+ written = run_manifest(manifest_path, data_path, out_dir)
+ assert written == out_dir / SUMMARY_FILENAME
+ summary = load_summary(written)
+ assert summary.schema_version == SUMMARY_SCHEMA_VERSION
+ assert summary.executed is False
+ assert summary.package_version == __version__
+ assert summary.dataset_sha256 == _DIGEST
+ assert summary.manifest_id == "smoke-telemetry"
+ payload = json.loads(written.read_text(encoding="utf-8"))
+ assert payload["metrics"] == ["mae", "rmse"]
+ assert "results" not in payload
+ body = summary_to_mapping(summary)
+ body.pop("summary_sha256")
+ assert summary.summary_sha256 == canonical_sha256(body)
+
+
+def test_run_rejects_dataset_hash_mismatch(tmp_path: Path) -> None:
+ """Tampered payload bytes fail before a summary is written."""
+ manifest_path, data_path, out_dir = _write_inputs(tmp_path)
+ data_path.write_bytes(b"tampered-bytes")
+ with pytest.raises(ManifestError, match="SHA256 mismatch"):
+ run_manifest(manifest_path, data_path, out_dir)
+ assert not (out_dir / SUMMARY_FILENAME).exists()
+
+
+def test_verify_accepts_directory_or_file(tmp_path: Path) -> None:
+ """``--against`` may be the artifact directory or ``summary.json``."""
+ manifest_path, data_path, out_dir = _write_inputs(tmp_path)
+ run_manifest(manifest_path, data_path, out_dir)
+ from_dir = verify_summary(manifest_path, out_dir)
+ from_file = verify_summary(manifest_path, out_dir / SUMMARY_FILENAME)
+ assert from_dir == from_file
+ assert from_dir.executed is False
+
+
+def test_verify_fails_on_tampered_summary_hash(tmp_path: Path) -> None:
+ """Changing ``summary_sha256`` without matching the body fails."""
+ manifest_path, data_path, out_dir = _write_inputs(tmp_path)
+ written = run_manifest(manifest_path, data_path, out_dir)
+ payload = json.loads(written.read_text(encoding="utf-8"))
+ payload["summary_sha256"] = "0" * 64
+ written.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
+ with pytest.raises(SummaryError, match="summary_sha256 mismatch"):
+ verify_summary(manifest_path, out_dir)
+
+
+def test_verify_fails_on_tampered_field(tmp_path: Path) -> None:
+ """Changing a bound field without updating the digest fails."""
+ manifest_path, data_path, out_dir = _write_inputs(tmp_path)
+ written = run_manifest(manifest_path, data_path, out_dir)
+ payload = json.loads(written.read_text(encoding="utf-8"))
+ payload["track"] = "multiphysics"
+ written.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
+ with pytest.raises(SummaryError, match="summary_sha256 mismatch"):
+ verify_summary(manifest_path, out_dir)
+
+
+def test_verify_fails_when_rehashed_field_unbound(tmp_path: Path) -> None:
+ """Rehashing after changing identity still fails the manifest bind."""
+ manifest_path, data_path, out_dir = _write_inputs(tmp_path)
+ written = run_manifest(manifest_path, data_path, out_dir)
+ payload = json.loads(written.read_text(encoding="utf-8"))
+ payload["track"] = "multiphysics"
+ body = {key: value for key, value in payload.items() if key != "summary_sha256"}
+ payload["summary_sha256"] = canonical_sha256(body)
+ written.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
+ with pytest.raises(SummaryError, match="track"):
+ verify_summary(manifest_path, out_dir)
+
+
+def test_build_summary_is_deterministic() -> None:
+ """Canonical hashing does not depend on dict insertion order."""
+ first = build_summary(_manifest())
+ second = build_summary(_manifest())
+ assert first.summary_sha256 == second.summary_sha256
+ assert first.manifest_sha256 == second.manifest_sha256
+
+
+def test_load_summary_rejects_invalid_json(tmp_path: Path) -> None:
+ """A non-mapping JSON root is a ``SummaryError``."""
+ path = tmp_path / "summary.json"
+ path.write_text("[1, 2]\n", encoding="utf-8")
+ with pytest.raises(SummaryError, match="mapping"):
+ load_summary(path)
+
+
+def test_load_summary_rejects_malformed_json(tmp_path: Path) -> None:
+ """Broken JSON text is a ``SummaryError``."""
+ path = tmp_path / "summary.json"
+ path.write_text("{not json", encoding="utf-8")
+ with pytest.raises(SummaryError, match="Invalid JSON"):
+ load_summary(path)
+
+
+def test_load_summary_missing_file(tmp_path: Path) -> None:
+ """Missing ``summary.json`` is a ``SummaryError``."""
+ with pytest.raises(SummaryError, match="not found"):
+ load_summary(tmp_path / "missing.json")
+
+
+def test_run_missing_data_file(tmp_path: Path) -> None:
+ """A missing ``--data`` path raises ``FileNotFoundError``."""
+ manifest_path, data_path, out_dir = _write_inputs(tmp_path)
+ data_path.unlink()
+ with pytest.raises(FileNotFoundError):
+ run_manifest(manifest_path, data_path, out_dir)
+
+
+def _summary_kwargs(**overrides: object) -> dict[str, object]:
+ """Return valid :class:`BenchmarkSummary` constructor kwargs.
+
+ Parameters
+ ----------
+ **overrides
+ Field replacements.
+
+ Returns
+ -------
+ dict
+ Keyword arguments for :class:`BenchmarkSummary`.
+ """
+ payload: dict[str, object] = {
+ "schema_version": SUMMARY_SCHEMA_VERSION,
+ "manifest_id": "smoke-telemetry",
+ "manifest_sha256": _DIGEST,
+ "dataset_sha256": _DIGEST,
+ "track": "telemetry",
+ "methods": (SummaryMethodRef(name="graph_koopman", role="koopman"),),
+ "seeds": (0, 1, 2),
+ "horizons": (1, 3, 12),
+ "metrics": ("mae", "rmse"),
+ "controls": ("pernode",),
+ "package_version": "0.0.0",
+ "executed": False,
+ "summary_sha256": _DIGEST,
+ }
+ payload.update(overrides)
+ return payload
+
+
+def _rehash_summary_file(path: Path, **updates: object) -> None:
+ """Rewrite identity fields and refresh ``summary_sha256``.
+
+ Parameters
+ ----------
+ path : Path
+ ``summary.json`` path.
+ **updates
+ Mapping keys to overwrite before hashing.
+ """
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ payload.update(updates)
+ body = {key: value for key, value in payload.items() if key != "summary_sha256"}
+ payload["summary_sha256"] = canonical_sha256(body)
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
+
+
+def test_summary_method_ref_rejects_empty_fields() -> None:
+ """Empty method name or role is a ``SummaryError``."""
+ with pytest.raises(SummaryError, match="summary.method.name"):
+ SummaryMethodRef(name="", role="koopman")
+ with pytest.raises(SummaryError, match="summary.method.role"):
+ SummaryMethodRef(name="graph_koopman", role="")
+
+
+def test_benchmark_summary_rejects_schema_and_identity_fields() -> None:
+ """Schema, empty identity, empty methods, and bad method types raise."""
+ with pytest.raises(SummaryError, match="schema_version"):
+ BenchmarkSummary(**_summary_kwargs(schema_version="benchmark_summary_v0"))
+ with pytest.raises(SummaryError, match="summary.track"):
+ BenchmarkSummary(**_summary_kwargs(track=""))
+ with pytest.raises(SummaryError, match="must be non-empty"):
+ BenchmarkSummary(**_summary_kwargs(methods=()))
+ with pytest.raises(SummaryError, match="SummaryMethodRef"):
+ BenchmarkSummary(**_summary_kwargs(methods=("graph_koopman",)))
+
+
+def test_benchmark_summary_rejects_invalid_digests() -> None:
+ """Digest fields must be 64-character hexadecimal strings."""
+ with pytest.raises(SummaryError, match="64-character hex"):
+ BenchmarkSummary(**_summary_kwargs(manifest_sha256="abc"))
+ with pytest.raises(SummaryError, match="64-character hex"):
+ BenchmarkSummary(**_summary_kwargs(dataset_sha256="g" * 64))
+
+
+def test_summary_from_mapping_rejects_shape_and_keys() -> None:
+ """Mapping load rejects non-objects, unknown keys, and incomplete methods."""
+ with pytest.raises(SummaryError, match="must be a mapping"):
+ summary_from_mapping([1, 2]) # type: ignore[arg-type]
+ valid = summary_to_mapping(build_summary(_manifest()))
+ extra = dict(valid)
+ extra["unexpected"] = True
+ with pytest.raises(SummaryError, match="unknown summary keys"):
+ summary_from_mapping(extra)
+ missing = {key: value for key, value in valid.items() if key != "track"}
+ with pytest.raises(SummaryError, match="missing required keys"):
+ summary_from_mapping(missing)
+ as_string = dict(valid)
+ as_string["methods"] = "graph_koopman"
+ with pytest.raises(SummaryError, match="sequence of mappings"):
+ summary_from_mapping(as_string)
+ with pytest.raises(SummaryError, match="must be a mapping"):
+ summary_from_mapping({**valid, "methods": ["graph_koopman"]})
+ with pytest.raises(SummaryError, match="missing required keys"):
+ summary_from_mapping({**valid, "methods": [{"name": "graph_koopman"}]})
+
+
+def test_verify_fails_when_rehashed_identity_unbound(tmp_path: Path) -> None:
+ """Each bound identity field is checked after a matching digest."""
+ manifest_path, data_path, out_dir = _write_inputs(tmp_path)
+ written = run_manifest(manifest_path, data_path, out_dir)
+ cases: tuple[tuple[dict[str, object], str], ...] = (
+ ({"manifest_sha256": "a" * 64}, "manifest_sha256"),
+ ({"manifest_id": "other-id"}, "manifest_id"),
+ ({"dataset_sha256": "b" * 64}, "dataset_sha256"),
+ ({"methods": [{"name": "other", "role": "koopman"}]}, "methods"),
+ ({"seeds": [9, 8, 7]}, "seeds"),
+ ({"horizons": [2, 4]}, "horizons"),
+ ({"metrics": ["mae"]}, "metrics"),
+ ({"controls": ["joint_ls"]}, "controls"),
+ )
+ original = written.read_text(encoding="utf-8")
+ for updates, match in cases:
+ written.write_text(original, encoding="utf-8")
+ _rehash_summary_file(written, **updates)
+ with pytest.raises(SummaryError, match=match):
+ verify_summary(manifest_path, out_dir)
diff --git a/tests/benchmark/test_smoke.py b/tests/benchmark/test_smoke.py
new file mode 100644
index 0000000..85b74e5
--- /dev/null
+++ b/tests/benchmark/test_smoke.py
@@ -0,0 +1,121 @@
+"""Identity-bound smoke manifests under ``benchmarks/v0.15/``."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+from tests.helpers import REPO_ROOT
+
+from koopman_graph.benchmark import (
+ SummaryError,
+ load_manifest,
+ load_summary,
+ run_manifest,
+ verify_dataset_hash,
+ verify_summary,
+)
+
+pytest.importorskip("yaml")
+
+pytestmark = pytest.mark.benchmark_smoke
+
+_SMOKE_ROOT = REPO_ROOT / "benchmarks" / "v0.15"
+_TRACKS = (
+ "smoke_telemetry",
+ "smoke_multiphysics",
+ "smoke_topology_transfer",
+)
+_REQUIRED_CONTROLS = {
+ "telemetry": frozenset({"pernode"}),
+ "multiphysics": frozenset({"joint_ls"}),
+ "topology_transfer": frozenset({"hold_last", "pernode", "joint_ls"}),
+}
+
+
+def _manifest_path(name: str) -> Path:
+ """Return the tracked YAML path for ``name``.
+
+ Parameters
+ ----------
+ name : str
+ Stem such as ``smoke_telemetry``.
+
+ Returns
+ -------
+ Path
+ Manifest path.
+ """
+ return _SMOKE_ROOT / f"{name}.yaml"
+
+
+def _data_path(name: str) -> Path:
+ """Return the tracked payload path for ``name``.
+
+ Parameters
+ ----------
+ name : str
+ Stem such as ``smoke_telemetry``.
+
+ Returns
+ -------
+ Path
+ Payload path.
+ """
+ return _SMOKE_ROOT / "data" / f"{name}.txt"
+
+
+def _summary_path(name: str) -> Path:
+ """Return the checked-in summary path for ``name``.
+
+ Parameters
+ ----------
+ name : str
+ Stem such as ``smoke_telemetry``.
+
+ Returns
+ -------
+ Path
+ Summary JSON path.
+ """
+ return _SMOKE_ROOT / "summaries" / f"{name}.json"
+
+
+@pytest.mark.parametrize("name", _TRACKS)
+def test_smoke_manifest_controls_and_payload(name: str) -> None:
+ """Each track stub lists required controls and a matching payload digest."""
+ manifest = load_manifest(_manifest_path(name))
+ required = _REQUIRED_CONTROLS[manifest.track]
+ assert required <= set(manifest.controls)
+ assert all(method.role != "teaching_gnn" for method in manifest.methods)
+ assert any(method.role == "koopman" for method in manifest.methods)
+ verify_dataset_hash(manifest.dataset, _data_path(name))
+ checked = load_summary(_summary_path(name))
+ assert checked.executed is False
+ assert checked.manifest_id == manifest.manifest_id
+
+
+@pytest.mark.parametrize("name", _TRACKS)
+def test_smoke_run_then_verify(name: str, tmp_path: Path) -> None:
+ """``run_manifest`` then ``verify_summary`` succeeds on the stand-in."""
+ written = run_manifest(_manifest_path(name), _data_path(name), tmp_path)
+ live = verify_summary(_manifest_path(name), written)
+ assert live.executed is False
+ checked = verify_summary(_manifest_path(name), _summary_path(name))
+ assert checked.manifest_id == live.manifest_id
+ assert checked.dataset_sha256 == live.dataset_sha256
+ assert checked.summary_sha256 == live.summary_sha256 or (
+ checked.package_version != live.package_version
+ )
+
+
+def test_checked_in_summary_tamper_fails(tmp_path: Path) -> None:
+ """Tampering a checked-in ``summary_sha256`` fails ``verify``."""
+ source = _summary_path("smoke_telemetry")
+ tampered = tmp_path / "summary.json"
+ payload = json.loads(source.read_text(encoding="utf-8"))
+ payload["summary_sha256"] = "0" * 64
+ tampered.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
+ with pytest.raises(SummaryError, match="summary_sha256 mismatch"):
+ verify_summary(_manifest_path("smoke_telemetry"), tampered)
diff --git a/tests/cli/test_benchmark.py b/tests/cli/test_benchmark.py
new file mode 100644
index 0000000..fe1c98a
--- /dev/null
+++ b/tests/cli/test_benchmark.py
@@ -0,0 +1,186 @@
+"""CLI tests for ``koopman-graph benchmark run`` / ``verify``."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from pathlib import Path
+
+import pytest
+
+from koopman_graph.benchmark import (
+ SCHEMA_VERSION,
+ SUMMARY_FILENAME,
+ ComputeBudget,
+ DatasetRef,
+ ExperimentManifest,
+ MethodSpec,
+ PreprocessingSpec,
+ SplitSpec,
+ dump_manifest,
+)
+from koopman_graph.cli import main
+
+_BYTES = b"fixture-bytes"
+_DIGEST = hashlib.sha256(_BYTES).hexdigest()
+
+
+def _write_inputs(tmp_path: Path) -> tuple[Path, Path, Path]:
+ """Write a telemetry manifest and matching payload.
+
+ Parameters
+ ----------
+ tmp_path : Path
+ Temporary directory.
+
+ Returns
+ -------
+ tuple of Path
+ ``(manifest, data, out_dir)``.
+ """
+ manifest = ExperimentManifest(
+ manifest_id="smoke-telemetry",
+ schema_version=SCHEMA_VERSION,
+ track="telemetry",
+ dataset=DatasetRef(
+ name="toy-path",
+ version="1",
+ sha256=_DIGEST,
+ card="docs/data/toy.md",
+ ),
+ split=SplitSpec(0.7, 0.1, 0.2, history_len=12),
+ preprocessing=PreprocessingSpec(zscore=True),
+ methods=(MethodSpec(name="graph_koopman", role="koopman"),),
+ seeds=(0, 1, 2),
+ horizons=(1, 3, 12),
+ metrics=("mae", "rmse"),
+ compute_budget=ComputeBudget(max_epochs=2),
+ controls=("pernode",),
+ )
+ manifest_path = tmp_path / "manifest.json"
+ data_path = tmp_path / "payload.bin"
+ out_dir = tmp_path / "artifacts"
+ dump_manifest(manifest, manifest_path)
+ data_path.write_bytes(_BYTES)
+ return manifest_path, data_path, out_dir
+
+
+def test_benchmark_help_lists_run_and_verify(
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ """``koopman-graph benchmark --help`` lists ``run`` and ``verify``."""
+ with pytest.raises(SystemExit) as exc_info:
+ main(["benchmark", "--help"])
+ assert exc_info.value.code == 0
+ help_text = capsys.readouterr().out
+ assert "run" in help_text
+ assert "verify" in help_text
+
+
+def test_benchmark_without_subcommand_prints_help(
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ """Bare ``benchmark`` prints nested help and exits 0."""
+ assert main(["benchmark"]) == 0
+ help_text = capsys.readouterr().out
+ assert "run" in help_text
+ assert "verify" in help_text
+
+
+def test_benchmark_run_requires_data(tmp_path: Path) -> None:
+ """``run`` without ``--data`` is an argparse error."""
+ manifest_path, _, out_dir = _write_inputs(tmp_path)
+ with pytest.raises(SystemExit) as exc_info:
+ main(
+ [
+ "benchmark",
+ "run",
+ "--manifest",
+ str(manifest_path),
+ "--out",
+ str(out_dir),
+ ]
+ )
+ assert exc_info.value.code == 2
+
+
+def test_benchmark_run_and_verify_succeed(
+ tmp_path: Path,
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ """``run`` then ``verify`` against the artifact directory exits 0."""
+ manifest_path, data_path, out_dir = _write_inputs(tmp_path)
+ run_code = main(
+ [
+ "benchmark",
+ "run",
+ "--manifest",
+ str(manifest_path),
+ "--data",
+ str(data_path),
+ "--out",
+ str(out_dir),
+ ]
+ )
+ assert run_code == 0
+ summary_path = out_dir / SUMMARY_FILENAME
+ assert summary_path.is_file()
+ payload = json.loads(summary_path.read_text(encoding="utf-8"))
+ assert payload["executed"] is False
+
+ verify_code = main(
+ [
+ "benchmark",
+ "verify",
+ "--manifest",
+ str(manifest_path),
+ "--against",
+ str(out_dir),
+ ]
+ )
+ assert verify_code == 0
+ out = capsys.readouterr().out
+ assert "wrote summary:" in out
+ assert "verified summary:" in out
+
+
+def test_benchmark_verify_tampered_hash_exits_one(
+ tmp_path: Path,
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ """Tampering ``summary_sha256`` exits 1 with ``error:`` on stderr."""
+ manifest_path, data_path, out_dir = _write_inputs(tmp_path)
+ assert (
+ main(
+ [
+ "benchmark",
+ "run",
+ "--manifest",
+ str(manifest_path),
+ "--data",
+ str(data_path),
+ "--out",
+ str(out_dir),
+ ]
+ )
+ == 0
+ )
+ written = out_dir / SUMMARY_FILENAME
+ payload = json.loads(written.read_text(encoding="utf-8"))
+ payload["summary_sha256"] = "0" * 64
+ written.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
+ capsys.readouterr()
+ code = main(
+ [
+ "benchmark",
+ "verify",
+ "--manifest",
+ str(manifest_path),
+ "--against",
+ str(out_dir),
+ ]
+ )
+ assert code == 1
+ err = capsys.readouterr().err
+ assert "error:" in err
+ assert "summary_sha256" in err
diff --git a/tests/data/test_calendar.py b/tests/data/test_calendar.py
new file mode 100644
index 0000000..e3ec55b
--- /dev/null
+++ b/tests/data/test_calendar.py
@@ -0,0 +1,101 @@
+"""Tests for time-of-day control and phase-index recipes."""
+
+from __future__ import annotations
+
+import ast
+import math
+from pathlib import Path
+
+import pytest
+import torch
+
+from koopman_graph.data import diurnal_control_features, diurnal_phase_index
+from koopman_graph.data.calendar import MAX_HARMONICS
+
+
+def test_diurnal_control_features_midpoint_and_period_wrap() -> None:
+ """Quarter-period is (1, 0); times 0 and T match."""
+ period = 24.0
+ features = diurnal_control_features(
+ torch.tensor([0.0, period / 4.0, period]),
+ period=period,
+ )
+ assert features.shape == (3, 2)
+ assert features.dtype == torch.float32
+ torch.testing.assert_close(features[0], torch.tensor([0.0, 1.0]))
+ torch.testing.assert_close(
+ features[1],
+ torch.tensor([1.0, 0.0]),
+ atol=1e-6,
+ rtol=0.0,
+ )
+ torch.testing.assert_close(features[2], features[0])
+
+
+def test_diurnal_control_features_second_harmonic_shape() -> None:
+ """Two harmonics yield four columns: sin/cos for k=1 and k=2."""
+ period = 8.0
+ times = torch.tensor([0.0, period / 4.0])
+ features = diurnal_control_features(times, period=period, harmonics=2)
+ assert features.shape == (2, 4)
+ quarter = features[1]
+ expected = torch.tensor(
+ [
+ math.sin(math.pi / 2.0),
+ math.cos(math.pi / 2.0),
+ math.sin(math.pi),
+ math.cos(math.pi),
+ ],
+ dtype=torch.float32,
+ )
+ torch.testing.assert_close(quarter, expected, atol=1e-6, rtol=0.0)
+
+
+def test_diurnal_phase_index_bins_and_wrap() -> None:
+ """Phase bins use floor(n * frac(t/T)) and wrap at the period."""
+ period = 24.0
+ times = torch.tensor([0.0, 6.0, 12.0, 24.0])
+ bins = diurnal_phase_index(times, period=period, num_phases=4)
+ assert bins.dtype == torch.int64
+ assert bins.tolist() == [0, 1, 2, 0]
+
+
+def test_diurnal_helpers_reject_invalid_inputs() -> None:
+ """Empty, non-1-D, non-finite, and bound errors raise."""
+ with pytest.raises(ValueError, match="at least one"):
+ diurnal_control_features(torch.tensor([]), period=1.0)
+ with pytest.raises(ValueError, match="1-D"):
+ diurnal_control_features(torch.zeros(2, 2), period=1.0)
+ with pytest.raises(ValueError, match="finite"):
+ diurnal_control_features(torch.tensor([float("nan")]), period=1.0)
+ with pytest.raises(ValueError, match="positive finite"):
+ diurnal_control_features(torch.tensor([0.0]), period=0.0)
+ with pytest.raises(ValueError, match="harmonics"):
+ diurnal_control_features(torch.tensor([0.0]), period=1.0, harmonics=0)
+ with pytest.raises(ValueError, match="harmonics"):
+ diurnal_control_features(
+ torch.tensor([0.0]),
+ period=1.0,
+ harmonics=MAX_HARMONICS + 1,
+ )
+ with pytest.raises(ValueError, match="num_phases"):
+ diurnal_phase_index(torch.tensor([0.0]), period=1.0, num_phases=0)
+
+
+def test_calendar_module_does_not_import_nn_or_model() -> None:
+ """Calendar recipes must not import L4 model or L3 nn."""
+ source = Path(__file__).resolve().parents[2] / "src/koopman_graph/data/calendar.py"
+ tree = ast.parse(source.read_text(encoding="utf-8"))
+ imported: list[str] = []
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ imported.extend(alias.name for alias in node.names)
+ elif isinstance(node, ast.ImportFrom) and node.module is not None:
+ imported.append(node.module)
+ forbidden = ("koopman_graph.nn", "koopman_graph.model")
+ offenders = [
+ name
+ for name in imported
+ if any(name == prefix or name.startswith(f"{prefix}.") for prefix in forbidden)
+ ]
+ assert not offenders
diff --git a/tests/data/test_conditioning.py b/tests/data/test_conditioning.py
new file mode 100644
index 0000000..324897b
--- /dev/null
+++ b/tests/data/test_conditioning.py
@@ -0,0 +1,325 @@
+"""Tests for homogeneous regime coordinates and ConditioningContext."""
+
+from __future__ import annotations
+
+from dataclasses import FrozenInstanceError
+
+import pytest
+import torch
+from torch_geometric.data import Data, HeteroData
+
+import koopman_graph
+from koopman_graph.data import (
+ ConditioningContext,
+ GraphSnapshotSequence,
+ HeteroGraphSnapshotSequence,
+ collate_graph_trajectories,
+ conditioning_at,
+ diurnal_control_features,
+ diurnal_phase_index,
+ temporal_split,
+)
+from koopman_graph.data.sampling import induce_neighbor_subgraph_sequence
+from koopman_graph.model import GraphKoopmanModel
+from koopman_graph.nn import GNNDecoder, GNNEncoder
+from koopman_graph.training.device import sequence_to_device
+
+
+def _path_edge_index(num_nodes: int) -> torch.Tensor:
+ """Return a bidirectional path graph.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count (at least 2).
+
+ Returns
+ -------
+ Tensor
+ COO index with shape ``(2, 2 * (num_nodes - 1))``.
+ """
+ src = list(range(num_nodes - 1)) + list(range(1, num_nodes))
+ dst = list(range(1, num_nodes)) + list(range(num_nodes - 1))
+ return torch.tensor([src, dst], dtype=torch.long)
+
+
+def _features(
+ *,
+ timesteps: int = 10,
+ nodes: int = 4,
+ channels: int = 2,
+) -> torch.Tensor:
+ """Return a deterministic feature cube.
+
+ Parameters
+ ----------
+ timesteps, nodes, channels : int
+ Cube dimensions.
+
+ Returns
+ -------
+ Tensor
+ Features with shape ``(T, N, F)``.
+ """
+ return torch.arange(timesteps * nodes * channels, dtype=torch.float32).reshape(
+ timesteps, nodes, channels
+ )
+
+
+def test_from_arrays_round_trip_slice_split_and_windowed() -> None:
+ """parameter_trajectory survives from_arrays, slice, split, and windowed."""
+ features = _features()
+ edge_index = _path_edge_index(4)
+ mu = torch.stack(
+ [torch.tensor([0.1 * t, 1.0 + 0.1 * t], dtype=torch.float32) for t in range(10)]
+ )
+ times = torch.arange(10, dtype=torch.float32)
+ controls = torch.arange(20, dtype=torch.float32).reshape(10, 2)
+ sequence = GraphSnapshotSequence.from_arrays(
+ features,
+ edge_index,
+ timestamps=times,
+ parameter_trajectory=mu,
+ control_inputs=controls,
+ )
+ assert sequence.has_parameter_trajectory
+ torch.testing.assert_close(sequence.parameter_trajectory, mu)
+
+ window = sequence.slice(1, 4)
+ torch.testing.assert_close(window.parameter_trajectory, mu[1:4])
+ torch.testing.assert_close(window.timestamps, times[1:4])
+ torch.testing.assert_close(window.control_inputs, controls[1:4])
+
+ split = temporal_split(sequence)
+ assert split.train.parameter_trajectory is not None
+ torch.testing.assert_close(
+ split.train.parameter_trajectory,
+ mu[: split.train.num_timesteps],
+ )
+ assert split.val.parameter_trajectory is not None
+ train_end = split.train.num_timesteps
+ val_end = train_end + split.val.num_timesteps
+ torch.testing.assert_close(split.val.parameter_trajectory, mu[train_end:val_end])
+ assert split.test.parameter_trajectory is not None
+ torch.testing.assert_close(split.test.parameter_trajectory, mu[val_end:])
+
+ stacked = sequence.windowed(n_delays=2, stride=2, pad=True)
+ torch.testing.assert_close(stacked.parameter_trajectory, mu[[0, 2, 4, 6, 8]])
+ torch.testing.assert_close(stacked.timestamps, times[[0, 2, 4, 6, 8]])
+
+
+def test_from_dynamic_arrays_preserves_parameters() -> None:
+ """Dynamic-topology arrays keep the aligned mu rows."""
+ features = _features(timesteps=3, nodes=3, channels=1)
+ edges = [_path_edge_index(3) for _ in range(3)]
+ mu = torch.tensor([[1.0], [2.0], [3.0]])
+ sequence = GraphSnapshotSequence.from_dynamic_arrays(
+ features,
+ edges,
+ parameter_trajectory=mu,
+ )
+ assert sequence.allow_dynamic_topology
+ torch.testing.assert_close(sequence.parameter_trajectory, mu)
+
+
+def test_conditioning_at_fills_time_parameters_and_control() -> None:
+ """conditioning_at copies time, mu, and control; phase_index stays None."""
+ features = _features(timesteps=3, nodes=3, channels=2)
+ sequence = GraphSnapshotSequence.from_arrays(
+ features,
+ _path_edge_index(3),
+ timestamps=torch.tensor([10.0, 20.0, 30.0]),
+ parameter_trajectory=torch.tensor([[0.5, -1.0], [1.5, -2.0], [2.5, -3.0]]),
+ control_inputs=torch.arange(6, dtype=torch.float32).reshape(3, 2),
+ )
+ ctx = conditioning_at(sequence, 1)
+ assert ctx.time == 20.0
+ assert ctx.parameters is not None
+ torch.testing.assert_close(ctx.parameters, torch.tensor([1.5, -2.0]))
+ assert ctx.control is not None
+ torch.testing.assert_close(ctx.control, torch.tensor([2.0, 3.0]))
+ assert ctx.phase_index is None
+
+
+def test_conditioning_at_all_none_is_valid() -> None:
+ """Missing timestamps, mu, and controls yield an empty record."""
+ features = _features(timesteps=2, nodes=3, channels=1)
+ sequence = GraphSnapshotSequence.from_arrays(features, _path_edge_index(3))
+ ctx = conditioning_at(sequence, 0)
+ assert ctx.time is None
+ assert ctx.parameters is None
+ assert ctx.control is None
+ assert ctx.phase_index is None
+
+
+def test_conditioning_at_per_node_control_keeps_node_rows() -> None:
+ """Per-node controls at an index have shape (N, C)."""
+ features = _features(timesteps=2, nodes=3, channels=1)
+ controls = torch.arange(12, dtype=torch.float32).reshape(2, 3, 2)
+ sequence = GraphSnapshotSequence.from_arrays(
+ features,
+ _path_edge_index(3),
+ control_inputs=controls,
+ )
+ ctx = conditioning_at(sequence, 1)
+ assert ctx.control is not None
+ torch.testing.assert_close(ctx.control, controls[1])
+
+
+def test_conditioning_at_optional_phase_index_from_timestamps() -> None:
+ """period and num_phases fill phase_index; default stays None."""
+ features = _features(timesteps=3, nodes=3, channels=1)
+ sequence = GraphSnapshotSequence.from_arrays(
+ features,
+ _path_edge_index(3),
+ timestamps=torch.tensor([0.0, 6.0, 12.0]),
+ )
+ default = conditioning_at(sequence, 1)
+ assert default.phase_index is None
+ filled = conditioning_at(sequence, 1, period=24.0, num_phases=4)
+ assert filled.phase_index == 1
+ with pytest.raises(ValueError, match="together"):
+ conditioning_at(sequence, 1, period=24.0)
+ with pytest.raises(ValueError, match="together"):
+ conditioning_at(sequence, 1, num_phases=4)
+ no_time = GraphSnapshotSequence.from_arrays(features, _path_edge_index(3))
+ with pytest.raises(ValueError, match="timestamps"):
+ conditioning_at(no_time, 0, period=24.0, num_phases=4)
+
+
+def test_conditioning_at_rejects_bad_index_and_hetero() -> None:
+ """Out-of-range indices and hetero sequences are refused."""
+ features = _features(timesteps=2, nodes=3, channels=1)
+ sequence = GraphSnapshotSequence.from_arrays(features, _path_edge_index(3))
+ with pytest.raises(ValueError, match="out of range"):
+ conditioning_at(sequence, 2)
+ hetero = HeteroData()
+ hetero["node"].x = torch.randn(3, 1)
+ hetero["node", "r", "node"].edge_index = _path_edge_index(3)
+ hetero_seq = HeteroGraphSnapshotSequence([hetero, hetero])
+ with pytest.raises(TypeError, match="GraphSnapshotSequence"):
+ conditioning_at(hetero_seq, 0) # type: ignore[arg-type]
+
+
+@pytest.mark.parametrize(
+ "bad, match",
+ [
+ (torch.arange(10, dtype=torch.float32), "shape"),
+ (torch.zeros(10, 0), "d_mu"),
+ (torch.zeros(5, 2), "timesteps"),
+ (torch.tensor([[1.0, float("nan")]]), "finite"),
+ ],
+)
+def test_parameter_trajectory_rejects_invalid_tensors(
+ bad: torch.Tensor,
+ match: str,
+) -> None:
+ """Invalid mu stacks raise with a shape or finiteness message."""
+ features = _features(timesteps=10, nodes=3, channels=1)
+ if bad.shape[0] == 1:
+ features = _features(timesteps=1, nodes=3, channels=1)
+ with pytest.raises(ValueError, match=match):
+ GraphSnapshotSequence.from_arrays(
+ features,
+ _path_edge_index(3),
+ parameter_trajectory=bad,
+ )
+
+
+def test_parameter_trajectory_rejects_bool_dtype() -> None:
+ """Boolean mu stacks are refused at the sequence boundary."""
+ snapshots = [
+ Data(x=torch.randn(3, 1), edge_index=_path_edge_index(3)) for _ in range(2)
+ ]
+ with pytest.raises(ValueError, match="real numeric"):
+ GraphSnapshotSequence(
+ snapshots,
+ parameter_trajectory=torch.zeros(2, 1, dtype=torch.bool),
+ )
+
+
+def test_conditioning_context_validates_fields() -> None:
+ """Frozen context rejects non-finite time, empty mu, and bad phase."""
+ with pytest.raises(ValueError, match="finite float"):
+ ConditioningContext(time=float("inf"), parameters=None, control=None)
+ with pytest.raises(ValueError, match="d_mu"):
+ ConditioningContext(
+ time=None,
+ parameters=torch.tensor([]),
+ control=None,
+ )
+ with pytest.raises(ValueError, match="phase_index"):
+ ConditioningContext(time=None, parameters=None, control=None, phase_index=-1)
+ with pytest.raises(FrozenInstanceError):
+ ctx = ConditioningContext(time=1.0, parameters=None, control=None)
+ ctx.time = 2.0 # type: ignore[misc]
+
+
+def test_collate_rejects_parameter_trajectory() -> None:
+ """Batched collate refuses regime coordinates, like timestamps."""
+ features = _features(timesteps=2, nodes=3, channels=1)
+ sequence = GraphSnapshotSequence.from_arrays(
+ features,
+ _path_edge_index(3),
+ parameter_trajectory=torch.zeros(2, 1),
+ )
+ with pytest.raises(ValueError, match="parameter_trajectory"):
+ collate_graph_trajectories((sequence,))
+
+
+def test_sequence_to_device_preserves_parameter_trajectory() -> None:
+ """Device copy keeps mu on the target device."""
+ features = _features(timesteps=2, nodes=3, channels=1)
+ mu = torch.tensor([[0.25], [0.75]])
+ sequence = GraphSnapshotSequence.from_arrays(
+ features,
+ _path_edge_index(3),
+ parameter_trajectory=mu,
+ )
+ moved = sequence_to_device(sequence, torch.device("cpu"))
+ assert moved.parameter_trajectory is not None
+ torch.testing.assert_close(moved.parameter_trajectory, mu)
+ assert moved.parameter_trajectory.device.type == "cpu"
+
+
+def test_neighbor_subgraph_copies_global_parameter_trajectory() -> None:
+ """Induced subgraphs copy mu in full; they do not subset columns."""
+ features = _features(timesteps=3, nodes=5, channels=1)
+ mu = torch.tensor([[0.0, 9.0], [1.0, 8.0], [2.0, 7.0]])
+ sequence = GraphSnapshotSequence.from_arrays(
+ features,
+ _path_edge_index(5),
+ parameter_trajectory=mu,
+ )
+ induced = induce_neighbor_subgraph_sequence(
+ sequence,
+ seed_nodes=torch.tensor([0, 1], dtype=torch.long),
+ num_hops=1,
+ )
+ torch.testing.assert_close(induced.parameter_trajectory, mu)
+ assert induced.num_nodes < sequence.num_nodes
+
+
+def test_factory_builds_parametric_kind() -> None:
+ """switched/mixture stay LTI banks; parametric is the interpolant kind."""
+ encoder = GNNEncoder(2, 4, 2)
+ decoder = GNNDecoder(2, 4, 2)
+ model = GraphKoopmanModel(
+ encoder,
+ decoder,
+ latent_dim=2,
+ time_step=0.1,
+ koopman="parametric",
+ )
+ assert model.koopman_kind == "parametric"
+
+
+def test_conditioning_symbols_are_not_on_root_all() -> None:
+ """Q6: ConditioningContext stays off the root façade."""
+ assert "ConditioningContext" not in koopman_graph.__all__
+ assert "conditioning_at" not in koopman_graph.__all__
+ assert "parameter_trajectory" not in koopman_graph.__all__
+ assert "diurnal_control_features" not in koopman_graph.__all__
+ assert "diurnal_phase_index" not in koopman_graph.__all__
+ assert callable(diurnal_control_features)
+ assert callable(diurnal_phase_index)
diff --git a/tests/data/test_graph_batching.py b/tests/data/test_graph_batching.py
new file mode 100644
index 0000000..4998d92
--- /dev/null
+++ b/tests/data/test_graph_batching.py
@@ -0,0 +1,162 @@
+"""Tests for collating independent homogeneous snapshot sequences."""
+
+from __future__ import annotations
+
+import pytest
+import torch
+from torch_geometric.data import Data, HeteroData
+
+import koopman_graph
+from koopman_graph.data import (
+ BatchedGraphTrajectory,
+ GraphSnapshotSequence,
+ HeteroGraphSnapshotSequence,
+ collate_graph_trajectories,
+)
+
+
+def _path_edge_index(num_nodes: int) -> torch.Tensor:
+ """Return a bidirectional path graph on ``num_nodes`` vertices.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count (at least 2).
+
+ Returns
+ -------
+ Tensor
+ COO index with shape ``(2, 2 * (num_nodes - 1))``.
+ """
+ src = list(range(num_nodes - 1)) + list(range(1, num_nodes))
+ dst = list(range(1, num_nodes)) + list(range(num_nodes - 1))
+ return torch.tensor([src, dst], dtype=torch.long)
+
+
+def _sequence(
+ *,
+ num_nodes: int,
+ num_timesteps: int,
+ in_channels: int = 3,
+ seed: int = 0,
+) -> GraphSnapshotSequence:
+ """Build a static-topology homogeneous sequence.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Nodes per snapshot.
+ num_timesteps : int
+ Snapshot count.
+ in_channels : int, optional
+ Feature width. Default is ``3``.
+ seed : int, optional
+ RNG seed. Default is ``0``.
+
+ Returns
+ -------
+ GraphSnapshotSequence
+ Random snapshots on a path graph.
+ """
+ torch.manual_seed(seed)
+ edge_index = _path_edge_index(num_nodes)
+ snapshots = [
+ Data(x=torch.randn(num_nodes, in_channels), edge_index=edge_index)
+ for _ in range(num_timesteps)
+ ]
+ return GraphSnapshotSequence(snapshots)
+
+
+def test_collate_time_major_indices_and_disconnected_union() -> None:
+ """Collate uses time-major components and keeps graphs disconnected."""
+ seq_a = _sequence(num_nodes=3, num_timesteps=4, seed=1)
+ seq_b = _sequence(num_nodes=5, num_timesteps=4, seed=2)
+ batched = collate_graph_trajectories((seq_a, seq_b))
+ assert isinstance(batched, BatchedGraphTrajectory)
+ assert batched.num_trajectories == 2
+ assert batched.num_timesteps == 4
+ assert batched.num_components == 8
+ assert batched.time_index.tolist() == [0, 0, 1, 1, 2, 2, 3, 3]
+ assert batched.graph_index.tolist() == [0, 1, 0, 1, 0, 1, 0, 1]
+ src, dst = batched.batch.edge_index
+ assert torch.equal(batched.batch.batch[src], batched.batch.batch[dst])
+ slice0 = batched.graphs_at_time(0)
+ assert int(slice0.num_graphs) == 2
+ assert int(slice0.num_nodes) == 8
+ with pytest.raises(ValueError, match="timestep"):
+ batched.graphs_at_time(4)
+
+
+def test_collate_does_not_mutate_source_snapshots() -> None:
+ """Source ``Data`` objects are cloned rather than tagged with ``batch``."""
+ seq_a = _sequence(num_nodes=3, num_timesteps=2, seed=3)
+ original = seq_a[0]
+ collate_graph_trajectories((seq_a,))
+ assert seq_a[0] is original
+ assert getattr(original, "batch", None) is None
+
+
+def test_collate_rejects_unequal_lengths_and_hetero() -> None:
+ """Unequal ``T`` and hetero sequences are refused."""
+ short = _sequence(num_nodes=3, num_timesteps=3, seed=4)
+ long = _sequence(num_nodes=3, num_timesteps=4, seed=5)
+ with pytest.raises(ValueError, match="equal num_timesteps"):
+ collate_graph_trajectories((short, long))
+ with pytest.raises(ValueError, match="at least one sequence"):
+ collate_graph_trajectories(())
+
+ hetero = HeteroData()
+ hetero["node"].x = torch.randn(4, 3)
+ hetero["node", "r1", "node"].edge_index = torch.tensor(
+ [[0, 1], [1, 2]], dtype=torch.long
+ )
+ hetero_seq = HeteroGraphSnapshotSequence([hetero, hetero])
+ with pytest.raises(TypeError, match="GraphSnapshotSequence"):
+ collate_graph_trajectories((hetero_seq,)) # type: ignore[arg-type]
+
+
+def test_collate_rejects_controls_hyperedges_timestamps_and_parameters() -> None:
+ """Controls, hyperedges, timestamps, and mu stacks are out of scope."""
+ edge_index = _path_edge_index(3)
+ snapshots = [Data(x=torch.randn(3, 3), edge_index=edge_index) for _ in range(2)]
+ controlled = GraphSnapshotSequence(
+ snapshots,
+ control_inputs=torch.zeros(2, 1),
+ )
+ with pytest.raises(ValueError, match="control_inputs"):
+ collate_graph_trajectories((controlled,))
+
+ hyper_snaps = [
+ Data(
+ x=torch.randn(3, 3),
+ edge_index=edge_index,
+ hyperedge_index=torch.tensor([[0, 1, 2], [0, 0, 0]], dtype=torch.long),
+ )
+ for _ in range(2)
+ ]
+ hyper = GraphSnapshotSequence(hyper_snaps)
+ with pytest.raises(ValueError, match="hyperedge"):
+ collate_graph_trajectories((hyper,))
+
+ stamped = GraphSnapshotSequence(
+ snapshots,
+ timestamps=torch.tensor([0.0, 1.0]),
+ )
+ with pytest.raises(ValueError, match="timestamps"):
+ collate_graph_trajectories((stamped,))
+
+ parameterized = GraphSnapshotSequence(
+ snapshots,
+ parameter_trajectory=torch.zeros(2, 1),
+ )
+ with pytest.raises(ValueError, match="parameter_trajectory"):
+ collate_graph_trajectories((parameterized,))
+
+
+def test_batching_symbols_are_not_on_root_all() -> None:
+ """Collate types stay on the data package, not the root façade."""
+ assert "BatchedGraphTrajectory" not in koopman_graph.__all__
+ assert "collate_graph_trajectories" not in koopman_graph.__all__
+ from koopman_graph.data import BatchedGraphTrajectory as imported
+
+ assert imported is BatchedGraphTrajectory
diff --git a/tests/data/test_graph_state.py b/tests/data/test_graph_state.py
new file mode 100644
index 0000000..f0880eb
--- /dev/null
+++ b/tests/data/test_graph_state.py
@@ -0,0 +1,206 @@
+"""Tests for graph-state snapshots and frozen dynamics config."""
+
+from __future__ import annotations
+
+from dataclasses import FrozenInstanceError
+
+import pytest
+import torch
+from torch_geometric.data import Data
+
+from koopman_graph.data import (
+ DEFAULT_CANDIDATE_K,
+ GraphDynamicsConfig,
+ GraphSnapshotSequence,
+ GraphStateSnapshot,
+ graph_dynamics_from_mapping,
+ graph_state_at,
+)
+
+
+def _path_edge_index(num_nodes: int) -> torch.Tensor:
+ """Return a bidirectional path graph on ``num_nodes`` vertices.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count (at least 2).
+
+ Returns
+ -------
+ Tensor
+ COO index with shape ``(2, 2 * (num_nodes - 1))``.
+ """
+ src = list(range(num_nodes - 1)) + list(range(1, num_nodes))
+ dst = list(range(1, num_nodes)) + list(range(num_nodes - 1))
+ return torch.tensor([src, dst], dtype=torch.long)
+
+
+def _sequence(
+ *,
+ num_nodes: int = 4,
+ num_timesteps: int = 3,
+ in_channels: int = 2,
+ presence: torch.Tensor | None = None,
+ entity_ids: tuple[str, ...] | None = None,
+ edge_weight: torch.Tensor | None = None,
+) -> GraphSnapshotSequence:
+ """Build a static-topology homogeneous sequence.
+
+ Parameters
+ ----------
+ num_nodes : int, optional
+ Nodes per snapshot.
+ num_timesteps : int, optional
+ Snapshot count.
+ in_channels : int, optional
+ Feature width.
+ presence : Tensor or None, optional
+ Optional ``(T, N)`` presence masks.
+ entity_ids : tuple of str or None, optional
+ Optional universe row names.
+ edge_weight : Tensor or None, optional
+ Optional shared edge weights.
+
+ Returns
+ -------
+ GraphSnapshotSequence
+ Sequence on a path graph.
+ """
+ torch.manual_seed(0)
+ edge_index = _path_edge_index(num_nodes)
+ snapshots = []
+ for _ in range(num_timesteps):
+ data = Data(
+ x=torch.randn(num_nodes, in_channels),
+ edge_index=edge_index,
+ )
+ if edge_weight is not None:
+ data.edge_weight = edge_weight
+ snapshots.append(data)
+ return GraphSnapshotSequence(
+ snapshots,
+ presence_masks=presence,
+ entity_ids=entity_ids,
+ allow_node_churn=bool(presence is not None and not bool(presence.all())),
+ )
+
+
+def test_graph_dynamics_config_defaults_are_sparse_candidate() -> None:
+ """Default config selects the sparse-candidate head with ``k=8``."""
+ config = GraphDynamicsConfig()
+ assert config.topology_head == "sparse_candidate"
+ assert config.recursive_training is True
+ assert config.topology_loss_weight == 1.0
+ assert config.presence_loss_weight == 1.0
+ assert config.candidate_k == DEFAULT_CANDIDATE_K == 8
+ mapping = config.to_mapping()
+ restored = graph_dynamics_from_mapping(mapping)
+ assert restored == config
+
+
+def test_graph_dynamics_config_rejects_invalid_fields() -> None:
+ """Head kind, flags, weights, and ``candidate_k`` are validated."""
+ with pytest.raises(ValueError, match="topology_head"):
+ GraphDynamicsConfig(topology_head="self_adaptive") # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="recursive_training"):
+ GraphDynamicsConfig(recursive_training=1) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="topology_loss_weight"):
+ GraphDynamicsConfig(topology_loss_weight=-0.1)
+ with pytest.raises(ValueError, match="presence_loss_weight"):
+ GraphDynamicsConfig(presence_loss_weight=float("inf"))
+ with pytest.raises(ValueError, match="candidate_k"):
+ GraphDynamicsConfig(candidate_k=0)
+ frozen = GraphDynamicsConfig()
+ with pytest.raises(FrozenInstanceError):
+ frozen.topology_head = "none" # type: ignore[misc]
+
+
+def test_graph_dynamics_from_mapping_none_unknown_and_type() -> None:
+ """``None`` is the 0.14 default; unknown keys and non-mappings raise."""
+ assert graph_dynamics_from_mapping(None) is None
+ empty = graph_dynamics_from_mapping({})
+ assert empty is not None
+ assert empty.topology_head == "sparse_candidate"
+ with pytest.raises(TypeError, match="mapping or None"):
+ graph_dynamics_from_mapping(["sparse_candidate"]) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="unknown graph_dynamics keys"):
+ graph_dynamics_from_mapping({"topology_head": "none", "dense_max_nodes": 64})
+
+
+def test_graph_state_snapshot_validates_shapes() -> None:
+ """Snapshot construction rejects rank and length mismatches."""
+ x = torch.randn(3, 2)
+ edge_index = _path_edge_index(3)
+ presence = torch.ones(3, dtype=torch.bool)
+ GraphStateSnapshot(
+ x=x,
+ edge_index=edge_index,
+ edge_weight=None,
+ presence=presence,
+ )
+ with pytest.raises(ValueError, match="x must have shape"):
+ GraphStateSnapshot(
+ x=torch.randn(3),
+ edge_index=edge_index,
+ edge_weight=None,
+ presence=presence,
+ )
+ with pytest.raises(ValueError, match="edge_index must have shape"):
+ GraphStateSnapshot(
+ x=x,
+ edge_index=torch.arange(3),
+ edge_weight=None,
+ presence=presence,
+ )
+ with pytest.raises(ValueError, match="edge_weight must have shape"):
+ GraphStateSnapshot(
+ x=x,
+ edge_index=edge_index,
+ edge_weight=torch.ones(1),
+ presence=presence,
+ )
+ with pytest.raises(ValueError, match="presence must have shape"):
+ GraphStateSnapshot(
+ x=x,
+ edge_index=edge_index,
+ edge_weight=None,
+ presence=torch.ones(2, dtype=torch.bool),
+ )
+ with pytest.raises(ValueError, match="entity_ids length"):
+ GraphStateSnapshot(
+ x=x,
+ edge_index=edge_index,
+ edge_weight=None,
+ presence=presence,
+ entity_ids=("a", "b"),
+ )
+
+
+def test_graph_state_at_copies_features_edges_and_presence() -> None:
+ """Helper returns the requested timestep, including negative indices."""
+ weights = torch.ones(_path_edge_index(4).shape[1])
+ presence = torch.ones(3, 4, dtype=torch.bool)
+ presence[1, 3] = False
+ sequence = _sequence(
+ presence=presence,
+ entity_ids=("a", "b", "c", "d"),
+ edge_weight=weights,
+ )
+ mid = graph_state_at(sequence, 1)
+ assert torch.equal(mid.x, sequence[1].x)
+ assert torch.equal(mid.edge_index, sequence[1].edge_index)
+ assert mid.edge_weight is not None
+ assert torch.equal(mid.edge_weight, weights)
+ assert torch.equal(mid.presence, presence[1])
+ assert mid.entity_ids == ("a", "b", "c", "d")
+ last = graph_state_at(sequence, -1)
+ assert torch.equal(last.x, sequence[2].x)
+ plain = _sequence()
+ filled = graph_state_at(plain, 0)
+ assert bool(filled.presence.all())
+ assert filled.entity_ids is None
+ with pytest.raises(IndexError):
+ graph_state_at(sequence, 9)
+ with pytest.raises(TypeError, match="GraphSnapshotSequence"):
+ graph_state_at([sequence[0]], 0) # type: ignore[arg-type]
diff --git a/tests/data/test_remap.py b/tests/data/test_remap.py
new file mode 100644
index 0000000..5771043
--- /dev/null
+++ b/tests/data/test_remap.py
@@ -0,0 +1,134 @@
+"""Injective entity remap into a fixed union capacity."""
+
+from __future__ import annotations
+
+import pytest
+import torch
+from torch_geometric.data import Data, HeteroData
+
+from koopman_graph.data import (
+ EntityRemap,
+ GraphSnapshotSequence,
+ remap_node_features,
+)
+
+
+def _path_edge_index(num_nodes: int) -> torch.Tensor:
+ """Return a bidirectional path.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count (at least 2).
+
+ Returns
+ -------
+ Tensor
+ COO index.
+ """
+ src = list(range(num_nodes - 1)) + list(range(1, num_nodes))
+ dst = list(range(1, num_nodes)) + list(range(num_nodes - 1))
+ return torch.tensor([src, dst], dtype=torch.long)
+
+
+def test_entity_remap_scatters_features_and_edges() -> None:
+ """Mapped rows keep features and remapped endpoints."""
+ remap = EntityRemap(
+ entity_ids=("a", "b", "c", "d"),
+ index=torch.tensor([0, 2, 3]),
+ )
+ features = torch.tensor([[1.0, 0.0], [2.0, 0.0], [3.0, 0.0]])
+ mapped = remap.apply_features(features)
+ assert mapped.shape == (4, 2)
+ torch.testing.assert_close(mapped[2], features[1])
+ assert torch.equal(mapped[1], torch.zeros(2))
+ assert torch.equal(remap.presence_mask, torch.tensor([True, False, True, True]))
+ snapshot = Data(x=features, edge_index=_path_edge_index(3))
+ union = remap.apply_snapshot(snapshot)
+ assert int(union.num_nodes) == 4
+ expected_edges = torch.tensor(
+ [[0, 2, 2, 3], [2, 0, 3, 2]],
+ dtype=torch.long,
+ )
+ # Path 0-1-2 becomes 0-2-3 (bidirectional).
+ got = set(map(tuple, union.edge_index.t().tolist()))
+ expected = set(map(tuple, expected_edges.t().tolist()))
+ assert got == expected
+
+
+def test_entity_remap_apply_snapshots_builds_fixed_union_sequence() -> None:
+ """Remapped snapshots construct a presence-masked sequence."""
+ remap = EntityRemap(
+ entity_ids=("u0", "u1", "u2"),
+ index=torch.tensor([0, 2]),
+ )
+ edges = torch.tensor([[0, 1], [1, 0]], dtype=torch.long)
+ snapshots = [
+ Data(x=torch.ones(2, 1), edge_index=edges),
+ Data(x=2 * torch.ones(2, 1), edge_index=edges),
+ ]
+ remapped, presence = remap.apply_snapshots(snapshots)
+ sequence = GraphSnapshotSequence(
+ remapped,
+ presence_masks=presence,
+ entity_ids=remap.entity_ids,
+ allow_node_churn=True,
+ )
+ assert sequence.num_nodes == 3
+ assert sequence.entity_ids == remap.entity_ids
+ assert torch.equal(sequence.presence_mask_at(0), remap.presence_mask)
+
+
+def test_entity_remap_refuses_colliding_index_and_ids() -> None:
+ """Colliding destinations or union names raise."""
+ with pytest.raises(ValueError, match="injective"):
+ EntityRemap(
+ entity_ids=("a", "b", "c"),
+ index=torch.tensor([0, 0]),
+ )
+ with pytest.raises(ValueError, match="duplicate"):
+ EntityRemap(
+ entity_ids=("a", "a"),
+ index=torch.tensor([0]),
+ )
+
+
+def test_mismatched_node_count_refuses_unbounded_growth() -> None:
+ """Sequences may not grow N without an explicit remap."""
+ path3 = _path_edge_index(3)
+ path4 = _path_edge_index(4)
+ snapshots = [
+ Data(x=torch.randn(3, 2), edge_index=path3),
+ Data(x=torch.randn(4, 2), edge_index=path4),
+ ]
+ with pytest.raises(ValueError, match="EntityRemap"):
+ GraphSnapshotSequence(snapshots)
+ with pytest.raises(ValueError, match="unbounded"):
+ GraphSnapshotSequence(snapshots)
+
+
+def test_entity_remap_rejects_hetero_and_bad_edges() -> None:
+ """Hetero graphs and out-of-range endpoints are refused."""
+ remap = EntityRemap(entity_ids=("a", "b"), index=torch.tensor([0, 1]))
+ hetero = HeteroData()
+ hetero["node"].x = torch.ones(2, 1)
+ with pytest.raises(TypeError, match="homogeneous Data"):
+ remap.apply_snapshot(hetero)
+ bad = Data(
+ x=torch.ones(2, 1),
+ edge_index=torch.tensor([[0, 2], [1, 0]], dtype=torch.long),
+ )
+ with pytest.raises(ValueError, match="edge_index endpoints"):
+ remap.apply_snapshot(bad)
+
+
+def test_remap_node_features_primitive_still_injective() -> None:
+ """Tensor helper remains the scatter primitive."""
+ features = torch.arange(6, dtype=torch.float32).view(3, 2)
+ mapped = remap_node_features(
+ features,
+ old_index=torch.tensor([0, 2, 5]),
+ new_capacity=6,
+ )
+ assert mapped.shape == (6, 2)
+ torch.testing.assert_close(mapped[5], features[2])
diff --git a/tests/export/test_export.py b/tests/export/test_export.py
index 7df5616..f737b79 100644
--- a/tests/export/test_export.py
+++ b/tests/export/test_export.py
@@ -142,3 +142,11 @@ def test_export_rejects_switched() -> None:
graph = Data(x=torch.randn(4, 2), edge_index=_path_edges_v014_remaining(4))
with pytest.raises(ValueError, match="switched"):
export_inference_module(model, graph)
+
+
+def test_export_rejects_parametric() -> None:
+ """Portable export refuses parametric interpolants."""
+ model = _tiny_model_v014_remaining(koopman="parametric")
+ graph = Data(x=torch.randn(4, 2), edge_index=_path_edges_v014_remaining(4))
+ with pytest.raises(ValueError, match="parametric"):
+ export_inference_module(model, graph)
diff --git a/tests/graph_utils/test_graph_utils.py b/tests/graph_utils/test_graph_utils.py
index 62a3197..6ac0371 100644
--- a/tests/graph_utils/test_graph_utils.py
+++ b/tests/graph_utils/test_graph_utils.py
@@ -384,6 +384,7 @@ def decode(latent: torch.Tensor, _ei: torch.Tensor, _ew: torch.Tensor | None):
"MAX_ISOTYPIC_NODES",
"OrbitMethod",
"OrbitPartition",
+ "ParametersAtFn",
"PresenceAtFn",
"RELATION_NORMALIZATION_MODES",
"RandomWalkDirection",
@@ -396,6 +397,7 @@ def decode(latent: torch.Tensor, _ei: torch.Tensor, _ew: torch.Tensor | None):
"assemble_orbit_self_blocks",
"autoregressive_hetero_latent_rollout",
"autoregressive_latent_rollout",
+ "call_topology_at",
"clear_hyperedge_cache",
"compute_isotypic_decomposition",
"degree_support_mask",
diff --git a/tests/identification/test_coverage_gaps.py b/tests/identification/test_coverage_gaps.py
new file mode 100644
index 0000000..8a1dfe1
--- /dev/null
+++ b/tests/identification/test_coverage_gaps.py
@@ -0,0 +1,754 @@
+"""Coverage for identification validation and unused error branches.
+
+These tests target public guards and dataclass ``__post_init__`` checks
+that the scientific suites leave unhit. They do not change identification
+formulas or invent forecast numbers.
+"""
+
+from __future__ import annotations
+
+import importlib
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import pytest
+import torch
+from torch import nn
+
+from koopman_graph.identification import (
+ ClosedFormBackend,
+ IdentificationConfig,
+ LatentPairs,
+ LatentRankReport,
+ OperatorSnapshot,
+ ResDMDGateCandidate,
+ ResDMDGateResult,
+ SparseFactorReport,
+ SubspaceInvarianceReport,
+ apply_operator_snapshot,
+ build_identification_report,
+ identify_operator,
+ identify_sparse_graph_factors,
+ select_latent_rank,
+ select_resdmd_gated,
+ subspace_invariance_report,
+)
+from koopman_graph.identification.solvers import (
+ _fit_ridge_row_operator,
+ _fit_tls_row_operator,
+ _scale_spectral_radius,
+)
+from koopman_graph.identification.sparse_factors import (
+ _group_masks,
+ _group_soft_threshold,
+)
+from koopman_graph.operators import KoopmanOperator
+
+# analysis.__init__ binds ``resdmd`` over the submodule name; resolve
+# the module object so 3.10 ``patch`` does not getattr the function.
+_RESDMD_MODULE = importlib.import_module("koopman_graph.analysis.resdmd")
+
+
+def _line_encodings(*, n_times: int = 8, n_nodes: int = 4) -> torch.Tensor:
+ """Time-major encodings on :math:`\\mathrm{span}\\{e_1\\}`.
+
+ Parameters
+ ----------
+ n_times, n_nodes : int
+ Layout.
+
+ Returns
+ -------
+ Tensor
+ ``(T, N, 2)`` float64 encodings.
+ """
+ encodings = torch.zeros(n_times, n_nodes, 2, dtype=torch.float64)
+ encodings[:, :, 0] = torch.linspace(
+ 0.2, 1.6, n_times, dtype=torch.float64
+ ).unsqueeze(1)
+ return encodings
+
+
+def _linear_pairs(
+ true_k: torch.Tensor,
+ *,
+ n_samples: int = 16,
+ seed: int = 0,
+) -> LatentPairs:
+ """Build consecutive encodings from a known linear map.
+
+ Parameters
+ ----------
+ true_k : Tensor
+ Ground-truth ``K``.
+ n_samples, seed : int
+ Pair count and generator seed.
+
+ Returns
+ -------
+ LatentPairs
+ ``z_t`` / ``z_next`` matching ``true_k`` dtype.
+ """
+ generator = torch.Generator()
+ generator.manual_seed(seed)
+ dim = true_k.shape[0]
+ z_t = torch.randn(n_samples, dim, dtype=true_k.dtype, generator=generator)
+ return LatentPairs(z_t=z_t, z_next=z_t @ true_k.T)
+
+
+def _path_edges(num_nodes: int) -> torch.Tensor:
+ """Undirected path ``edge_index``.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count.
+
+ Returns
+ -------
+ Tensor
+ COO edges.
+ """
+ src = list(range(num_nodes - 1)) + list(range(1, num_nodes))
+ dst = list(range(1, num_nodes)) + list(range(num_nodes - 1))
+ return torch.tensor([src, dst], dtype=torch.long)
+
+
+def _rank_report_kwargs(**overrides: object) -> dict[str, object]:
+ """Valid :class:`LatentRankReport` constructor kwargs.
+
+ Parameters
+ ----------
+ **overrides
+ Field replacements.
+
+ Returns
+ -------
+ dict
+ Constructor mapping.
+ """
+ payload: dict[str, object] = {
+ "selected_rank": 1,
+ "criterion": "vamp2",
+ "candidates": (1,),
+ "scores": (0.5,),
+ "rejected_alternatives": (),
+ "numerical_rank": 1,
+ "n_samples": 4,
+ }
+ payload.update(overrides)
+ return payload
+
+
+def _sparse_report_kwargs(**overrides: object) -> dict[str, object]:
+ """Valid :class:`SparseFactorReport` constructor kwargs.
+
+ Parameters
+ ----------
+ **overrides
+ Field replacements.
+
+ Returns
+ -------
+ dict
+ Constructor mapping.
+ """
+ eye = torch.eye(2, dtype=torch.float64)
+ mask = torch.ones(2, 2, dtype=torch.bool)
+ payload: dict[str, object] = {
+ "K_self": eye,
+ "K_nbr": eye.clone(),
+ "active_mask_self": mask,
+ "active_mask_nbr": mask.clone(),
+ "residual": 0.0,
+ "nnz": 4,
+ "n_samples": 8,
+ "method": "stlsq",
+ "group": "none",
+ "threshold": 0.1,
+ }
+ payload.update(overrides)
+ return payload
+
+
+# ---------------------------------------------------------------------------
+# invariance
+# ---------------------------------------------------------------------------
+
+
+def test_invariance_report_rejects_malformed_fields() -> None:
+ """``SubspaceInvarianceReport`` validates leakage, counts, rank, and flag."""
+ with pytest.raises(ValueError, match="leakage must be a finite float"):
+ SubspaceInvarianceReport(
+ leakage=True, # type: ignore[arg-type]
+ n_samples=4,
+ rank=1,
+ held_out=True,
+ )
+ with pytest.raises(ValueError, match="leakage must be a finite float"):
+ SubspaceInvarianceReport(
+ leakage="0.1", # type: ignore[arg-type]
+ n_samples=4,
+ rank=1,
+ held_out=True,
+ )
+ with pytest.raises(ValueError, match="finite non-negative float"):
+ SubspaceInvarianceReport(leakage=-0.1, n_samples=4, rank=1, held_out=True)
+ with pytest.raises(ValueError, match="finite non-negative float"):
+ SubspaceInvarianceReport(
+ leakage=float("nan"), n_samples=4, rank=1, held_out=True
+ )
+ with pytest.raises(ValueError, match="n_samples must be a positive int"):
+ SubspaceInvarianceReport(
+ leakage=0.0,
+ n_samples=True, # type: ignore[arg-type]
+ rank=1,
+ held_out=True,
+ )
+ with pytest.raises(ValueError, match="n_samples must be a positive int"):
+ SubspaceInvarianceReport(
+ leakage=0.0,
+ n_samples=4.0, # type: ignore[arg-type]
+ rank=1,
+ held_out=True,
+ )
+ with pytest.raises(ValueError, match="n_samples must be >= 1"):
+ SubspaceInvarianceReport(leakage=0.0, n_samples=0, rank=1, held_out=True)
+ with pytest.raises(ValueError, match="rank must be a positive int"):
+ SubspaceInvarianceReport(
+ leakage=0.0,
+ n_samples=4,
+ rank=True, # type: ignore[arg-type]
+ held_out=True,
+ )
+ with pytest.raises(ValueError, match="rank must be >= 1"):
+ SubspaceInvarianceReport(leakage=0.0, n_samples=4, rank=0, held_out=True)
+ with pytest.raises(ValueError, match="held_out must be a bool"):
+ SubspaceInvarianceReport(
+ leakage=0.0,
+ n_samples=4,
+ rank=1,
+ held_out=1, # type: ignore[arg-type]
+ )
+
+
+def test_invariance_report_rejects_layout_and_matrix_mismatches() -> None:
+ """``subspace_invariance_report`` refuses bad layouts and ``K`` shapes."""
+ encodings = _line_encodings()
+ matrix = torch.eye(2, dtype=torch.float64)
+ with pytest.raises(TypeError, match="held_out must be a bool"):
+ subspace_invariance_report(encodings, matrix, held_out=1) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="\\(T, d\\) or \\(T, N, d\\)"):
+ subspace_invariance_report(torch.zeros(2, 2, 2, 2), matrix, held_out=False)
+ with pytest.raises(ValueError, match="matrix must be square 2-D"):
+ subspace_invariance_report(encodings, torch.ones(2, 3), held_out=False)
+ with pytest.raises(ValueError, match="matrix must be square 2-D"):
+ subspace_invariance_report(encodings, torch.ones(2), held_out=False)
+ with pytest.raises(ValueError, match="trailing width must match"):
+ subspace_invariance_report(encodings, torch.eye(3), held_out=False)
+ with pytest.raises(ValueError, match="at least one snapshot"):
+ subspace_invariance_report(torch.empty(0, 2), matrix, held_out=False)
+ with pytest.raises(ValueError, match="at least one encoding row"):
+ subspace_invariance_report(
+ torch.zeros(4, 0, 2, dtype=torch.float64),
+ matrix,
+ held_out=True,
+ )
+
+
+def test_invariance_report_rejects_degenerate_encoding_basis(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A zero or over-truncated encoding cloud has no numerical basis."""
+ matrix = torch.eye(2, dtype=torch.float64)
+ zeros = torch.zeros(6, 2, dtype=torch.float64)
+ with pytest.raises(ValueError, match="encoding basis is degenerate"):
+ subspace_invariance_report(zeros, matrix, held_out=False)
+ monkeypatch.setattr(
+ "koopman_graph.identification.invariance.SINGULAR_VALUE_REL_CUTOFF",
+ 2.0,
+ )
+ encodings = _line_encodings(n_times=6, n_nodes=1)
+ with pytest.raises(ValueError, match="encoding basis rank is 0"):
+ subspace_invariance_report(encodings, matrix, held_out=False)
+
+
+# ---------------------------------------------------------------------------
+# gate
+# ---------------------------------------------------------------------------
+
+
+def test_resdmd_candidate_rejects_non_finite_scores() -> None:
+ """Candidate MSE and residual must be finite non-negative floats."""
+ with pytest.raises(ValueError, match="mse must be a finite float"):
+ ResDMDGateCandidate(name="ok", mse=True, residual_max=0.0) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="residual_max must be a finite float"):
+ ResDMDGateCandidate(name="ok", mse=0.1, residual_max="high") # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="finite non-negative float"):
+ ResDMDGateCandidate(name="ok", mse=-0.1, residual_max=0.0)
+ with pytest.raises(ValueError, match="finite non-negative float"):
+ ResDMDGateCandidate(name="ok", mse=0.1, residual_max=float("inf"))
+
+
+def test_resdmd_result_rejects_malformed_fields() -> None:
+ """``ResDMDGateResult`` validates names, rejected list, tolerance, and flag."""
+ with pytest.raises(ValueError, match="selected must be a non-empty str"):
+ ResDMDGateResult(
+ selected="",
+ rejected_alternatives=(),
+ residual_tolerance=0.01,
+ gated=True,
+ )
+ with pytest.raises(ValueError, match="rejected_alternatives must be a tuple"):
+ ResDMDGateResult(
+ selected="ok",
+ rejected_alternatives=["bad"], # type: ignore[arg-type]
+ residual_tolerance=0.01,
+ gated=True,
+ )
+ with pytest.raises(ValueError, match="non-empty strings"):
+ ResDMDGateResult(
+ selected="ok",
+ rejected_alternatives=("",),
+ residual_tolerance=0.01,
+ gated=True,
+ )
+ with pytest.raises(ValueError, match="residual_tolerance must be a finite float"):
+ ResDMDGateResult(
+ selected="ok",
+ rejected_alternatives=(),
+ residual_tolerance=True, # type: ignore[arg-type]
+ gated=True,
+ )
+ with pytest.raises(ValueError, match="finite non-negative float"):
+ ResDMDGateResult(
+ selected="ok",
+ rejected_alternatives=(),
+ residual_tolerance=-0.1,
+ gated=True,
+ )
+ with pytest.raises(ValueError, match="gated must be a bool"):
+ ResDMDGateResult(
+ selected="ok",
+ rejected_alternatives=(),
+ residual_tolerance=0.01,
+ gated=1, # type: ignore[arg-type]
+ )
+
+
+def test_select_resdmd_gated_rejects_tolerance_and_pool_types() -> None:
+ """``select_resdmd_gated`` validates tolerance and candidate sequence types."""
+ ok = ResDMDGateCandidate(name="ok", mse=0.1, residual_max=0.0)
+ with pytest.raises(ValueError, match="residual_tolerance must be a finite float"):
+ select_resdmd_gated((ok,), residual_tolerance=True) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="finite non-negative float"):
+ select_resdmd_gated((ok,), residual_tolerance=float("nan"))
+ with pytest.raises(ValueError, match="sequence of ResDMDGateCandidate"):
+ select_resdmd_gated("ok") # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="entries must be ResDMDGateCandidate"):
+ select_resdmd_gated((ok, "nope")) # type: ignore[arg-type]
+ custom = select_resdmd_gated((ok,), residual_tolerance=0.5, gate_resdmd=False)
+ assert custom.selected == "ok"
+ assert custom.residual_tolerance == pytest.approx(0.5)
+ assert custom.gated is False
+
+
+# ---------------------------------------------------------------------------
+# solvers
+# ---------------------------------------------------------------------------
+
+
+def test_identify_operator_rejects_flat_empty_and_underdetermined() -> None:
+ """Closed-form solvers refuse 1-D, empty, and rank-deficient ridge=0 fits."""
+ vector = torch.ones(3, dtype=torch.float64)
+ with pytest.raises(ValueError, match="at least 2-D"):
+ identify_operator(
+ LatentPairs(z_t=vector, z_next=vector),
+ IdentificationConfig(solver="ridge", ridge=0.0),
+ )
+ empty = torch.empty(0, 2, dtype=torch.float64)
+ with pytest.raises(ValueError, match="at least one latent pair"):
+ identify_operator(
+ LatentPairs(z_t=empty, z_next=empty),
+ IdentificationConfig(solver="ridge", ridge=1e-4),
+ )
+ thin = torch.ones(1, 3, dtype=torch.float64)
+ with pytest.raises(ValueError, match="at least latent_dim"):
+ identify_operator(
+ LatentPairs(z_t=thin, z_next=thin),
+ IdentificationConfig(solver="ridge", ridge=0.0),
+ )
+
+
+def test_identify_operator_tls_and_ridge_internal_shape_guards() -> None:
+ """TLS empty snapshots and mismatched private helper inputs raise."""
+ empty_pairs = torch.zeros(0, 2, dtype=torch.float64)
+ with pytest.raises(ValueError, match="TLS truncation rank must be >= 1"):
+ _fit_tls_row_operator(empty_pairs, empty_pairs)
+ left = torch.ones(4, 2)
+ with pytest.raises(ValueError, match="ridge left/right must share shape"):
+ _fit_ridge_row_operator(left, torch.ones(3, 2), ridge=0.0)
+ with pytest.raises(ValueError, match="TLS left/right must share shape"):
+ _fit_tls_row_operator(left, torch.ones(3, 2))
+ with pytest.raises(ValueError, match="TLS left/right must be 2-D"):
+ _fit_tls_row_operator(torch.ones(2, 2, 2), torch.ones(2, 2, 2))
+
+
+def test_identify_operator_tls_wraps_rank_deficient_pinv() -> None:
+ """A rank-deficient truncated ``U_x`` becomes a ``ValueError``."""
+ true_k = torch.diag(torch.tensor([0.8, 0.4], dtype=torch.float64))
+ pairs = _linear_pairs(true_k)
+
+ def _boom(*_args: object, **_kwargs: object) -> torch.Tensor:
+ raise RuntimeError("singular")
+
+ with (
+ patch("torch.linalg.pinv", side_effect=_boom),
+ pytest.raises(ValueError, match="rank-deficient"),
+ ):
+ identify_operator(pairs, IdentificationConfig(solver="tls"))
+
+
+def test_constrained_ls_leaves_contractive_maps_and_rejects_nonfinite() -> None:
+ """Unit-disk projection is a no-op when ``ρ≤1`` and refuses NaN radii."""
+ true_k = torch.diag(torch.tensor([0.5, 0.2], dtype=torch.float64))
+ pairs = _linear_pairs(true_k)
+ snapshot = identify_operator(
+ pairs, IdentificationConfig(solver="constrained_ls", ridge=0.0)
+ )
+ assert snapshot.matrix is not None
+ rho = float(torch.linalg.eigvals(snapshot.matrix).abs().max().real)
+ assert rho == pytest.approx(0.5, rel=1e-6, abs=1e-8)
+ with pytest.raises(ValueError, match="spectral radius is non-finite"):
+ _scale_spectral_radius(
+ torch.tensor([[float("nan")]], dtype=torch.float64),
+ 1.0,
+ )
+
+
+def test_identify_operator_rejects_unknown_solver_via_mutated_config() -> None:
+ """A solver name that bypasses config validation is still refused."""
+ pairs = _linear_pairs(torch.eye(2, dtype=torch.float64), n_samples=8)
+ config = IdentificationConfig(solver="ridge")
+ object.__setattr__(config, "solver", "not-a-solver")
+ with pytest.raises(ValueError, match="unsupported identification solver"):
+ identify_operator(pairs, config)
+
+
+def test_apply_operator_snapshot_rejects_non_dense_and_missing_matrix() -> None:
+ """Writes require a dense uncontrolled operator and a filled matrix slot."""
+ odo = KoopmanOperator(latent_dim=2, parameterization="odo")
+ with pytest.raises(ValueError, match="parameterization='dense'"):
+ apply_operator_snapshot(odo, OperatorSnapshot(matrix=torch.eye(2)))
+ dense = KoopmanOperator(latent_dim=2, parameterization="dense")
+ with pytest.raises(ValueError, match="OperatorSnapshot.matrix is required"):
+ apply_operator_snapshot(dense, OperatorSnapshot(k_self=torch.eye(2)))
+ with pytest.raises(ValueError, match="KoopmanOperator only"):
+ apply_operator_snapshot(nn.Linear(2, 2), OperatorSnapshot(matrix=torch.eye(2)))
+
+
+def test_closed_form_backend_fit_operator_matches_identify() -> None:
+ """``ClosedFormBackend.fit_operator`` dispatches to ``identify_operator``."""
+ true_k = torch.diag(torch.tensor([0.9, 0.3], dtype=torch.float64))
+ pairs = _linear_pairs(true_k)
+ config = IdentificationConfig(solver="ridge", ridge=1e-4)
+ direct = identify_operator(pairs, config)
+ via_backend = ClosedFormBackend().fit_operator(pairs, config)
+ assert direct.matrix is not None and via_backend.matrix is not None
+ torch.testing.assert_close(
+ via_backend.matrix,
+ direct.matrix,
+ rtol=1e-12,
+ atol=1e-14,
+ )
+
+
+# ---------------------------------------------------------------------------
+# metrics
+# ---------------------------------------------------------------------------
+
+
+def test_build_identification_report_validates_kwargs_and_empty_pools() -> None:
+ """Report builder checks flags, horizon, tolerance, matrix, and pair lists."""
+ true_k = torch.diag(torch.tensor([0.6, 0.4], dtype=torch.float64))
+ pairs = _linear_pairs(true_k, n_samples=8)
+ snapshot = identify_operator(pairs, IdentificationConfig(solver="ridge", ridge=0.0))
+ with pytest.raises(TypeError, match="gate_resdmd must be a bool"):
+ build_identification_report(pairs, snapshot, gate_resdmd=1) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="rollout_horizon must be >= 1"):
+ build_identification_report(pairs, snapshot, rollout_horizon=0)
+ with pytest.raises(ValueError, match="residual_tolerance must be a finite float"):
+ build_identification_report(
+ pairs,
+ snapshot,
+ residual_tolerance=True, # type: ignore[arg-type]
+ )
+ with pytest.raises(ValueError, match="finite non-negative float"):
+ build_identification_report(pairs, snapshot, residual_tolerance=-0.1)
+ with pytest.raises(ValueError, match="requires snapshot.matrix"):
+ build_identification_report(pairs, OperatorSnapshot(k_self=true_k))
+ with pytest.raises(ValueError, match="spectral radius must be a finite"):
+ build_identification_report(
+ pairs,
+ OperatorSnapshot(
+ matrix=torch.tensor([[float("nan")]], dtype=torch.float64)
+ ),
+ )
+ with pytest.raises(ValueError, match="at least one LatentPairs"):
+ build_identification_report((), snapshot)
+
+
+def test_build_identification_report_skips_empty_rollouts_and_pools_blocks() -> None:
+ """Zero-length pairs leave rollout empty; mixed pools skip empty MSE blocks."""
+ true_k = torch.diag(torch.tensor([0.7, 0.2], dtype=torch.float64))
+ filled = _linear_pairs(true_k, n_samples=6)
+ empty = LatentPairs(
+ z_t=torch.empty(0, 2, dtype=torch.float64),
+ z_next=torch.empty(0, 2, dtype=torch.float64),
+ )
+ snapshot = identify_operator(
+ filled,
+ IdentificationConfig(solver="ridge", ridge=0.0),
+ )
+ vacant = build_identification_report(empty, snapshot, rollout_horizon=3)
+ assert vacant.one_step.mse is None
+ assert vacant.one_step.n_samples is None
+ assert vacant.rollout.mse is None
+ pooled = build_identification_report((empty, filled), snapshot, rollout_horizon=2)
+ assert pooled.one_step.n_samples == 6 * 2
+ assert pooled.one_step.mse == pytest.approx(0.0, abs=1e-12)
+ assert pooled.rollout.mse is not None
+
+
+def test_build_identification_report_rejects_nonfinite_resdmd_residual() -> None:
+ """A non-finite max residual from ResDMD is refused when gating."""
+ true_k = torch.diag(torch.tensor([0.5, 0.25], dtype=torch.float64))
+ pairs = _linear_pairs(true_k, n_samples=10)
+ snapshot = identify_operator(pairs, IdentificationConfig(solver="ridge", ridge=0.0))
+ fake = SimpleNamespace(residuals=torch.tensor([float("nan")]))
+ with (
+ patch.object(_RESDMD_MODULE, "resdmd", return_value=fake),
+ pytest.raises(ValueError, match="residual_max must be a finite"),
+ ):
+ build_identification_report(pairs, snapshot, gate_resdmd=True)
+ fake_neg = SimpleNamespace(residuals=torch.tensor([-0.1]))
+ with (
+ patch.object(_RESDMD_MODULE, "resdmd", return_value=fake_neg),
+ pytest.raises(ValueError, match="residual_max must be a finite"),
+ ):
+ build_identification_report(pairs, snapshot, gate_resdmd=True)
+
+
+# ---------------------------------------------------------------------------
+# rank
+# ---------------------------------------------------------------------------
+
+
+def test_latent_rank_report_rejects_malformed_fields() -> None:
+ """``LatentRankReport`` validates criterion, ranks, scores, and rejected names."""
+ with pytest.raises(ValueError, match="criterion must be one of"):
+ LatentRankReport(**_rank_report_kwargs(criterion="aic")) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="selected_rank must be an int >= 1"):
+ LatentRankReport(**_rank_report_kwargs(selected_rank=0))
+ with pytest.raises(ValueError, match="numerical_rank must be an int >= 1"):
+ LatentRankReport(**_rank_report_kwargs(numerical_rank=0))
+ with pytest.raises(ValueError, match="n_samples must be an int >= 1"):
+ LatentRankReport(**_rank_report_kwargs(n_samples=0))
+ with pytest.raises(ValueError, match="same length"):
+ LatentRankReport(**_rank_report_kwargs(candidates=(1, 2), scores=(0.1,)))
+ with pytest.raises(ValueError, match="not among scored"):
+ LatentRankReport(**_rank_report_kwargs(selected_rank=2))
+ with pytest.raises(ValueError, match="candidates must be positive ints"):
+ LatentRankReport(
+ **_rank_report_kwargs(selected_rank=1, candidates=(1.0,), scores=(0.1,))
+ )
+ with pytest.raises(ValueError, match="scores must be finite floats"):
+ LatentRankReport(**_rank_report_kwargs(scores=(True,)))
+ with pytest.raises(ValueError, match="scores must be finite"):
+ LatentRankReport(**_rank_report_kwargs(scores=(float("inf"),)))
+ with pytest.raises(ValueError, match="tuple of non-empty strings"):
+ LatentRankReport(**_rank_report_kwargs(rejected_alternatives=("4", "")))
+ with pytest.raises(ValueError, match="tuple of non-empty strings"):
+ LatentRankReport(**_rank_report_kwargs(rejected_alternatives=["4"]))
+
+
+def test_select_latent_rank_rejects_layout_candidates_and_penalties() -> None:
+ """``select_latent_rank`` checks encodings, candidate type, and scalars."""
+ encodings = torch.randn(8, 3, dtype=torch.float64)
+ with pytest.raises(ValueError, match="\\(T, d\\) or \\(T, N, d\\)"):
+ select_latent_rank(torch.randn(2, 2, 2, 2), (1,), criterion="vamp2")
+ with pytest.raises(ValueError, match="must be floating-point"):
+ select_latent_rank(
+ torch.zeros(5, 2, dtype=torch.int64),
+ (1,),
+ criterion="vamp2",
+ )
+ with pytest.raises(ValueError, match="trailing width d >= 1"):
+ select_latent_rank(torch.zeros(5, 0), (1,), criterion="vamp2")
+ with pytest.raises(ValueError, match="sequence of positive ints"):
+ select_latent_rank(encodings, "12", criterion="vamp2") # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="ridge must be a finite"):
+ select_latent_rank(encodings, (1, 2), criterion="vamp2", ridge=float("inf"))
+ with pytest.raises(ValueError, match="stability_penalty must be a finite"):
+ select_latent_rank(
+ encodings,
+ (1, 2),
+ criterion="stability_penalized",
+ stability_penalty=-1.0,
+ )
+
+
+def test_select_latent_rank_rejects_degenerate_basis(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A zero cloud or over-truncated SVD cutoff has no numerical rank."""
+ zeros = torch.zeros(8, 3, dtype=torch.float64)
+ with pytest.raises(ValueError, match="encoding basis is degenerate"):
+ select_latent_rank(zeros, (1, 2), criterion="vamp2")
+ monkeypatch.setattr(
+ "koopman_graph.identification.rank.SINGULAR_VALUE_REL_CUTOFF",
+ 2.0,
+ )
+ encodings = torch.randn(8, 3, dtype=torch.float64)
+ encodings[:, 1:] = 0.0
+ encodings[:, 0] = torch.linspace(0.2, 1.4, 8, dtype=torch.float64)
+ with pytest.raises(ValueError, match="encoding basis rank is 0"):
+ select_latent_rank(encodings, (1,), criterion="vamp2")
+
+
+def test_stability_penalized_rank_rejects_missing_or_nonfinite_k(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Stability scoring requires a dense finite identified matrix."""
+ encodings = torch.randn(10, 3, dtype=torch.float64)
+
+ def _factors_only(
+ pairs: LatentPairs,
+ _config: IdentificationConfig,
+ ) -> OperatorSnapshot:
+ width = int(pairs.z_t.shape[-1])
+ return OperatorSnapshot(k_self=torch.eye(width, dtype=pairs.z_t.dtype))
+
+ monkeypatch.setattr(
+ "koopman_graph.identification.rank.identify_operator",
+ _factors_only,
+ )
+ with pytest.raises(ValueError, match="must return a dense matrix"):
+ select_latent_rank(encodings, (1,), criterion="stability_penalized")
+
+ def _nan_matrix(
+ pairs: LatentPairs,
+ _config: IdentificationConfig,
+ ) -> OperatorSnapshot:
+ width = int(pairs.z_t.shape[-1])
+ return OperatorSnapshot(
+ matrix=torch.full((width, width), float("nan"), dtype=pairs.z_t.dtype)
+ )
+
+ monkeypatch.setattr(
+ "koopman_graph.identification.rank.identify_operator",
+ _nan_matrix,
+ )
+ with pytest.raises(ValueError, match="spectral radius is non-finite"):
+ select_latent_rank(encodings, (1,), criterion="stability_penalized")
+
+
+# ---------------------------------------------------------------------------
+# sparse factors
+# ---------------------------------------------------------------------------
+
+
+def test_sparse_factor_report_rejects_malformed_fields() -> None:
+ """``SparseFactorReport`` validates shapes, residual, counts, and threshold."""
+ with pytest.raises(ValueError, match="K_self and K_nbr must share shape"):
+ SparseFactorReport(
+ **_sparse_report_kwargs(K_nbr=torch.eye(3, dtype=torch.float64))
+ )
+ with pytest.raises(ValueError, match="K_self must be square 2-D"):
+ rect = torch.ones(2, 3, dtype=torch.float64)
+ SparseFactorReport(**_sparse_report_kwargs(K_self=rect, K_nbr=rect.clone()))
+ with pytest.raises(ValueError, match="active_mask_self must match"):
+ SparseFactorReport(
+ **_sparse_report_kwargs(active_mask_self=torch.ones(3, 3, dtype=torch.bool))
+ )
+ with pytest.raises(ValueError, match="active_mask_nbr must match"):
+ SparseFactorReport(
+ **_sparse_report_kwargs(active_mask_nbr=torch.ones(3, 3, dtype=torch.bool))
+ )
+ with pytest.raises(ValueError, match="residual must be a finite"):
+ SparseFactorReport(**_sparse_report_kwargs(residual=float("nan")))
+ with pytest.raises(ValueError, match="nnz must be non-negative"):
+ SparseFactorReport(**_sparse_report_kwargs(nnz=-1))
+ with pytest.raises(ValueError, match="n_samples >= 1"):
+ SparseFactorReport(**_sparse_report_kwargs(n_samples=0))
+ with pytest.raises(ValueError, match="threshold must be non-negative"):
+ SparseFactorReport(**_sparse_report_kwargs(threshold=-0.1))
+
+
+def test_identify_sparse_graph_factors_rejects_edges_layout_and_iter() -> None:
+ """Sparse-factor ID checks COO bounds, empty axes, dtype, and ``max_iter``."""
+ z_pairs = torch.randn(4, 3, 2, dtype=torch.float64)
+ edges = _path_edges(3)
+ with pytest.raises(ValueError, match="edge_index must have shape"):
+ identify_sparse_graph_factors(
+ z_pairs,
+ torch.zeros(3, 4, dtype=torch.long),
+ threshold=0.1,
+ )
+ with pytest.raises(ValueError, match="edge_index entries must lie"):
+ identify_sparse_graph_factors(
+ z_pairs,
+ torch.tensor([[0, 9], [1, 0]], dtype=torch.long),
+ threshold=0.1,
+ )
+ with pytest.raises(ValueError, match="N >= 1 and d >= 1"):
+ identify_sparse_graph_factors(
+ torch.randn(4, 0, 2, dtype=torch.float64),
+ edges,
+ threshold=0.1,
+ )
+ with pytest.raises(ValueError, match="N >= 1 and d >= 1"):
+ identify_sparse_graph_factors(
+ torch.randn(4, 3, 0, dtype=torch.float64),
+ edges,
+ threshold=0.1,
+ )
+ with pytest.raises(ValueError, match="z_pairs must be floating-point"):
+ identify_sparse_graph_factors(
+ torch.zeros(4, 3, 2, dtype=torch.int64),
+ edges,
+ threshold=0.1,
+ )
+ with pytest.raises(ValueError, match="max_iter must be >= 1"):
+ identify_sparse_graph_factors(z_pairs, edges, threshold=0.1, max_iter=0)
+
+
+def test_identify_sparse_graph_factors_empty_support_and_soft_threshold() -> None:
+ """A huge cutoff zeros both factors; a zero group is skipped in ISTA prox."""
+ z_pairs = torch.randn(5, 3, 2, dtype=torch.float64)
+ edges = _path_edges(3)
+ weights = torch.ones(edges.shape[1], dtype=torch.float64)
+ wiped = identify_sparse_graph_factors(
+ z_pairs,
+ edges,
+ group="self_nbr",
+ method="stlsq",
+ threshold=1e6,
+ edge_weight=weights,
+ max_iter=2,
+ )
+ assert wiped.nnz == 0
+ assert torch.equal(wiped.K_self, torch.zeros(2, 2, dtype=torch.float64))
+ lasso = identify_sparse_graph_factors(
+ z_pairs,
+ edges,
+ group="self_nbr",
+ method="group_lasso",
+ threshold=0.05,
+ adjacency="symmetric",
+ )
+ assert lasso.method == "group_lasso"
+ assert torch.isfinite(lasso.K_self).all()
+ masks = _group_masks("self_nbr", 2, device=torch.device("cpu"))
+ zeroed = _group_soft_threshold(torch.zeros(4, 2), masks, tau=0.1)
+ assert torch.equal(zeroed, torch.zeros(4, 2))
diff --git a/tests/identification/test_fit_identification.py b/tests/identification/test_fit_identification.py
new file mode 100644
index 0000000..b31a599
--- /dev/null
+++ b/tests/identification/test_fit_identification.py
@@ -0,0 +1,270 @@
+"""Opt-in ``fit(..., identification=...)`` bind and default-Adam isolation."""
+
+from __future__ import annotations
+
+import pytest
+import torch
+from torch import Tensor, nn
+from torch_geometric.data import Data
+
+from koopman_graph.data import GraphSnapshotSequence
+from koopman_graph.identification import IdentificationConfig, IdentificationReport
+from koopman_graph.model import GraphKoopmanModel
+from koopman_graph.operators import GraphKoopmanOperator, KoopmanOperator
+
+
+class _IdentityCodec(nn.Module):
+ """Pass-through encoder/decoder for linear latent oracles.
+
+ Parameters
+ ----------
+ dim : int
+ Feature and latent width.
+ """
+
+ def __init__(self, dim: int) -> None:
+ """Store channel metadata.
+
+ Parameters
+ ----------
+ dim : int
+ Shared input / latent / output width.
+ """
+ super().__init__()
+ self.in_channels = dim
+ self.latent_dim = dim
+ self.out_channels = dim
+ self.num_layers = 1
+
+ def forward(
+ self,
+ x_or_data: Tensor | Data,
+ edge_index: Tensor | None = None,
+ edge_weight: Tensor | None = None,
+ ) -> Tensor:
+ """Return node features unchanged.
+
+ Parameters
+ ----------
+ x_or_data : Tensor or Data
+ Features or a snapshot.
+ edge_index, edge_weight : Tensor or None
+ Ignored topology.
+
+ Returns
+ -------
+ Tensor
+ Node features.
+ """
+ del edge_index, edge_weight
+ if isinstance(x_or_data, Data):
+ return x_or_data.x
+ return x_or_data
+
+
+def _eigvals_match(
+ left: Tensor,
+ right: Tensor,
+ *,
+ rtol: float,
+ atol: float,
+) -> bool:
+ """Greedy multiset match of complex eigenvalues.
+
+ Parameters
+ ----------
+ left, right : Tensor
+ Eigenvalue vectors.
+ rtol, atol : float
+ Construction tolerances.
+
+ Returns
+ -------
+ bool
+ Whether the multisets match within tolerance.
+ """
+ remaining = right.detach().clone()
+ for value in left.detach():
+ diffs = (remaining - value).abs()
+ index = int(torch.argmin(diffs))
+ if not torch.isclose(value, remaining[index], rtol=rtol, atol=atol):
+ return False
+ remaining[index] = complex(float("inf"), float("inf"))
+ return True
+
+
+def _linear_sequence(
+ true_k: Tensor, *, n_nodes: int = 4, n_times: int = 16
+) -> tuple[GraphSnapshotSequence, Tensor]:
+ """Build a graph sequence whose node states follow ``z @ K.T``.
+
+ Parameters
+ ----------
+ true_k : Tensor
+ Shared per-node map.
+ n_nodes, n_times : int
+ Graph size and length.
+
+ Returns
+ -------
+ tuple
+ Sequence and the undirected edge index.
+ """
+ dim = true_k.shape[0]
+ generator = torch.Generator()
+ generator.manual_seed(0)
+ state = torch.randn(n_nodes, dim, dtype=true_k.dtype, generator=generator)
+ src = list(range(n_nodes - 1))
+ dst = list(range(1, n_nodes))
+ edge_index = torch.tensor([src + dst, dst + src], dtype=torch.long)
+ snapshots = []
+ for _ in range(n_times):
+ snapshots.append(Data(x=state.clone(), edge_index=edge_index))
+ state = state @ true_k.T
+ return GraphSnapshotSequence(snapshots), edge_index
+
+
+def _identity_model(dim: int, *, dtype: torch.dtype) -> GraphKoopmanModel:
+ """Per-node dense model with identity lift/decode.
+
+ Parameters
+ ----------
+ dim : int
+ Latent width.
+ dtype : torch.dtype
+ Parameter dtype.
+
+ Returns
+ -------
+ GraphKoopmanModel
+ Untrained identity codec model.
+ """
+ model = GraphKoopmanModel(
+ encoder=_IdentityCodec(dim),
+ decoder=_IdentityCodec(dim),
+ latent_dim=dim,
+ time_step=1.0,
+ koopman_init_mode="identity",
+ koopman_init_scale=0.0,
+ )
+ return model.to(dtype=dtype)
+
+
+def test_fit_identification_recovers_linear_gaussian_eigenvalues() -> None:
+ """Identity-encoder fit recovers known eigenvalues.
+
+ Noiseless float64 oracle; ``rtol=1e-5``, ``atol=1e-8`` from
+ construction.
+ """
+ true_k = torch.tensor([[0.9, 0.15], [-0.05, 0.55]], dtype=torch.float64)
+ sequence, _ = _linear_sequence(true_k)
+ model = _identity_model(2, dtype=torch.float64)
+ history = model.fit(
+ sequence,
+ epochs=2,
+ identification=IdentificationConfig(solver="ridge", ridge=0.0),
+ )
+ assert history.epochs == 2
+ report = model.identification_report
+ assert isinstance(report, IdentificationReport)
+ assert report.one_step.mse is not None
+ assert report.one_step.mse == pytest.approx(0.0, abs=1e-8)
+ assert report.invariance.leakage is None
+ assert report.spectral.polluted is None
+ assert report.spectral.residual_max is None
+ assert report.stability.spectral_radius is not None
+ recovered = torch.linalg.eigvals(model.koopman.K.detach())
+ assert _eigvals_match(
+ recovered,
+ torch.linalg.eigvals(true_k),
+ rtol=1e-5,
+ atol=1e-8,
+ )
+
+
+def test_default_fit_leaves_identification_report_unset(
+ scaling_sequence: GraphSnapshotSequence,
+) -> None:
+ """Adam ``fit`` does not populate ``identification_report``."""
+ from koopman_graph import GNNDecoder, GNNEncoder
+
+ model = GraphKoopmanModel(
+ encoder=GNNEncoder(
+ in_channels=3, hidden_channels=8, latent_dim=4, num_layers=1
+ ),
+ decoder=GNNDecoder(
+ latent_dim=4, hidden_channels=8, out_channels=3, num_layers=1
+ ),
+ latent_dim=4,
+ time_step=0.1,
+ )
+ assert model.identification_report is None
+ model.fit(scaling_sequence, epochs=1, lr=1e-2, device="cpu")
+ assert model.identification_report is None
+ assert isinstance(model.koopman, KoopmanOperator)
+
+
+def test_identification_rejects_graph_operator() -> None:
+ """Networked operators are out of scope for this increment."""
+ true_k = torch.diag(torch.tensor([0.8, 0.5], dtype=torch.float64))
+ sequence, _ = _linear_sequence(true_k)
+ model = GraphKoopmanModel(
+ encoder=_IdentityCodec(2),
+ decoder=_IdentityCodec(2),
+ latent_dim=2,
+ time_step=1.0,
+ koopman=GraphKoopmanOperator(latent_dim=2),
+ )
+ with pytest.raises(ValueError, match="per-node"):
+ model.fit(
+ sequence,
+ epochs=1,
+ identification=IdentificationConfig(solver="ridge"),
+ )
+
+
+def test_identification_rejects_windowed_fit() -> None:
+ """Windowed sampling is refused with identification."""
+ true_k = torch.eye(2, dtype=torch.float64)
+ sequence, _ = _linear_sequence(true_k, n_times=8)
+ model = _identity_model(2, dtype=torch.float64)
+ with pytest.raises(ValueError, match="windowed"):
+ model.fit(
+ sequence,
+ epochs=1,
+ window_length=4,
+ identification=IdentificationConfig(),
+ )
+
+
+def test_identification_rejects_ddp_strategy() -> None:
+ """DDP is mutually exclusive with identification."""
+ true_k = torch.eye(2, dtype=torch.float64)
+ sequence, _ = _linear_sequence(true_k, n_times=6)
+ model = _identity_model(2, dtype=torch.float64)
+ with pytest.raises(ValueError, match="ddp"):
+ model.fit(
+ sequence,
+ epochs=1,
+ strategy="ddp",
+ identification=IdentificationConfig(),
+ )
+
+
+def test_fit_identification_gate_resdmd_fills_spectral() -> None:
+ """``gate_resdmd=True`` fills the spectral block and does not abort."""
+ true_k = torch.diag(torch.tensor([0.8, 0.5], dtype=torch.float64))
+ sequence, _ = _linear_sequence(true_k)
+ model = _identity_model(2, dtype=torch.float64)
+ model.fit(
+ sequence,
+ epochs=1,
+ identification=IdentificationConfig(
+ solver="ridge", ridge=0.0, gate_resdmd=True
+ ),
+ )
+ report = model.identification_report
+ assert isinstance(report, IdentificationReport)
+ assert report.spectral.residual_max is not None
+ assert report.spectral.residual_max == pytest.approx(0.0, abs=1e-6)
+ assert report.spectral.polluted is False
diff --git a/tests/identification/test_import_isolation.py b/tests/identification/test_import_isolation.py
new file mode 100644
index 0000000..6733d3d
--- /dev/null
+++ b/tests/identification/test_import_isolation.py
@@ -0,0 +1,231 @@
+"""Import-cycle guards for ``koopman_graph.identification``."""
+
+from __future__ import annotations
+
+import ast
+import subprocess
+import sys
+from pathlib import Path
+
+from tests.helpers import REPO_ROOT
+
+_PACKAGE_ROOT = REPO_ROOT / "src" / "koopman_graph"
+_FORBIDDEN_FROM_IDENTIFICATION = (
+ "koopman_graph.training",
+ "koopman_graph.model",
+ "koopman_graph.adaptation",
+)
+_FORBIDDEN_FROM_TRAINING = ("koopman_graph.identification",)
+
+
+def _is_type_checking(node: ast.AST) -> bool:
+ """Return whether ``node`` is ``if TYPE_CHECKING:``.
+
+ Parameters
+ ----------
+ node : ast.AST
+ Statement to inspect.
+
+ Returns
+ -------
+ bool
+ ``True`` for a ``TYPE_CHECKING`` guard.
+ """
+ if not isinstance(node, ast.If):
+ return False
+ test = node.test
+ return isinstance(test, ast.Name) and test.id == "TYPE_CHECKING"
+
+
+def _import_names(node: ast.AST) -> list[str]:
+ """Return imported module names on an import statement.
+
+ Parameters
+ ----------
+ node : ast.AST
+ Import node.
+
+ Returns
+ -------
+ list of str
+ Absolute module names.
+ """
+ if isinstance(node, ast.Import):
+ return [alias.name for alias in node.names]
+ if isinstance(node, ast.ImportFrom) and node.module:
+ return [node.module]
+ return []
+
+
+def _module_import_offenders(
+ root: Path,
+ prefixes: tuple[str, ...],
+ *,
+ module_level_only: bool = False,
+) -> list[str]:
+ """Return ``path:module`` entries that import a forbidden prefix.
+
+ Parameters
+ ----------
+ root : Path
+ Package directory to walk.
+ prefixes : tuple of str
+ Forbidden module prefixes (exact or dotted children).
+ module_level_only : bool, optional
+ When ``True``, ignore function-body lazy imports and
+ ``if TYPE_CHECKING:`` blocks. Default is ``False`` (walk all).
+
+ Returns
+ -------
+ list of str
+ Offending ``filename:module`` labels.
+ """
+ offenders: list[str] = []
+ for path in sorted(root.rglob("*.py")):
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
+ if module_level_only:
+ nodes: list[ast.AST] = [
+ node for node in tree.body if not _is_type_checking(node)
+ ]
+ else:
+ nodes = list(ast.walk(tree))
+ for node in nodes:
+ for name in _import_names(node):
+ if any(
+ name == prefix or name.startswith(prefix + ".")
+ for prefix in prefixes
+ ):
+ offenders.append(f"{path.relative_to(_PACKAGE_ROOT)}:{name}")
+ return offenders
+
+
+def test_identification_sources_do_not_import_training_model_or_adaptation() -> None:
+ """``identification/`` must not import training, model, or adaptation."""
+ assert (
+ _module_import_offenders(
+ _PACKAGE_ROOT / "identification",
+ _FORBIDDEN_FROM_IDENTIFICATION,
+ )
+ == []
+ )
+
+
+def test_training_sources_do_not_import_identification_at_module_load() -> None:
+ """``training`` may lazy-import identification inside functions only."""
+ assert (
+ _module_import_offenders(
+ _PACKAGE_ROOT / "training",
+ _FORBIDDEN_FROM_TRAINING,
+ module_level_only=True,
+ )
+ == []
+ )
+
+
+def test_model_sources_do_not_import_identification_at_module_load() -> None:
+ """``model`` may TYPE_CHECKING-import identification; not at runtime load."""
+ assert (
+ _module_import_offenders(
+ _PACKAGE_ROOT / "model",
+ _FORBIDDEN_FROM_TRAINING,
+ module_level_only=True,
+ )
+ == []
+ )
+
+
+def test_importing_training_does_not_load_identification() -> None:
+ """``import koopman_graph.training`` must not load identification."""
+ script = (
+ "import sys\n"
+ "import koopman_graph.training\n"
+ "assert 'koopman_graph.identification' not in sys.modules, "
+ "sorted(k for k in sys.modules if 'identification' in k)\n"
+ )
+ completed = subprocess.run(
+ [sys.executable, "-c", script],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ assert completed.returncode == 0, completed.stderr
+
+
+def test_importing_model_does_not_load_identification() -> None:
+ """``import koopman_graph.model`` must not load identification."""
+ script = (
+ "import sys\n"
+ "import koopman_graph.model\n"
+ "assert 'koopman_graph.identification' not in sys.modules, "
+ "sorted(k for k in sys.modules if 'identification' in k)\n"
+ )
+ completed = subprocess.run(
+ [sys.executable, "-c", script],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ assert completed.returncode == 0, completed.stderr
+
+
+def test_default_fit_does_not_load_identification() -> None:
+ """Adam ``fit`` (``identification=None``) does not import identification."""
+ script = """\
+import sys
+import torch
+from torch_geometric.data import Data
+from koopman_graph import GNNDecoder, GNNEncoder, GraphKoopmanModel
+from koopman_graph.data import GraphSnapshotSequence
+
+edge = torch.tensor([[0, 1], [1, 0]], dtype=torch.long)
+sequence = GraphSnapshotSequence(
+ [
+ Data(x=torch.ones(2, 3), edge_index=edge),
+ Data(x=0.9 * torch.ones(2, 3), edge_index=edge),
+ ]
+)
+model = GraphKoopmanModel(
+ encoder=GNNEncoder(in_channels=3, hidden_channels=4, latent_dim=2, num_layers=1),
+ decoder=GNNDecoder(latent_dim=2, hidden_channels=4, out_channels=3, num_layers=1),
+ latent_dim=2,
+ time_step=0.1,
+)
+model.fit(sequence, epochs=1, lr=1e-2, device="cpu")
+assert "koopman_graph.identification" not in sys.modules, sorted(
+ key for key in sys.modules if "identification" in key
+)
+"""
+ completed = subprocess.run(
+ [sys.executable, "-c", script],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ assert completed.returncode == 0, completed.stderr
+
+
+def test_importing_metrics_does_not_load_identification() -> None:
+ """``import koopman_graph.metrics`` must not load identification."""
+ script = (
+ "import sys\n"
+ "import koopman_graph.metrics\n"
+ "assert 'koopman_graph.identification' not in sys.modules, "
+ "sorted(k for k in sys.modules if 'identification' in k)\n"
+ )
+ completed = subprocess.run(
+ [sys.executable, "-c", script],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ assert completed.returncode == 0, completed.stderr
+
+
+def test_identification_package_is_importable() -> None:
+ """``import koopman_graph.identification`` exposes solvers."""
+ import koopman_graph.identification as ident
+
+ assert ident.IdentificationReport is not None
+ assert ident.IdentificationConfig().solver == "ridge"
+ assert callable(ident.identify_operator)
+ assert callable(ident.select_latent_rank)
diff --git a/tests/identification/test_invariance.py b/tests/identification/test_invariance.py
new file mode 100644
index 0000000..10c8b1f
--- /dev/null
+++ b/tests/identification/test_invariance.py
@@ -0,0 +1,218 @@
+"""Finite-sample subspace invariance leakage :math:`\\eta`."""
+
+from __future__ import annotations
+
+import pytest
+import torch
+from tests.identification.test_fit_identification import _identity_model, _IdentityCodec
+from torch_geometric.data import Data, HeteroData
+
+from koopman_graph.data import GraphSnapshotSequence, HeteroGraphSnapshotSequence
+from koopman_graph.identification import (
+ SubspaceInvarianceReport,
+ subspace_invariance_report,
+)
+from koopman_graph.model import GraphKoopmanModel
+from koopman_graph.nn import RelGraphDecoder, RelGraphEncoder
+from koopman_graph.operators import GraphKoopmanOperator
+
+
+def _path_edges(num_nodes: int) -> torch.Tensor:
+ """Undirected path ``edge_index``.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count.
+
+ Returns
+ -------
+ Tensor
+ COO edge index.
+ """
+ src = list(range(num_nodes - 1)) + list(range(1, num_nodes))
+ dst = list(range(1, num_nodes)) + list(range(num_nodes - 1))
+ return torch.tensor([src, dst], dtype=torch.long)
+
+
+def _line_encodings(*, n_times: int = 8, n_nodes: int = 4) -> torch.Tensor:
+ """Time-major encodings on :math:`\\mathrm{span}\\{e_1\\}\\subset\\mathbb{R}^{2}`.
+
+ Parameters
+ ----------
+ n_times, n_nodes : int
+ Layout.
+
+ Returns
+ -------
+ Tensor
+ ``(T, N, 2)`` float64 encodings.
+ """
+ encodings = torch.zeros(n_times, n_nodes, 2, dtype=torch.float64)
+ encodings[:, :, 0] = torch.linspace(
+ 0.2, 1.6, n_times, dtype=torch.float64
+ ).unsqueeze(1)
+ return encodings
+
+
+def _line_sequence(*, n_times: int = 8, n_nodes: int = 4) -> GraphSnapshotSequence:
+ """Identity-codec snapshots on the first coordinate axis.
+
+ Parameters
+ ----------
+ n_times, n_nodes : int
+ Sequence length and graph size.
+
+ Returns
+ -------
+ GraphSnapshotSequence
+ Homogeneous snapshots.
+ """
+ encodings = _line_encodings(n_times=n_times, n_nodes=n_nodes)
+ edge_index = _path_edges(n_nodes)
+ snapshots = [
+ Data(x=encodings[index].clone(), edge_index=edge_index)
+ for index in range(n_times)
+ ]
+ return GraphSnapshotSequence(snapshots)
+
+
+def test_leakage_vanishes_on_invariant_line() -> None:
+ """Diagonal ``K`` leaves :math:`\\mathrm{span}\\{e_1\\}` invariant, so ``η→0``.
+
+ Noiseless float64 construction; ``atol=1e-8``.
+ """
+ encodings = _line_encodings()
+ matrix = torch.diag(torch.tensor([0.9, 0.5], dtype=torch.float64))
+ report = subspace_invariance_report(encodings, matrix, held_out=True)
+ assert isinstance(report, SubspaceInvarianceReport)
+ assert report.leakage == pytest.approx(0.0, abs=1e-8)
+ assert report.rank == 1
+ assert report.held_out is True
+ assert report.n_samples == 4 * 4
+
+
+def test_leakage_positive_when_map_leaves_the_span() -> None:
+ """A 90° map sends :math:`e_1` to :math:`e_2`, so leakage is order-one."""
+ encodings = _line_encodings()
+ matrix = torch.tensor([[0.0, 1.0], [-1.0, 0.0]], dtype=torch.float64)
+ report = subspace_invariance_report(encodings, matrix, held_out=True)
+ assert report.leakage > 0.5
+ assert report.rank == 1
+
+
+def test_held_out_uses_last_half_only() -> None:
+ """Full-span early snapshots do not enlarge ``P`` when ``held_out=True``."""
+ n_times, n_nodes = 8, 4
+ encodings = torch.zeros(n_times, n_nodes, 2, dtype=torch.float64)
+ encodings[:4, 0, 0] = 1.0
+ encodings[:4, 1, 1] = 1.0
+ encodings[4:, :, 0] = torch.linspace(0.2, 0.8, 4, dtype=torch.float64).unsqueeze(1)
+ rotation = torch.tensor([[0.0, 1.0], [-1.0, 0.0]], dtype=torch.float64)
+ held = subspace_invariance_report(encodings, rotation, held_out=True)
+ full = subspace_invariance_report(encodings, rotation, held_out=False)
+ assert held.rank == 1
+ assert held.leakage > 0.5
+ assert full.rank == 2
+ assert full.leakage == pytest.approx(0.0, abs=1e-8)
+
+
+def test_held_out_requires_four_snapshots() -> None:
+ """``held_out=True`` refuses short trajectories."""
+ encodings = torch.zeros(3, 2, 2, dtype=torch.float64)
+ encodings[:, :, 0] = 1.0
+ matrix = torch.eye(2, dtype=torch.float64)
+ with pytest.raises(ValueError, match="at least 4 snapshots"):
+ subspace_invariance_report(encodings, matrix, held_out=True)
+
+
+def test_zero_mapped_norm_raises() -> None:
+ """A zero map makes the leakage denominator undefined."""
+ encodings = _line_encodings()
+ matrix = torch.zeros(2, 2, dtype=torch.float64)
+ with pytest.raises(ValueError, match=r"E\|\|K"):
+ subspace_invariance_report(encodings, matrix, held_out=True)
+
+
+def test_evaluate_default_leaves_invariance_unset() -> None:
+ """Default ``evaluate`` metrics match an explicit ``include_invariance=False``."""
+ sequence = _line_sequence()
+ model = _identity_model(2, dtype=torch.float64)
+ with torch.no_grad():
+ model.koopman.set_dense_matrix(
+ torch.diag(torch.tensor([0.9, 0.5], dtype=torch.float64))
+ )
+ default = model.evaluate(sequence, horizons=(1,))
+ explicit = model.evaluate(sequence, horizons=(1,), include_invariance=False)
+ assert default.invariance is None
+ assert explicit.invariance is None
+ assert default.aggregate_rmse == pytest.approx(explicit.aggregate_rmse)
+
+
+def test_evaluate_include_invariance_preserves_rmse() -> None:
+ """Opt-in leakage attaches without changing aggregate RMSE."""
+ sequence = _line_sequence()
+ model = _identity_model(2, dtype=torch.float64)
+ with torch.no_grad():
+ model.koopman.set_dense_matrix(
+ torch.diag(torch.tensor([0.9, 0.5], dtype=torch.float64))
+ )
+ baseline = model.evaluate(sequence, horizons=(1,))
+ result = model.evaluate(sequence, horizons=(1,), include_invariance=True)
+ assert isinstance(result.invariance, SubspaceInvarianceReport)
+ assert result.invariance.leakage == pytest.approx(0.0, abs=1e-8)
+ assert result.aggregate_rmse == pytest.approx(baseline.aggregate_rmse)
+ assert result.aggregate_mae == pytest.approx(baseline.aggregate_mae)
+
+
+def test_model_subspace_invariance_report_matches_tensor_path() -> None:
+ """The model helper encodes then matches the tensor API."""
+ sequence = _line_sequence()
+ model = _identity_model(2, dtype=torch.float64)
+ matrix = torch.diag(torch.tensor([0.9, 0.5], dtype=torch.float64))
+ with torch.no_grad():
+ model.koopman.set_dense_matrix(matrix)
+ report = model.subspace_invariance_report(sequence, held_out=True)
+ direct = subspace_invariance_report(_line_encodings(), matrix, held_out=True)
+ assert report.leakage == pytest.approx(direct.leakage, abs=1e-8)
+ assert report.rank == direct.rank
+
+
+def test_graph_operator_refuses_invariance() -> None:
+ """Networked operators are out of scope for this increment."""
+ sequence = _line_sequence()
+ model = GraphKoopmanModel(
+ encoder=_IdentityCodec(2),
+ decoder=_IdentityCodec(2),
+ latent_dim=2,
+ time_step=1.0,
+ koopman=GraphKoopmanOperator(latent_dim=2),
+ )
+ with pytest.raises(ValueError, match="per-node"):
+ model.subspace_invariance_report(sequence)
+ with pytest.raises(ValueError, match="per-node"):
+ model.evaluate(sequence, horizons=(1,), include_invariance=True)
+
+
+def test_hetero_evaluate_refuses_include_invariance() -> None:
+ """Hetero evaluate raises before silently dropping the flag."""
+ data = HeteroData()
+ data["node"].x = torch.randn(4, 3)
+ data["node", "r1", "node"].edge_index = torch.tensor(
+ [[0, 1, 2], [1, 2, 0]], dtype=torch.long
+ )
+ data["node", "r2", "node"].edge_index = torch.tensor(
+ [[0, 2], [2, 3]], dtype=torch.long
+ )
+ sequence = HeteroGraphSnapshotSequence([data.clone() for _ in range(5)])
+ model = GraphKoopmanModel(
+ encoder=RelGraphEncoder(3, 8, 4, 2, num_layers=1),
+ decoder=RelGraphDecoder(4, 8, 3, 2, num_layers=1),
+ latent_dim=4,
+ time_step=1.0,
+ koopman="hetero_graph",
+ )
+ with pytest.raises(ValueError, match="hetero"):
+ model.evaluate(sequence, horizons=(1,), include_invariance=True)
+ with pytest.raises(ValueError, match="Hetero"):
+ model.subspace_invariance_report(sequence)
diff --git a/tests/identification/test_package.py b/tests/identification/test_package.py
new file mode 100644
index 0000000..55bfade
--- /dev/null
+++ b/tests/identification/test_package.py
@@ -0,0 +1,193 @@
+"""Tests for the identification package surface (types, not solvers)."""
+
+from __future__ import annotations
+
+from dataclasses import FrozenInstanceError
+
+import pytest
+import torch
+
+import koopman_graph
+import koopman_graph.identification as identification
+from koopman_graph.identification import (
+ IDENTIFICATION_SOLVERS,
+ IdentificationBackend,
+ IdentificationConfig,
+ IdentificationReport,
+ InvarianceBlock,
+ LatentPairs,
+ LatentRankReport,
+ MetricBlock,
+ OperatorSnapshot,
+ SparseFactorReport,
+ SpectralReliabilityBlock,
+ StabilityBlock,
+ SubspaceInvarianceReport,
+ identify_sparse_graph_factors,
+ select_latent_rank,
+)
+
+
+def test_identification_not_on_root_facade() -> None:
+ """Config/report types stay off the thin root façade."""
+ for name in (
+ "IdentificationConfig",
+ "IdentificationReport",
+ "IdentificationBackend",
+ "LatentPairs",
+ "OperatorSnapshot",
+ "MetricBlock",
+ "identify_operator",
+ "ClosedFormBackend",
+ "SubspaceInvarianceReport",
+ "subspace_invariance_report",
+ "select_resdmd_gated",
+ "ResDMDGateCandidate",
+ "ResDMDGateResult",
+ "DEFAULT_RESDMD_GATE_TOLERANCE",
+ "SparseFactorReport",
+ "identify_sparse_graph_factors",
+ "LatentRankReport",
+ "select_latent_rank",
+ ):
+ assert name not in koopman_graph.__all__
+ assert not hasattr(koopman_graph, name)
+ assert name in identification.__all__
+ with pytest.raises(ImportError):
+ exec("from koopman_graph import IdentificationReport")
+
+
+def test_package_reexports_match_submodules() -> None:
+ """Package ``__all__`` names resolve to the submodule objects."""
+ assert identification.IdentificationConfig is IdentificationConfig
+ assert identification.IdentificationReport is IdentificationReport
+ assert identification.IDENTIFICATION_SOLVERS is IDENTIFICATION_SOLVERS
+ assert identification.LatentPairs is LatentPairs
+ assert identification.OperatorSnapshot is OperatorSnapshot
+ assert identification.identify_operator is not None
+ assert identification.subspace_invariance_report is not None
+ assert identification.SubspaceInvarianceReport is SubspaceInvarianceReport
+ assert identification.select_resdmd_gated is not None
+ assert identification.ResDMDGateCandidate is not None
+ assert identification.ResDMDGateResult is not None
+ assert identification.DEFAULT_RESDMD_GATE_TOLERANCE == 1e-2
+ assert identification.identify_sparse_graph_factors is identify_sparse_graph_factors
+ assert identification.SparseFactorReport is SparseFactorReport
+ assert identification.select_latent_rank is select_latent_rank
+ assert identification.LatentRankReport is LatentRankReport
+
+
+def test_default_report_has_design_field_groups() -> None:
+ """Empty report exposes reconstruction through stability plus rank slots."""
+ report = IdentificationReport()
+ assert report.reconstruction == MetricBlock()
+ assert report.one_step == MetricBlock()
+ assert report.rollout == MetricBlock()
+ assert report.closure == MetricBlock()
+ assert report.invariance == InvarianceBlock()
+ assert report.spectral == SpectralReliabilityBlock()
+ assert report.stability == StabilityBlock()
+ assert report.selected_rank is None
+ assert report.rejected_alternatives == ()
+
+
+def test_report_and_config_are_frozen() -> None:
+ """Public identification records reject in-place mutation."""
+ config = IdentificationConfig()
+ report = IdentificationReport()
+ with pytest.raises(FrozenInstanceError):
+ config.ridge = 0.1 # type: ignore[misc]
+ with pytest.raises(FrozenInstanceError):
+ report.selected_rank = 2 # type: ignore[misc]
+
+
+def test_config_defaults_and_solver_names() -> None:
+ """Default config matches the documented opt-in ridge settings."""
+ config = IdentificationConfig()
+ assert config.solver == "ridge"
+ assert config.ridge == pytest.approx(1e-4)
+ assert config.select_on == ("rollout", "invariance", "resdmd")
+ assert config.gate_resdmd is False
+ assert {
+ "ridge",
+ "tls",
+ "constrained_ls",
+ "varpro",
+ "alternating",
+ } == IDENTIFICATION_SOLVERS
+ for solver in sorted(IDENTIFICATION_SOLVERS):
+ IdentificationConfig(solver=solver) # type: ignore[arg-type]
+
+
+def test_config_rejects_unknown_solver_and_negative_ridge() -> None:
+ """Invalid solver names and non-finite ridge weights raise ValueError."""
+ with pytest.raises(ValueError, match="solver must be one of"):
+ IdentificationConfig(solver="adam") # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="ridge must be a finite"):
+ IdentificationConfig(ridge=-1e-3)
+ with pytest.raises(ValueError, match="ridge must be a finite"):
+ IdentificationConfig(ridge=float("nan"))
+ with pytest.raises(ValueError, match="gate_resdmd must be a bool"):
+ IdentificationConfig(gate_resdmd=1) # type: ignore[arg-type]
+
+
+def test_metric_block_rejects_nonfinite_mse() -> None:
+ """MSE slots are finite floats; sample counts are non-negative."""
+ with pytest.raises(ValueError, match="mse must be a finite"):
+ MetricBlock(mse=float("inf"))
+ with pytest.raises(ValueError, match="n_samples must be a non-negative"):
+ MetricBlock(n_samples=-1)
+ block = MetricBlock(mse=0.25, n_samples=8)
+ assert block.mse == pytest.approx(0.25)
+ assert block.n_samples == 8
+
+
+def test_invariance_and_stability_reject_negative_scalars() -> None:
+ """Leakage and spectral radius are non-negative when set."""
+ with pytest.raises(ValueError, match="leakage must be non-negative"):
+ InvarianceBlock(leakage=-0.1)
+ with pytest.raises(ValueError, match="spectral_radius must be non-negative"):
+ StabilityBlock(spectral_radius=-0.01)
+ assert InvarianceBlock(leakage=0.0).leakage == pytest.approx(0.0)
+
+
+def test_latent_pairs_require_matching_tensors() -> None:
+ """Consecutive encodings must share shape, dtype, and device."""
+ z_t = torch.zeros(4, 2)
+ z_next = torch.ones(4, 2)
+ pairs = LatentPairs(z_t=z_t, z_next=z_next)
+ assert pairs.z_t.shape == (4, 2)
+ with pytest.raises(ValueError, match="must share shape"):
+ LatentPairs(z_t=z_t, z_next=torch.zeros(3, 2))
+ with pytest.raises(TypeError, match="must be torch.Tensor"):
+ LatentPairs(z_t=z_t, z_next=[0.0]) # type: ignore[arg-type]
+
+
+def test_operator_snapshot_requires_a_factor() -> None:
+ """Empty snapshots are rejected; a dense matrix is enough."""
+ with pytest.raises(ValueError, match="requires matrix, k_self, or k_nbr"):
+ OperatorSnapshot()
+ matrix = torch.eye(4)
+ snap = OperatorSnapshot(matrix=matrix)
+ assert snap.matrix is matrix
+ assert snap.k_self is None
+
+
+def test_runtime_checkable_backend_protocol() -> None:
+ """A duck-typed object with fit_operator satisfies the protocol."""
+
+ class _RidgeStub:
+ def fit_operator(
+ self,
+ encodings: LatentPairs,
+ config: IdentificationConfig,
+ ) -> OperatorSnapshot:
+ del encodings, config
+ return OperatorSnapshot(matrix=torch.eye(2))
+
+ stub = _RidgeStub()
+ assert isinstance(stub, IdentificationBackend)
+ pairs = LatentPairs(z_t=torch.zeros(3, 2), z_next=torch.zeros(3, 2))
+ out = stub.fit_operator(pairs, IdentificationConfig())
+ assert out.matrix is not None
+ assert tuple(out.matrix.shape) == (2, 2)
diff --git a/tests/identification/test_rank.py b/tests/identification/test_rank.py
new file mode 100644
index 0000000..7c0df89
--- /dev/null
+++ b/tests/identification/test_rank.py
@@ -0,0 +1,216 @@
+"""Latent-rank selection versus dense SVD rank and Ray Tune HPO."""
+
+from __future__ import annotations
+
+import pytest
+import torch
+from tests.helpers import REPO_ROOT
+
+import koopman_graph
+import koopman_graph.identification as identification
+from koopman_graph.identification import LatentRankReport, select_latent_rank
+
+_SOURCE = REPO_ROOT / "src" / "koopman_graph" / "identification" / "rank.py"
+_TRUE_RANK = 3
+_AMBIENT = 8
+_CANDIDATES = (1, 2, 3, 4, 5, 6)
+
+
+def _rank3_ambient_encodings(
+ *,
+ num_times: int,
+ seed: int,
+ process_noise: float = 0.25,
+) -> torch.Tensor:
+ """Build a rank-3 linear Gaussian trajectory in ambient 8.
+
+ Latent dynamics are diagonal AR(1) plus optional isotropic process
+ noise, then an orthonormal embedding into :math:`\\mathbb{R}^{8}`.
+
+ Parameters
+ ----------
+ num_times : int
+ Length ``T``.
+ seed : int
+ RNG seed for the embedding, initial state, and process noise.
+ process_noise : float, optional
+ Standard deviation of latent process noise. Default ``0.25``.
+ Use ``0`` for a noiseless embedding (ResDMD elbow).
+
+ Returns
+ -------
+ Tensor
+ Encodings with shape ``(T, 8)``, float64.
+ """
+ dtype = torch.float64
+ generator = torch.Generator(device="cpu")
+ generator.manual_seed(seed)
+ true_k = torch.diag(torch.tensor([0.95, 0.90, 0.85], dtype=dtype))
+ embedding, _ = torch.linalg.qr(
+ torch.randn(_AMBIENT, _TRUE_RANK, dtype=dtype, generator=generator)
+ )
+ state = torch.randn(_TRUE_RANK, dtype=dtype, generator=generator)
+ frames = [state]
+ for _ in range(num_times - 1):
+ state = state @ true_k.T
+ if process_noise > 0.0:
+ state = state + process_noise * torch.randn(
+ _TRUE_RANK,
+ dtype=dtype,
+ generator=generator,
+ )
+ frames.append(state)
+ latent = torch.stack(frames, dim=0)
+ return latent @ embedding.T
+
+
+def _finite_float(value: float) -> bool:
+ """Return whether ``value`` is a finite Python float.
+
+ Parameters
+ ----------
+ value : float
+ Scalar.
+
+ Returns
+ -------
+ bool
+ ``True`` when finite.
+ """
+ return value == value and value not in (float("inf"), float("-inf"))
+
+
+def test_rank_helpers_exported_and_not_on_root_all() -> None:
+ """Rank helpers are identification exports, not root symbols."""
+ for name in ("LatentRankReport", "select_latent_rank"):
+ assert name in identification.__all__
+ assert name not in set(koopman_graph.__all__)
+ assert not hasattr(koopman_graph, name)
+ text = _SOURCE.read_text(encoding="utf-8")
+ assert "Wu2020VAMP" in text
+ assert "10.1007/s00332-019-09567-y" in text
+ assert "deeptime2021" in text
+ assert "10.1088/2632-2153/ac3de0" in text
+ assert "ColbrookTownsend2023ResDMD" in text
+ assert "Ray Tune" in text
+ assert "latent_dim" in text
+ assert "koopman_graph.tuning" in text
+
+
+@pytest.mark.parametrize(
+ "criterion",
+ ("vamp2", "resdmd_elbow", "stability_penalized"),
+)
+def test_linear_gaussian_oracle_recovers_true_rank(criterion: str) -> None:
+ """Seeded rank-3 linear Gaussian map in ambient 8.
+
+ ``vamp2`` / ``stability_penalized`` use driven AR(1) noise so all
+ three modes stay excited. ``resdmd_elbow`` uses the noiseless
+ embedding so max finite-dictionary residuals drop until the true
+ rank (process noise inflates extra-mode residuals).
+ """
+ noise = 0.0 if criterion == "resdmd_elbow" else 0.25
+ encodings = _rank3_ambient_encodings(
+ num_times=48,
+ seed=0,
+ process_noise=noise,
+ )
+ report = select_latent_rank(encodings, _CANDIDATES, criterion=criterion)
+ assert report.selected_rank == _TRUE_RANK
+ assert report.numerical_rank == _TRUE_RANK
+ assert report.candidates == (1, 2, 3)
+ assert report.rejected_alternatives == ("4", "5", "6")
+ assert report.criterion == criterion
+ expected_pairs = 23 if criterion == "stability_penalized" else 47
+ assert report.n_samples == expected_pairs
+ assert len(report.scores) == 3
+ assert all(_finite_float(score) for score in report.scores)
+
+
+def test_node_layout_and_validation_split_recover_rank() -> None:
+ """``(T, N, d)`` encodings and an explicit validation split still pick 3.
+
+ Same construction as the oracle; the selected rank is an integer.
+ """
+ train = _rank3_ambient_encodings(num_times=64, seed=1)
+ nodes = train[:40].unsqueeze(1).expand(-1, 3, -1).contiguous()
+ report = select_latent_rank(
+ nodes,
+ _CANDIDATES,
+ criterion="stability_penalized",
+ )
+ assert report.selected_rank == _TRUE_RANK
+ split = select_latent_rank(
+ train[:40],
+ (1, 2, 3, 4),
+ criterion="vamp2",
+ validation_z=train[40:],
+ )
+ assert split.selected_rank == _TRUE_RANK
+ assert split.n_samples == 39
+
+
+def test_rank_selection_distinct_from_ray_tune() -> None:
+ """The selector is not a Tune search space or latent_dim AutoML helper."""
+ from koopman_graph.tuning.search_spaces import example_lr_latent_dim_space
+
+ assert select_latent_rank is not example_lr_latent_dim_space
+ assert not hasattr(select_latent_rank, "param_space")
+ assert LatentRankReport is not type(example_lr_latent_dim_space)
+
+
+def test_deeptime_vamp2_ordering_when_installed() -> None:
+ """Optional deeptime VAMP-2 agrees that rank 3 beats rank 2 on the oracle.
+
+ deeptime's score includes the constant singular value (+1); the
+ in-tree mean-free score is compared after that shift. Relative
+ ``0.05`` / absolute ``0.05`` match
+ ``test_deeptime_oracle_agrees_when_installed``.
+ """
+ deeptime = pytest.importorskip("deeptime")
+ from deeptime.decomposition import VAMP
+
+ from koopman_graph.baselines.vamp2 import vamp2_score
+
+ encodings = _rank3_ambient_encodings(num_times=64, seed=4)
+ _, _, vh = torch.linalg.svd(encodings, full_matrices=False)
+ projected = encodings @ vh[:_TRUE_RANK].T
+ left = projected[:-1, :_TRUE_RANK]
+ right = projected[1:, :_TRUE_RANK]
+ our_score = float(vamp2_score(left, right))
+ model = VAMP(lagtime=1, dim=None).fit(projected[:, :_TRUE_RANK].numpy())
+ dt_score = float(model.fetch_model().score(2))
+ assert our_score == pytest.approx(dt_score - 1.0, rel=0.05, abs=0.05)
+ two = float(vamp2_score(projected[:-1, :2], projected[1:, :2]))
+ assert two < our_score
+ del deeptime
+
+
+def test_rank_guards() -> None:
+ """Invalid layouts, criteria, and empty grids raise with the constraint."""
+ encodings = _rank3_ambient_encodings(num_times=16, seed=3)
+ with pytest.raises(ValueError, match="criterion must be one of"):
+ select_latent_rank(encodings, (1, 2, 3), criterion="aic") # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="positive ints"):
+ select_latent_rank(encodings, (0, 2), criterion="vamp2")
+ with pytest.raises(ValueError, match="non-empty sequence"):
+ select_latent_rank(encodings, (), criterion="vamp2")
+ with pytest.raises(ValueError, match="T >= 2"):
+ select_latent_rank(encodings[:1], (1, 2), criterion="vamp2")
+ with pytest.raises(ValueError, match="T >= 4"):
+ select_latent_rank(
+ encodings[:3],
+ (1, 2),
+ criterion="stability_penalized",
+ )
+ with pytest.raises(ValueError, match="no candidate rank is at most"):
+ select_latent_rank(encodings, (16, 17), criterion="vamp2")
+ with pytest.raises(ValueError, match="trailing layout must match"):
+ select_latent_rank(
+ encodings,
+ (1, 2, 3),
+ criterion="vamp2",
+ validation_z=encodings[:, :4],
+ )
+ with pytest.raises(ValueError, match="ridge must be a finite"):
+ select_latent_rank(encodings, (1, 2, 3), criterion="vamp2", ridge=-1.0)
diff --git a/tests/identification/test_resdmd_gate.py b/tests/identification/test_resdmd_gate.py
new file mode 100644
index 0000000..a8e8af7
--- /dev/null
+++ b/tests/identification/test_resdmd_gate.py
@@ -0,0 +1,180 @@
+"""Residual-aware dictionary selection on a synthetic polluted lift."""
+
+from __future__ import annotations
+
+import pytest
+import torch
+
+from koopman_graph.analysis.resdmd import resdmd
+from koopman_graph.identification import (
+ DEFAULT_RESDMD_GATE_TOLERANCE,
+ IdentificationConfig,
+ LatentPairs,
+ ResDMDGateCandidate,
+ build_identification_report,
+ identify_operator,
+ select_resdmd_gated,
+)
+
+
+def _noisy_linear_trajectory(
+ true_k: torch.Tensor,
+ *,
+ n_times: int,
+ noise: float,
+ seed: int,
+) -> torch.Tensor:
+ """Simulate ``z_{t+1} = z_t K^\\top + \\varepsilon`` in :math:`\\mathbb{R}^{2}`.
+
+ Parameters
+ ----------
+ true_k : Tensor
+ Shared linear map with shape ``(2, 2)``.
+ n_times : int
+ Trajectory length.
+ noise : float
+ i.i.d. Gaussian standard deviation on the increment.
+ seed : int
+ Generator seed.
+
+ Returns
+ -------
+ Tensor
+ Time-major states with shape ``(n_times, 2)``.
+ """
+ generator = torch.Generator()
+ generator.manual_seed(seed)
+ state = torch.tensor([0.4, -0.2], dtype=true_k.dtype)
+ rows = []
+ for _ in range(n_times):
+ rows.append(state.clone())
+ state = state @ true_k.T + noise * torch.randn(
+ 2, dtype=true_k.dtype, generator=generator
+ )
+ return torch.stack(rows, dim=0)
+
+
+def _one_step_mse(psi0: torch.Tensor, psi1: torch.Tensor) -> float:
+ """Train one-step mean squared error of unconstrained least squares.
+
+ Parameters
+ ----------
+ psi0, psi1 : Tensor
+ Consecutive dictionary rows with the same shape.
+
+ Returns
+ -------
+ float
+ Mean squared residual of ``psi0 @ K ≈ psi1``.
+ """
+ solution = torch.linalg.lstsq(psi0, psi1).solution
+ return float((psi0 @ solution - psi1).square().mean().item())
+
+
+def _residual_max(psi0: torch.Tensor, psi1: torch.Tensor) -> float:
+ """Maximum finite-dictionary ResDMD residual.
+
+ Parameters
+ ----------
+ psi0, psi1 : Tensor
+ Consecutive dictionary rows.
+
+ Returns
+ -------
+ float
+ ``max`` of :func:`~koopman_graph.analysis.resdmd` residuals.
+ """
+ report = resdmd(psi0, psi1, tolerance=DEFAULT_RESDMD_GATE_TOLERANCE)
+ return float(report.residuals.max().real.item())
+
+
+def _polluted_dictionaries() -> tuple[ResDMDGateCandidate, ResDMDGateCandidate]:
+ """Identity dictionary versus identity plus a leaked future coordinate.
+
+ Returns
+ -------
+ tuple of ResDMDGateCandidate
+ ``identity`` then ``identity_plus_junk``.
+ """
+ true_k = torch.tensor([[0.9, 0.15], [-0.05, 0.55]], dtype=torch.float64)
+ traj = _noisy_linear_trajectory(true_k, n_times=48, noise=1e-4, seed=0)
+ psi0 = traj[:-1]
+ psi1 = traj[1:]
+ junk0 = traj[1:, :1]
+ junk1 = torch.cat([traj[2:, :1], traj[-1:, :1]], dim=0)
+ pol0 = torch.cat([psi0, junk0], dim=1)
+ pol1 = torch.cat([psi1, junk1], dim=1)
+ clean = ResDMDGateCandidate(
+ name="identity",
+ mse=_one_step_mse(psi0, psi1),
+ residual_max=_residual_max(psi0, psi1),
+ )
+ polluted = ResDMDGateCandidate(
+ name="identity_plus_junk",
+ mse=_one_step_mse(pol0, pol1),
+ residual_max=_residual_max(pol0, pol1),
+ )
+ return clean, polluted
+
+
+def test_rmse_only_picks_polluted_dictionary() -> None:
+ """Min train MSE prefers the junk lift; the residual gate keeps identity."""
+ clean, polluted = _polluted_dictionaries()
+ assert polluted.mse < clean.mse
+ assert clean.residual_max <= DEFAULT_RESDMD_GATE_TOLERANCE
+ assert polluted.residual_max > DEFAULT_RESDMD_GATE_TOLERANCE
+ rmse_only = select_resdmd_gated(
+ (clean, polluted),
+ gate_resdmd=False,
+ )
+ assert rmse_only.selected == "identity_plus_junk"
+ assert rmse_only.rejected_alternatives == ()
+ assert rmse_only.gated is False
+ gated = select_resdmd_gated((clean, polluted), gate_resdmd=True)
+ assert gated.selected == "identity"
+ assert gated.rejected_alternatives == ("identity_plus_junk",)
+ assert gated.gated is True
+ assert gated.residual_tolerance == pytest.approx(DEFAULT_RESDMD_GATE_TOLERANCE)
+
+
+def test_all_polluted_candidates_raise() -> None:
+ """Gating raises when every residual exceeds the tolerance."""
+ candidates = (
+ ResDMDGateCandidate(name="a", mse=0.1, residual_max=0.5),
+ ResDMDGateCandidate(name="b", mse=0.2, residual_max=0.4),
+ )
+ with pytest.raises(ValueError, match="rejected every candidate"):
+ select_resdmd_gated(candidates, gate_resdmd=True)
+
+
+def test_empty_and_duplicate_names_raise() -> None:
+ """Empty pools and colliding labels are refused."""
+ with pytest.raises(ValueError, match="at least one candidate"):
+ select_resdmd_gated(())
+ twin = ResDMDGateCandidate(name="same", mse=0.1, residual_max=0.0)
+ with pytest.raises(ValueError, match="unique"):
+ select_resdmd_gated((twin, twin))
+ with pytest.raises(ValueError, match="non-empty str"):
+ ResDMDGateCandidate(name="", mse=0.1, residual_max=0.0)
+ with pytest.raises(TypeError, match="gate_resdmd must be a bool"):
+ select_resdmd_gated(
+ (ResDMDGateCandidate(name="ok", mse=0.1, residual_max=0.0),),
+ gate_resdmd=1, # type: ignore[arg-type]
+ )
+
+
+def test_build_report_fills_spectral_only_when_gated() -> None:
+ """Default reports leave ``spectral`` unset; ``gate_resdmd`` fills it."""
+ true_k = torch.diag(torch.tensor([0.7, 0.4], dtype=torch.float64))
+ generator = torch.Generator()
+ generator.manual_seed(0)
+ z_t = torch.randn(20, 2, dtype=torch.float64, generator=generator)
+ pairs = LatentPairs(z_t=z_t, z_next=z_t @ true_k.T)
+ snapshot = identify_operator(pairs, IdentificationConfig(solver="ridge", ridge=0.0))
+ default = build_identification_report(pairs, snapshot)
+ assert default.spectral.polluted is None
+ assert default.spectral.residual_max is None
+ gated = build_identification_report(pairs, snapshot, gate_resdmd=True)
+ assert gated.spectral.residual_max is not None
+ assert gated.spectral.residual_max == pytest.approx(0.0, abs=1e-8)
+ assert gated.spectral.polluted is False
diff --git a/tests/identification/test_solvers.py b/tests/identification/test_solvers.py
new file mode 100644
index 0000000..424c6c7
--- /dev/null
+++ b/tests/identification/test_solvers.py
@@ -0,0 +1,230 @@
+"""Closed-form identification solvers (ridge, TLS, constrained LS)."""
+
+from __future__ import annotations
+
+import pytest
+import torch
+
+from koopman_graph.baselines.base import fit_row_operator, fit_tls_row_operator
+from koopman_graph.identification import (
+ ClosedFormBackend,
+ IdentificationBackend,
+ IdentificationConfig,
+ LatentPairs,
+ OperatorSnapshot,
+ identify_operator,
+)
+from koopman_graph.identification.solvers import apply_operator_snapshot
+from koopman_graph.operators import KoopmanOperator
+
+
+def _eigvals_match(
+ left: torch.Tensor,
+ right: torch.Tensor,
+ *,
+ rtol: float,
+ atol: float,
+) -> bool:
+ """Greedy multiset match of complex eigenvalues.
+
+ Parameters
+ ----------
+ left, right : Tensor
+ Eigenvalue vectors.
+ rtol, atol : float
+ Relative and absolute tolerances from construction.
+
+ Returns
+ -------
+ bool
+ Whether every left value matches an unused right value.
+ """
+ if left.shape != right.shape:
+ return False
+ remaining = right.detach().clone()
+ for value in left.detach():
+ diffs = (remaining - value).abs()
+ index = int(torch.argmin(diffs))
+ if not torch.isclose(value, remaining[index], rtol=rtol, atol=atol):
+ return False
+ remaining[index] = complex(float("inf"), float("inf"))
+ return True
+
+
+def _linear_pairs(
+ true_k: torch.Tensor,
+ *,
+ n_samples: int = 32,
+ seed: int = 0,
+ noise: float = 0.0,
+) -> LatentPairs:
+ """Build consecutive encodings from a known linear map.
+
+ Parameters
+ ----------
+ true_k : Tensor
+ Ground-truth ``K`` with shape ``(d, d)``.
+ n_samples : int, optional
+ Number of pairs.
+ seed : int, optional
+ Generator seed.
+ noise : float, optional
+ Gaussian standard deviation added to ``z_next``.
+
+ Returns
+ -------
+ LatentPairs
+ ``z_t`` / ``z_next`` on the same device/dtype as ``true_k``.
+ """
+ generator = torch.Generator(device="cpu")
+ generator.manual_seed(seed)
+ dim = true_k.shape[0]
+ z_t = torch.randn(n_samples, dim, dtype=true_k.dtype, generator=generator)
+ z_next = z_t @ true_k.T
+ if noise > 0.0:
+ z_next = z_next + noise * torch.randn(
+ n_samples, dim, dtype=true_k.dtype, generator=generator
+ )
+ return LatentPairs(z_t=z_t, z_next=z_next)
+
+
+def test_closed_form_backend_satisfies_protocol() -> None:
+ """Default backend is runtime-checkable."""
+ backend = ClosedFormBackend()
+ assert isinstance(backend, IdentificationBackend)
+
+
+def test_ridge_zero_matches_lstsq_row_operator() -> None:
+ """Ridge with ``ridge=0`` matches unregularized row-convention LS."""
+ true_k = torch.tensor([[0.9, 0.1], [0.0, 0.5]], dtype=torch.float64)
+ pairs = _linear_pairs(true_k)
+ snapshot = identify_operator(pairs, IdentificationConfig(solver="ridge", ridge=0.0))
+ baseline = fit_row_operator(pairs.z_t, pairs.z_next, rank=None)
+ assert snapshot.matrix is not None
+ torch.testing.assert_close(snapshot.matrix, baseline, rtol=1e-10, atol=1e-12)
+
+
+def test_tls_matches_baseline_helper() -> None:
+ """TLS identification matches ``fit_tls_row_operator``."""
+ true_k = torch.tensor([[0.8, 0.2], [-0.1, 0.7]], dtype=torch.float64)
+ pairs = _linear_pairs(true_k, n_samples=48)
+ snapshot = identify_operator(pairs, IdentificationConfig(solver="tls"))
+ baseline = fit_tls_row_operator(pairs.z_t, pairs.z_next, rank=None)
+ assert snapshot.matrix is not None
+ torch.testing.assert_close(snapshot.matrix, baseline, rtol=1e-10, atol=1e-12)
+
+
+def test_alternating_aliases_ridge() -> None:
+ """``solver='alternating'`` uses the ridge formula."""
+ true_k = torch.diag(torch.tensor([0.9, 0.4], dtype=torch.float64))
+ pairs = _linear_pairs(true_k)
+ ridge = identify_operator(pairs, IdentificationConfig(solver="ridge", ridge=1e-4))
+ alt = identify_operator(
+ pairs, IdentificationConfig(solver="alternating", ridge=1e-4)
+ )
+ assert ridge.matrix is not None and alt.matrix is not None
+ torch.testing.assert_close(alt.matrix, ridge.matrix, rtol=1e-12, atol=1e-14)
+
+
+def test_varpro_raises_not_implemented() -> None:
+ """Variable projection is refused with a pointer to OptDMD."""
+ true_k = torch.eye(2, dtype=torch.float64)
+ pairs = _linear_pairs(true_k, n_samples=8)
+ with pytest.raises(NotImplementedError, match="varpro"):
+ identify_operator(pairs, IdentificationConfig(solver="varpro"))
+
+
+def test_constrained_ls_projects_spectral_radius() -> None:
+ """Constrained LS scales ``ρ(K)`` onto the unit disk.
+
+ Synthetic ground truth: an expanding map with ``ρ=2``; after
+ projection ``ρ≤1`` within construction tolerance.
+ """
+ true_k = torch.tensor([[2.0, 0.0], [0.0, 1.5]], dtype=torch.float64)
+ pairs = _linear_pairs(true_k, n_samples=40)
+ unconstrained = identify_operator(
+ pairs, IdentificationConfig(solver="ridge", ridge=0.0)
+ )
+ constrained = identify_operator(
+ pairs, IdentificationConfig(solver="constrained_ls", ridge=0.0)
+ )
+ assert unconstrained.matrix is not None and constrained.matrix is not None
+ rho_open = float(torch.linalg.eigvals(unconstrained.matrix).abs().max().real)
+ rho_proj = float(torch.linalg.eigvals(constrained.matrix).abs().max().real)
+ assert rho_open > 1.0
+ assert rho_proj == pytest.approx(1.0, rel=1e-6, abs=1e-8)
+
+
+def test_noiseless_oracle_recovers_eigenvalues() -> None:
+ """Ridge on a noiseless linear map recovers eigenvalues.
+
+ Synthetic ground truth in float64; ``rtol=1e-5``, ``atol=1e-8`` from
+ construction (independent oracle, not a literature table).
+ """
+ true_k = torch.tensor([[0.9, 0.2], [-0.1, 0.6]], dtype=torch.float64)
+ pairs = _linear_pairs(true_k, n_samples=64)
+ snapshot = identify_operator(pairs, IdentificationConfig(solver="ridge", ridge=0.0))
+ assert snapshot.matrix is not None
+ assert _eigvals_match(
+ torch.linalg.eigvals(snapshot.matrix),
+ torch.linalg.eigvals(true_k),
+ rtol=1e-5,
+ atol=1e-8,
+ )
+
+
+def test_noisy_oracle_tls_recovers_eigenvalues() -> None:
+ """TLS on a linear Gaussian latent recovers eigenvalues within slack.
+
+ Process noise ``σ=1e-3`` on ``z_next``; ``rtol=5e-2``, ``atol=1e-3``
+ from this construction (seeded; not a bootstrap interval).
+ """
+ true_k = torch.diag(torch.tensor([0.85, 0.4], dtype=torch.float64))
+ pairs = _linear_pairs(true_k, n_samples=128, seed=7, noise=1e-3)
+ snapshot = identify_operator(pairs, IdentificationConfig(solver="tls"))
+ assert snapshot.matrix is not None
+ assert _eigvals_match(
+ torch.linalg.eigvals(snapshot.matrix),
+ torch.linalg.eigvals(true_k),
+ rtol=5e-2,
+ atol=1e-3,
+ )
+
+
+def test_identification_report_fills_one_step_and_radius() -> None:
+ """Report helper fills one-step MSE and spectral radius; invariance stays empty."""
+ from koopman_graph.identification import build_identification_report
+
+ true_k = torch.diag(torch.tensor([0.7, 0.4], dtype=torch.float64))
+ pairs = _linear_pairs(true_k, n_samples=20)
+ snapshot = identify_operator(pairs, IdentificationConfig(solver="ridge", ridge=0.0))
+ report = build_identification_report(pairs, snapshot, rollout_horizon=3)
+ assert report.one_step.mse == pytest.approx(0.0, abs=1e-12)
+ assert report.one_step.n_samples == 20 * 2
+ assert report.rollout.mse is not None
+ assert report.stability.spectral_radius == pytest.approx(0.7, rel=1e-6, abs=1e-8)
+ assert report.invariance.leakage is None
+ assert report.spectral.polluted is None
+ assert report.reconstruction.mse is None
+
+
+def test_apply_operator_snapshot_writes_dense_k() -> None:
+ """Identified matrix is copied onto a dense ``KoopmanOperator``."""
+ operator = KoopmanOperator(latent_dim=2, parameterization="dense")
+ matrix = torch.tensor([[0.5, 0.1], [0.0, 0.3]])
+ apply_operator_snapshot(operator, OperatorSnapshot(matrix=matrix))
+ torch.testing.assert_close(operator.K.detach(), matrix, rtol=1e-6, atol=1e-8)
+
+
+def test_apply_rejects_graph_and_controlled_operators() -> None:
+ """Non-per-node and controlled operators raise."""
+ from koopman_graph.operators import GraphKoopmanOperator
+
+ matrix = torch.eye(2)
+ snapshot = OperatorSnapshot(matrix=matrix)
+ graph = GraphKoopmanOperator(latent_dim=2)
+ with pytest.raises(ValueError, match="per-node"):
+ apply_operator_snapshot(graph, snapshot)
+ controlled = KoopmanOperator(latent_dim=2, control_dim=1)
+ with pytest.raises(ValueError, match="controlled"):
+ apply_operator_snapshot(controlled, snapshot)
diff --git a/tests/identification/test_sparse_factors.py b/tests/identification/test_sparse_factors.py
new file mode 100644
index 0000000..fe7eb17
--- /dev/null
+++ b/tests/identification/test_sparse_factors.py
@@ -0,0 +1,314 @@
+"""Sparse graph-factor identification versus dense joint LS and SINDy."""
+
+from __future__ import annotations
+
+import pytest
+import torch
+from tests.helpers import REPO_ROOT
+
+import koopman_graph
+import koopman_graph.identification as identification
+from koopman_graph.analysis import identify_sparse_dynamics
+from koopman_graph.identification import (
+ SparseFactorReport,
+ identify_sparse_graph_factors,
+)
+from koopman_graph.losses import KoopmanSparsityLoss
+
+_SOURCE = REPO_ROOT / "src" / "koopman_graph" / "identification" / "sparse_factors.py"
+
+
+def _path_edge_index(num_nodes: int) -> torch.Tensor:
+ """Undirected path edges on ``num_nodes`` nodes.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Path length.
+
+ Returns
+ -------
+ Tensor
+ COO index with shape ``(2, 2*(num_nodes-1))``.
+ """
+ sources = list(range(num_nodes - 1)) + list(range(1, num_nodes))
+ targets = list(range(1, num_nodes)) + list(range(num_nodes - 1))
+ return torch.tensor([sources, targets], dtype=torch.long)
+
+
+def _rollout_pairs(
+ k_self: torch.Tensor,
+ k_nbr: torch.Tensor,
+ adjacency: torch.Tensor,
+ *,
+ num_times: int,
+ seed: int,
+) -> torch.Tensor:
+ """Build consecutive encodings from the one-tap graph map.
+
+ Parameters
+ ----------
+ k_self, k_nbr : Tensor
+ True factors ``(d, d)``.
+ adjacency : Tensor
+ Dense ``Â``.
+ num_times : int
+ Length ``T``.
+ seed : int
+ RNG seed for the initial snapshot.
+
+ Returns
+ -------
+ Tensor
+ Encodings with shape ``(T, N, d)``.
+ """
+ generator = torch.Generator(device=k_self.device)
+ generator.manual_seed(seed)
+ num_nodes = int(adjacency.shape[0])
+ latent_dim = int(k_self.shape[0])
+ state = torch.randn(
+ num_nodes,
+ latent_dim,
+ dtype=k_self.dtype,
+ generator=generator,
+ )
+ trajectory = [state]
+ for _ in range(num_times - 1):
+ neighbor = adjacency @ state
+ state = state @ k_self.T + neighbor @ k_nbr.T
+ trajectory.append(state)
+ return torch.stack(trajectory, dim=0)
+
+
+def _dense_adjacency(num_nodes: int, dtype: torch.dtype) -> torch.Tensor:
+ """Symmetric-normalized path adjacency used by the identifier.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Path length.
+ dtype : torch.dtype
+ Floating dtype.
+
+ Returns
+ -------
+ Tensor
+ Dense ``Â``.
+ """
+ from koopman_graph.graph_utils.topology import (
+ dense_symmetric_normalized_adjacency,
+ )
+
+ return dense_symmetric_normalized_adjacency(
+ _path_edge_index(num_nodes),
+ num_nodes,
+ dtype=dtype,
+ )
+
+
+def _dense_random_walk_adjacency(num_nodes: int, dtype: torch.dtype) -> torch.Tensor:
+ """Forward random-walk normalized path adjacency.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Path length.
+ dtype : torch.dtype
+ Floating dtype.
+
+ Returns
+ -------
+ Tensor
+ Dense ``Â``.
+ """
+ from koopman_graph.graph_utils.topology import (
+ dense_random_walk_normalized_adjacency,
+ )
+
+ return dense_random_walk_normalized_adjacency(
+ _path_edge_index(num_nodes),
+ num_nodes,
+ dtype=dtype,
+ direction="forward",
+ )
+
+
+def test_sparse_factors_exported_and_not_on_root_all() -> None:
+ """Sparse-factor helpers are identification exports, not root symbols."""
+ for name in ("SparseFactorReport", "identify_sparse_graph_factors"):
+ assert name in identification.__all__
+ assert name not in set(koopman_graph.__all__)
+ assert not hasattr(koopman_graph, name)
+ text = _SOURCE.read_text(encoding="utf-8")
+ assert "10.1073/pnas.1517384113" in text
+ assert "Brunton2016SINDy" in text
+ assert "10.1017/jfm.2021.271" in text
+ assert "Pan2021SparseSubspace" in text
+ assert "identify_sparse_dynamics" in text
+ assert "KoopmanSparsityLoss" in text
+ assert "multi-task" in text
+
+
+def test_stlsq_recovers_sparse_factors_sparser_than_dense_ls() -> None:
+ """Elementwise STLSQ matches a sparse oracle and zeros more than dense LS.
+
+ Independent construction of ``Z_{t+1} = Z_t K_self^T + (Â Z_t) K_nbr^T``
+ on a path; float64 residual of that linear map, so ``rtol`` ``1e-8`` /
+ ``atol`` ``1e-10`` on recovered factors.
+ """
+ dtype = torch.float64
+ num_nodes = 6
+ k_self = torch.tensor(
+ [[0.50, 0.20, 0.00], [0.00, 0.40, 0.00], [0.00, 0.00, 0.30]],
+ dtype=dtype,
+ )
+ k_nbr = torch.tensor(
+ [[0.00, 0.15, 0.00], [0.00, 0.00, 0.00], [0.00, 0.00, 0.00]],
+ dtype=dtype,
+ )
+ adjacency = _dense_adjacency(num_nodes, dtype)
+ z_pairs = _rollout_pairs(k_self, k_nbr, adjacency, num_times=24, seed=0)
+ edges = _path_edge_index(num_nodes)
+ dense = identify_sparse_graph_factors(
+ z_pairs,
+ edges,
+ group="none",
+ method="stlsq",
+ threshold=0.0,
+ )
+ sparse = identify_sparse_graph_factors(
+ z_pairs,
+ edges,
+ group="none",
+ method="stlsq",
+ threshold=0.05,
+ )
+ assert sparse.nnz < dense.nnz
+ assert sparse.nnz == 5
+ torch.testing.assert_close(sparse.K_self, k_self, rtol=1e-8, atol=1e-10)
+ torch.testing.assert_close(sparse.K_nbr, k_nbr, rtol=1e-8, atol=1e-10)
+ assert sparse.residual == pytest.approx(0.0, abs=1e-12)
+ assert sparse.method == "stlsq"
+ assert sparse.group == "none"
+
+
+def test_self_nbr_group_zeros_true_zero_neighbor_factor() -> None:
+ """``group='self_nbr'`` drops a whole factor whose Frobenius norm is small.
+
+ Oracle ``K_nbr = 0``; threshold sits between the two factor norms of
+ unconstrained least squares. Construction residual, so recovered
+ ``K_self`` uses ``rtol`` ``1e-8`` / ``atol`` ``1e-10``.
+ """
+ dtype = torch.float64
+ k_self = torch.diag(torch.tensor([0.8, 0.6, 0.5], dtype=dtype))
+ k_nbr = torch.zeros(3, 3, dtype=dtype)
+ adjacency = _dense_adjacency(5, dtype)
+ z_pairs = _rollout_pairs(k_self, k_nbr, adjacency, num_times=20, seed=1)
+ edges = _path_edge_index(5)
+ dense = identify_sparse_graph_factors(
+ z_pairs,
+ edges,
+ group="self_nbr",
+ method="stlsq",
+ threshold=0.0,
+ )
+ neighbor_norm = float(dense.K_nbr.norm().item())
+ self_norm = float(dense.K_self.norm().item())
+ assert neighbor_norm < 0.5 * self_norm
+ cutoff = 0.5 * (neighbor_norm + self_norm)
+ report = identify_sparse_graph_factors(
+ z_pairs,
+ edges,
+ group="self_nbr",
+ method="stlsq",
+ threshold=cutoff,
+ )
+ assert torch.equal(report.K_nbr, torch.zeros_like(k_nbr))
+ torch.testing.assert_close(report.K_self, k_self, rtol=1e-8, atol=1e-10)
+
+
+def test_orbit_group_and_group_lasso_smoke() -> None:
+ """Latent-row grouping and ISTA group-lasso return finite refit factors."""
+ dtype = torch.float64
+ k_self = torch.diag(torch.tensor([0.7, 0.4, 0.0], dtype=dtype))
+ k_nbr = torch.zeros(3, 3, dtype=dtype)
+ adjacency = _dense_adjacency(4, dtype)
+ z_pairs = _rollout_pairs(k_self, k_nbr, adjacency, num_times=16, seed=2)
+ edges = _path_edge_index(4)
+ orbit = identify_sparse_graph_factors(
+ z_pairs,
+ edges,
+ group="orbit",
+ method="stlsq",
+ threshold=0.2,
+ )
+ assert orbit.K_self.shape == (3, 3)
+ assert torch.isfinite(orbit.K_self).all()
+ lasso = identify_sparse_graph_factors(
+ z_pairs,
+ edges,
+ group="self_nbr",
+ method="group_lasso",
+ threshold=0.05,
+ max_iter=80,
+ )
+ assert lasso.method == "group_lasso"
+ assert torch.isfinite(lasso.K_self).all()
+ assert lasso.nnz <= 2 * 9
+ walk_adj = _dense_random_walk_adjacency(4, dtype)
+ walk_pairs = _rollout_pairs(k_self, k_nbr, walk_adj, num_times=16, seed=3)
+ walk = identify_sparse_graph_factors(
+ walk_pairs,
+ edges,
+ group="none",
+ method="stlsq",
+ threshold=0.05,
+ adjacency="random_walk",
+ )
+ assert walk.n_samples == 15 * 4
+ torch.testing.assert_close(walk.K_self, k_self, rtol=1e-8, atol=1e-10)
+ torch.testing.assert_close(walk.K_nbr, k_nbr, rtol=1e-8, atol=1e-10)
+
+
+def test_sparse_factors_distinct_from_sindy_and_l1_loss() -> None:
+ """Graph-factor ID is not latent SINDy and not a training L1 penalty."""
+ assert identify_sparse_graph_factors is not identify_sparse_dynamics
+ assert SparseFactorReport is not type(KoopmanSparsityLoss)
+ assert not hasattr(identify_sparse_graph_factors, "library")
+
+
+def test_sparse_factor_guards() -> None:
+ """Invalid layouts, methods, and dual adjacency raise with the constraint."""
+ z_pairs = torch.randn(4, 3, 2, dtype=torch.float64)
+ edges = _path_edge_index(3)
+ with pytest.raises(ValueError, match="T >= 2"):
+ identify_sparse_graph_factors(
+ z_pairs[:1],
+ edges,
+ threshold=0.1,
+ )
+ with pytest.raises(ValueError, match="group must be one of"):
+ identify_sparse_graph_factors(
+ z_pairs,
+ edges,
+ group="nodes", # type: ignore[arg-type]
+ threshold=0.1,
+ )
+ with pytest.raises(ValueError, match="method must be one of"):
+ identify_sparse_graph_factors(
+ z_pairs,
+ edges,
+ method="omp", # type: ignore[arg-type]
+ threshold=0.1,
+ )
+ with pytest.raises(ValueError, match="dual random-walk is out of scope"):
+ identify_sparse_graph_factors(
+ z_pairs,
+ edges,
+ adjacency="dual_random_walk", # type: ignore[arg-type]
+ threshold=0.1,
+ )
+ with pytest.raises(ValueError, match="threshold must be a finite"):
+ identify_sparse_graph_factors(z_pairs, edges, threshold=-0.1)
+ with pytest.raises(ValueError, match="z_pairs must have shape"):
+ identify_sparse_graph_factors(torch.randn(5, 2), edges, threshold=0.1)
diff --git a/tests/model/test_graph_dynamics_rollout.py b/tests/model/test_graph_dynamics_rollout.py
new file mode 100644
index 0000000..3cc7329
--- /dev/null
+++ b/tests/model/test_graph_dynamics_rollout.py
@@ -0,0 +1,340 @@
+"""Rollout, evaluate, and permutation tests for graph-state topology."""
+
+from __future__ import annotations
+
+import types
+
+import pytest
+import torch
+from torch import nn
+from torch_geometric.data import Data
+
+from koopman_graph import GNNDecoder, GNNEncoder, GraphKoopmanModel
+from koopman_graph.data import GraphDynamicsConfig, GraphSnapshotSequence
+from koopman_graph.nn.predicted_topology import (
+ PredictedTopologyHead,
+ build_supervision_index,
+ candidate_edge_labels,
+ decode_weighted_topology,
+)
+from koopman_graph.training import LossWeights, compute_batched_training_loss
+from koopman_graph.uq import EnsembleGraphKoopmanModel
+
+
+def _path_edge_index(num_nodes: int) -> torch.Tensor:
+ """Return a bidirectional path graph.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count (at least 2).
+
+ Returns
+ -------
+ Tensor
+ COO index ``(2, 2*(N-1))``.
+ """
+ src = list(range(num_nodes - 1)) + list(range(1, num_nodes))
+ dst = list(range(1, num_nodes)) + list(range(num_nodes - 1))
+ return torch.tensor([src, dst], dtype=torch.long)
+
+
+def _cycle_edge_index(num_nodes: int) -> torch.Tensor:
+ """Return a bidirectional cycle.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count (at least 3).
+
+ Returns
+ -------
+ Tensor
+ COO index.
+ """
+ forward_src = list(range(num_nodes))
+ forward_dst = list(range(1, num_nodes)) + [0]
+ src = forward_src + forward_dst
+ dst = forward_dst + forward_src
+ return torch.tensor([src, dst], dtype=torch.long)
+
+
+def _directed_gnp_edge_index(num_nodes: int, *, seed: int) -> torch.Tensor:
+ """Return a directed G(n,p) COO without self-loops.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count.
+ seed : int
+ RNG seed.
+
+ Returns
+ -------
+ Tensor
+ COO index with at least one edge.
+ """
+ generator = torch.Generator().manual_seed(seed)
+ mask = torch.rand(num_nodes, num_nodes, generator=generator) < 0.35
+ mask.fill_diagonal_(False)
+ src, dst = mask.nonzero(as_tuple=True)
+ if src.numel() == 0:
+ src = torch.arange(num_nodes)
+ dst = (src + 1) % num_nodes
+ return torch.stack([src, dst], dim=0)
+
+
+def _tiny_graph_model(
+ *,
+ graph_dynamics: GraphDynamicsConfig | None,
+ in_channels: int = 2,
+ latent_dim: int = 2,
+ koopman: str | None = "graph",
+) -> GraphKoopmanModel:
+ """Return a small homogeneous model.
+
+ Parameters
+ ----------
+ graph_dynamics : GraphDynamicsConfig or None
+ Opt-in graph-state config.
+ in_channels : int, optional
+ Feature width.
+ latent_dim : int, optional
+ Latent width.
+ koopman : str or None, optional
+ Operator kind.
+
+ Returns
+ -------
+ GraphKoopmanModel
+ Untrained model.
+ """
+ return GraphKoopmanModel(
+ GNNEncoder(in_channels, 8, latent_dim, num_layers=1),
+ GNNDecoder(latent_dim, 8, in_channels, num_layers=1),
+ latent_dim=latent_dim,
+ time_step=0.1,
+ koopman=koopman,
+ graph_dynamics=graph_dynamics,
+ )
+
+
+def test_default_predict_matches_hold_last() -> None:
+ """``graph_dynamics=None`` stays on the 0.14 hold-last schedule."""
+ torch.manual_seed(0)
+ baseline = _tiny_graph_model(graph_dynamics=None)
+ configured = _tiny_graph_model(graph_dynamics=GraphDynamicsConfig())
+ configured.encoder.load_state_dict(baseline.encoder.state_dict())
+ configured.decoder.load_state_dict(baseline.decoder.state_dict())
+ configured.koopman.load_state_dict(baseline.koopman.state_dict())
+ origin = Data(x=torch.randn(6, 2), edge_index=_path_edge_index(6))
+ baseline.eval()
+ configured.eval()
+ with torch.no_grad():
+ hold = baseline.predict(origin, steps=3)
+ auto = configured.predict(origin, steps=3, topology_policy="hold_last")
+ for left, right in zip(hold, auto, strict=True):
+ torch.testing.assert_close(left.x, right.x, atol=1e-6, rtol=0.0)
+
+
+def test_recursive_predict_differs_from_hold_last() -> None:
+ """A recursive head changes the forecast relative to hold-last."""
+ torch.manual_seed(1)
+ model = _tiny_graph_model(graph_dynamics=GraphDynamicsConfig(candidate_k=4))
+ origin = Data(x=torch.randn(6, 2), edge_index=_path_edge_index(6))
+ model.eval()
+ with torch.no_grad():
+ recursive = model.predict(origin, steps=2, topology_policy="recursive")
+ hold = model.predict(origin, steps=2, topology_policy="hold_last")
+ assert not torch.allclose(recursive[0].x, hold[0].x, atol=1e-6)
+ assert recursive[0].edge_weight is not None
+ assert hold[0].edge_weight is None or not torch.equal(
+ recursive[0].edge_index, hold[0].edge_index
+ )
+
+
+def test_oracle_future_topologies_override_recursive() -> None:
+ """Supplied ``future_topologies`` keep the oracle hold-last schedule."""
+ torch.manual_seed(2)
+ model = _tiny_graph_model(graph_dynamics=GraphDynamicsConfig())
+ origin = Data(x=torch.randn(6, 2), edge_index=_path_edge_index(6))
+ future = Data(x=torch.zeros(6, 2), edge_index=_cycle_edge_index(6))
+ model.eval()
+ with torch.no_grad():
+ with_future = model.predict(origin, steps=1, future_topologies=[future])
+ hold = model.predict(
+ origin,
+ steps=1,
+ future_topologies=[future],
+ topology_policy="hold_last",
+ )
+ torch.testing.assert_close(with_future[0].x, hold[0].x, atol=1e-6, rtol=0.0)
+ assert torch.equal(with_future[0].edge_index, future.edge_index)
+
+
+def test_evaluate_recursive_does_not_inject_oracle_futures() -> None:
+ """Recursive evaluate does not pass oracle future edges into ``predict``."""
+ torch.manual_seed(3)
+ g0 = _path_edge_index(6)
+ g1 = _cycle_edge_index(6)
+ snapshots = [
+ Data(x=torch.randn(6, 2), edge_index=g0 if t < 3 else g1) for t in range(8)
+ ]
+ sequence = GraphSnapshotSequence(snapshots, allow_dynamic_topology=True)
+ model = _tiny_graph_model(graph_dynamics=GraphDynamicsConfig())
+ seen: list[object] = []
+ original = model.predict
+
+ def _wrapped(*args: object, **kwargs: object) -> object:
+ seen.append(kwargs.get("future_topologies"))
+ return original(*args, **kwargs)
+
+ model.predict = _wrapped # type: ignore[method-assign]
+ model.evaluate(sequence, horizons=(1,), start_indices=(0,))
+ assert seen
+ assert all(item is None for item in seen)
+
+
+def test_evaluate_default_still_injects_oracle_without_graph_dynamics() -> None:
+ """0.14 evaluate on dynamic sequences still injects oracle futures."""
+ torch.manual_seed(4)
+ g0 = _path_edge_index(6)
+ g1 = _cycle_edge_index(6)
+ snapshots = [
+ Data(x=torch.randn(6, 2), edge_index=g0 if t < 3 else g1) for t in range(8)
+ ]
+ sequence = GraphSnapshotSequence(snapshots, allow_dynamic_topology=True)
+ model = _tiny_graph_model(graph_dynamics=None)
+ seen: list[object] = []
+ original = model.predict
+
+ def _wrapped(*args: object, **kwargs: object) -> object:
+ seen.append(kwargs.get("future_topologies"))
+ return original(*args, **kwargs)
+
+ model.predict = _wrapped # type: ignore[method-assign]
+ model.evaluate(sequence, horizons=(1,), start_indices=(0,))
+ assert seen
+ assert all(item is not None for item in seen)
+
+
+def test_recursive_policy_without_head_raises() -> None:
+ """Explicit recursive policy requires an attached topology head."""
+ model = _tiny_graph_model(graph_dynamics=None)
+ origin = Data(x=torch.randn(4, 2), edge_index=_path_edge_index(4))
+ with pytest.raises(ValueError, match="recursive"):
+ model.predict(origin, steps=1, topology_policy="recursive")
+
+
+def test_batch_graphs_rejects_recursive_graph_state() -> None:
+ """``batch_graphs=True`` refuses recursive predicted topology."""
+ model = _tiny_graph_model(graph_dynamics=GraphDynamicsConfig())
+ snapshots = [
+ Data(x=torch.randn(4, 2), edge_index=_path_edge_index(4)) for _ in range(3)
+ ]
+ sequence = GraphSnapshotSequence(snapshots)
+ with pytest.raises(ValueError, match="recursive graph-state"):
+ compute_batched_training_loss(model, (sequence,), LossWeights())
+
+
+def test_dense_logits_permute_with_nodes() -> None:
+ """Permuting nodes permutes dense pairwise logits."""
+ torch.manual_seed(5)
+ head = PredictedTopologyHead(3, hidden_dim=8, max_nodes=8)
+ z = torch.randn(5, 3)
+ perm = torch.tensor([2, 0, 4, 1, 3])
+ logits = head.pairwise_logits(z)
+ logits_perm = head.pairwise_logits(z[perm])
+ torch.testing.assert_close(
+ logits_perm,
+ logits[perm][:, perm],
+ atol=1e-5,
+ rtol=0.0,
+ )
+
+
+def test_supervision_index_keeps_union_over_cap() -> None:
+ """Supervision keeps current ∪ next even when that exceeds ``k``."""
+ current = torch.tensor([[0, 0, 1], [1, 2, 3]], dtype=torch.long)
+ nxt = torch.tensor([[0, 0, 0], [3, 4, 5]], dtype=torch.long)
+ index = build_supervision_index(
+ 6,
+ candidate_k=2,
+ current_edge_index=current,
+ next_edge_index=nxt,
+ generator=torch.Generator().manual_seed(0),
+ )
+ labels = candidate_edge_labels(index, nxt, 6)
+ # All three next destinations of source 0 must remain.
+ src0 = index[1][index[0] == 0]
+ assert set(src0.tolist()) >= {3, 4, 5}
+ assert float(labels.sum()) >= 3.0
+
+
+def test_er_event_stub_head_beats_hold_last() -> None:
+ """A correct next-graph head beats hold-last after a directed G(n,p) event.
+
+ Truth is the model's own one-step oracle forecast on the post-event
+ graph. A dense stub that scores those edges high should match the
+ oracle more closely than hold-last on the pre-event path.
+ """
+ torch.manual_seed(6)
+ num_nodes = 6
+ g0 = _cycle_edge_index(num_nodes)
+ g1 = _directed_gnp_edge_index(num_nodes, seed=11)
+ origin = Data(x=torch.randn(num_nodes, 2), edge_index=g0)
+ nxt = Data(x=torch.zeros(num_nodes, 2), edge_index=g1)
+ model = _tiny_graph_model(
+ graph_dynamics=GraphDynamicsConfig(topology_head="dense_mlp"),
+ koopman="graph",
+ )
+ assert isinstance(model.predicted_topology, PredictedTopologyHead)
+
+ def _oracle_logits(self: PredictedTopologyHead, z: torch.Tensor) -> torch.Tensor:
+ del self
+ logits = torch.full(
+ (z.shape[0], z.shape[0]),
+ -8.0,
+ device=z.device,
+ dtype=z.dtype,
+ )
+ logits[g1[0], g1[1]] = 8.0
+ logits.fill_diagonal_(-1e9)
+ return logits
+
+ model.predicted_topology.pairwise_logits = types.MethodType( # type: ignore[method-assign]
+ _oracle_logits,
+ model.predicted_topology,
+ )
+ model.eval()
+ with torch.no_grad():
+ truth = model.predict(origin, steps=1, future_topologies=[nxt])[0]
+ recursive = model.predict(origin, steps=1, topology_policy="recursive")[0]
+ hold = model.predict(origin, steps=1, topology_policy="hold_last")[0]
+ mse_rec = float(nn.functional.mse_loss(recursive.x, truth.x))
+ mse_hold = float(nn.functional.mse_loss(hold.x, truth.x))
+ assert mse_rec < mse_hold
+
+
+def test_uq_ensemble_respects_topology_policy() -> None:
+ """Ensemble rollout uses the same recursive vs hold-last split as predict."""
+ torch.manual_seed(8)
+ model = _tiny_graph_model(graph_dynamics=GraphDynamicsConfig(candidate_k=4))
+ ensemble = EnsembleGraphKoopmanModel([model])
+ origin = Data(x=torch.randn(6, 2), edge_index=_path_edge_index(6))
+ recursive = ensemble.predict(origin, steps=1, topology_policy="recursive")
+ hold = ensemble.predict(origin, steps=1, topology_policy="hold_last")
+ assert not torch.allclose(recursive[0].x, hold[0].x, atol=1e-6)
+
+
+def test_decode_weighted_topology_uses_sigmoid_not_threshold() -> None:
+ """Operator topology keeps all candidates with sigmoid weights."""
+ torch.manual_seed(7)
+ model = _tiny_graph_model(graph_dynamics=GraphDynamicsConfig(candidate_k=3))
+ z = torch.randn(5, 2)
+ current = _path_edge_index(5)
+ assert model.predicted_topology is not None
+ index, weight = decode_weighted_topology(model.predicted_topology, z, current)
+ assert index.shape[1] == weight.shape[0]
+ assert torch.all(weight > 0)
+ assert torch.all(weight < 1)
diff --git a/tests/model/test_inference_coverage.py b/tests/model/test_inference_coverage.py
new file mode 100644
index 0000000..985d011
--- /dev/null
+++ b/tests/model/test_inference_coverage.py
@@ -0,0 +1,315 @@
+"""Coverage for spectrum, presence, and invariance inference helpers."""
+
+from __future__ import annotations
+
+import pytest
+import torch
+from torch_geometric.data import Data, HeteroData
+
+from koopman_graph import GNNDecoder, GNNEncoder, GraphKoopmanModel
+from koopman_graph.data import GraphSnapshotSequence, HeteroGraphSnapshotSequence
+from koopman_graph.model.inference import (
+ compute_model_spectrum,
+ latent_decode_rollout,
+ resolve_future_presence_at,
+ sequence_subspace_invariance,
+)
+from koopman_graph.nn import RelGraphDecoder, RelGraphEncoder
+from koopman_graph.operators import (
+ ContinuousGraphKoopmanOperator,
+ ContinuousHeteroGraphKoopmanOperator,
+ ContinuousKoopmanOperator,
+ GraphKoopmanOperator,
+ HeteroGraphKoopmanOperator,
+ HypergraphKoopmanOperator,
+ KoopmanOperator,
+)
+
+
+def _path_edge_index(num_nodes: int) -> torch.Tensor:
+ """Return a bidirectional path graph.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count (at least 2).
+
+ Returns
+ -------
+ Tensor
+ COO index.
+ """
+ src = list(range(num_nodes - 1)) + list(range(1, num_nodes))
+ dst = list(range(1, num_nodes)) + list(range(num_nodes - 1))
+ return torch.tensor([src, dst], dtype=torch.long)
+
+
+def _tiny_model() -> GraphKoopmanModel:
+ """Return a small homogeneous per-node model.
+
+ Returns
+ -------
+ GraphKoopmanModel
+ One-layer GCN stack.
+ """
+ return GraphKoopmanModel(
+ encoder=GNNEncoder(2, 8, 3, num_layers=1),
+ decoder=GNNDecoder(3, 8, 2, num_layers=1),
+ latent_dim=3,
+ time_step=0.1,
+ koopman_init_mode="identity",
+ koopman_init_scale=0.0,
+ )
+
+
+def test_compute_model_spectrum_hetero_success() -> None:
+ """Discrete hetero spectrum uses the assembled multiplex operator."""
+ operator = HeteroGraphKoopmanOperator(3, num_relations=2)
+ edge_indices = (
+ torch.tensor([[0, 1], [1, 2]], dtype=torch.long),
+ torch.tensor([[0, 2], [2, 3]], dtype=torch.long),
+ )
+ spectrum = compute_model_spectrum(
+ operator,
+ uses_graph_koopman=False,
+ is_continuous=False,
+ time_step=1.0,
+ uses_hetero_koopman=True,
+ edge_indices=edge_indices,
+ num_nodes=4,
+ )
+ assert spectrum.eigenvalues.numel() == 12
+
+ model = GraphKoopmanModel(
+ encoder=RelGraphEncoder(3, 8, 4, 2, num_layers=1),
+ decoder=RelGraphDecoder(4, 8, 3, 2, num_layers=1),
+ latent_dim=4,
+ time_step=1.0,
+ koopman="hetero_graph",
+ )
+ origin = HeteroData()
+ origin["node"].x = torch.randn(4, 3)
+ origin["node", "r1", "node"].edge_index = edge_indices[0]
+ origin["node", "r2", "node"].edge_index = edge_indices[1]
+ via_model = model.spectrum(edge_indices=edge_indices, num_nodes=4)
+ assert via_model.eigenvalues.numel() == 16
+
+
+def test_compute_model_spectrum_family_guards_and_success() -> None:
+ """Networked, continuous, and missing-topology spectrum branches run."""
+ edge = _path_edge_index(3)
+ hyper = torch.tensor([[0, 1, 2], [0, 0, 0]], dtype=torch.long)
+ banks = (
+ torch.tensor([[0, 1], [1, 2]], dtype=torch.long),
+ torch.tensor([[0, 2], [2, 0]], dtype=torch.long),
+ )
+ with pytest.raises(ValueError, match="edge_indices and num_nodes"):
+ compute_model_spectrum(
+ ContinuousHeteroGraphKoopmanOperator(2, num_relations=2),
+ uses_graph_koopman=False,
+ is_continuous=True,
+ time_step=1.0,
+ uses_continuous_hetero_koopman=True,
+ )
+ continuous_hetero = compute_model_spectrum(
+ ContinuousHeteroGraphKoopmanOperator(2, num_relations=2),
+ uses_graph_koopman=False,
+ is_continuous=True,
+ time_step=1.0,
+ uses_continuous_hetero_koopman=True,
+ edge_indices=banks,
+ num_nodes=3,
+ )
+ assert continuous_hetero.eigenvalues.numel() == 6
+ with pytest.raises(ValueError, match="edge_indices and num_nodes"):
+ compute_model_spectrum(
+ HeteroGraphKoopmanOperator(2, num_relations=2),
+ uses_graph_koopman=False,
+ is_continuous=False,
+ time_step=1.0,
+ uses_hetero_koopman=True,
+ )
+ with pytest.raises(ValueError, match="edge_index and num_nodes"):
+ compute_model_spectrum(
+ ContinuousGraphKoopmanOperator(2),
+ uses_graph_koopman=False,
+ is_continuous=True,
+ time_step=1.0,
+ uses_continuous_graph_koopman=True,
+ )
+ continuous_graph = compute_model_spectrum(
+ ContinuousGraphKoopmanOperator(2),
+ uses_graph_koopman=False,
+ is_continuous=True,
+ time_step=1.0,
+ uses_continuous_graph_koopman=True,
+ edge_index=edge,
+ num_nodes=3,
+ )
+ assert continuous_graph.eigenvalues.numel() == 6
+ with pytest.raises(ValueError, match="hyperedge_index and num_nodes"):
+ compute_model_spectrum(
+ HypergraphKoopmanOperator(2),
+ uses_graph_koopman=False,
+ is_continuous=False,
+ time_step=1.0,
+ uses_hypergraph_koopman=True,
+ )
+ hyper_spec = compute_model_spectrum(
+ HypergraphKoopmanOperator(2),
+ uses_graph_koopman=False,
+ is_continuous=False,
+ time_step=1.0,
+ uses_hypergraph_koopman=True,
+ hyperedge_index=hyper,
+ num_nodes=3,
+ )
+ assert hyper_spec.eigenvalues.numel() == 6
+ with pytest.raises(ValueError, match="edge_index and num_nodes"):
+ compute_model_spectrum(
+ GraphKoopmanOperator(2),
+ uses_graph_koopman=True,
+ is_continuous=False,
+ time_step=1.0,
+ )
+ graph_spec = compute_model_spectrum(
+ GraphKoopmanOperator(2),
+ uses_graph_koopman=True,
+ is_continuous=False,
+ time_step=1.0,
+ edge_index=edge,
+ num_nodes=3,
+ )
+ assert graph_spec.eigenvalues.numel() == 6
+ auxiliary = ContinuousKoopmanOperator(2, parameterization="auxiliary_spectral")
+ with pytest.raises(ValueError, match="auxiliary_spectral"):
+ compute_model_spectrum(
+ auxiliary,
+ uses_graph_koopman=False,
+ is_continuous=True,
+ time_step=1.0,
+ )
+ generator = compute_model_spectrum(
+ ContinuousKoopmanOperator(2),
+ uses_graph_koopman=False,
+ is_continuous=True,
+ time_step=1.0,
+ )
+ discrete = compute_model_spectrum(
+ ContinuousKoopmanOperator(2),
+ uses_graph_koopman=False,
+ is_continuous=True,
+ time_step=1.0,
+ delta_t=0.25,
+ )
+ pernode = compute_model_spectrum(
+ KoopmanOperator(2),
+ uses_graph_koopman=False,
+ is_continuous=False,
+ time_step=1.0,
+ )
+ assert generator.eigenvalues.numel() == 2
+ assert discrete.eigenvalues.numel() == 2
+ assert pernode.eigenvalues.numel() == 2
+
+
+def test_resolve_future_presence_sequence_schedule() -> None:
+ """A sequence of 1-d masks builds a callable presence schedule."""
+ masks = [
+ torch.ones(3, dtype=torch.bool),
+ torch.tensor([True, False, True]),
+ ]
+ schedule = resolve_future_presence_at(masks, steps=2, num_nodes=3)
+ assert schedule is not None
+ torch.testing.assert_close(schedule(0), masks[0])
+ torch.testing.assert_close(schedule(1), masks[1].bool())
+
+
+def test_latent_decode_rollout_hold_last_and_parameter_length() -> None:
+ """Hold-last topology runs; a short parameter schedule raises."""
+ model = _tiny_model()
+ origin = Data(x=torch.randn(3, 2), edge_index=_path_edge_index(3))
+ presence = [torch.ones(3, dtype=torch.bool)]
+ rolled = latent_decode_rollout(
+ model.koopman,
+ model.decoder,
+ model.encode_rollout_origin,
+ x_or_data=origin,
+ steps=1,
+ control_dim=0,
+ default_delta_t=0.1,
+ future_presence=presence,
+ topology_model=None,
+ )
+ assert len(rolled) == 1
+ with pytest.raises(ValueError, match="parameters for rollout"):
+ latent_decode_rollout(
+ model.koopman,
+ model.decoder,
+ model.encode_rollout_origin,
+ x_or_data=origin,
+ steps=2,
+ control_dim=0,
+ default_delta_t=0.1,
+ parameters=[torch.zeros(1)],
+ )
+ features = origin.x
+ from_tensor = latent_decode_rollout(
+ model.koopman,
+ model.decoder,
+ model.encode_rollout_origin,
+ x_or_data=features,
+ steps=1,
+ control_dim=0,
+ default_delta_t=0.1,
+ edge_index=origin.edge_index,
+ topology_model=None,
+ )
+ assert len(from_tensor) == 1
+ hyper_origin = Data(
+ x=torch.randn(3, 2),
+ edge_index=origin.edge_index,
+ hyperedge_index=torch.tensor([[0, 1, 2], [0, 0, 0]], dtype=torch.long),
+ )
+ from_hyper = latent_decode_rollout(
+ model.koopman,
+ model.decoder,
+ model.encode_rollout_origin,
+ x_or_data=hyper_origin,
+ steps=1,
+ control_dim=0,
+ default_delta_t=0.1,
+ topology_model=None,
+ )
+ assert len(from_hyper) == 1
+ preds = model.predict_at(origin, query_times=[0.1, 0.2])
+ assert len(preds) == 2
+
+
+def test_subspace_invariance_rejects_heterodata_lists() -> None:
+ """Bare HeteroData sequences are refused before ``resolve_sequence``."""
+ model = _tiny_model()
+ snap = HeteroData()
+ snap["node"].x = torch.randn(3, 2)
+ snap["node", "r", "node"].edge_index = torch.tensor(
+ [[0, 1], [1, 2]], dtype=torch.long
+ )
+ with pytest.raises(ValueError, match="HeteroData sequences"):
+ sequence_subspace_invariance(model, [snap, snap.clone()])
+ with pytest.raises(ValueError, match="HeteroData sequences"):
+ model.subspace_invariance_report([snap])
+ hetero_seq = HeteroGraphSnapshotSequence([snap, snap.clone()])
+ with pytest.raises(ValueError, match="HeteroGraphSnapshotSequence"):
+ sequence_subspace_invariance(model, hetero_seq)
+
+
+def test_subspace_invariance_accepts_plain_data_lists() -> None:
+ """A list of ``Data`` snapshots is resolved then encoded."""
+ model = _tiny_model()
+ edge = _path_edge_index(3)
+ snapshots = [Data(x=torch.randn(3, 2), edge_index=edge) for _ in range(6)]
+ report = sequence_subspace_invariance(model, snapshots, held_out=False)
+ assert report.n_samples >= 1
+ wrapped = GraphSnapshotSequence(snapshots)
+ again = model.subspace_invariance_report(wrapped, held_out=False)
+ assert again.n_samples == report.n_samples
diff --git a/tests/mpc/test_tube.py b/tests/mpc/test_tube.py
new file mode 100644
index 0000000..409a945
--- /dev/null
+++ b/tests/mpc/test_tube.py
@@ -0,0 +1,274 @@
+"""Tests for residual-tube Koopman-MPC (TASK-2347)."""
+
+from __future__ import annotations
+
+import ast
+import math
+from pathlib import Path
+
+import pytest
+import torch
+from tests.mpc.test_mpc import _calibrated_uq, _identity_plant_model, _origin
+from torch_geometric.data import Data
+
+import koopman_graph
+from koopman_graph.mpc import (
+ KoopmanMPC,
+ TubeKoopmanMPC,
+ TubeMPCReport,
+ ensemble_residual_radii,
+)
+from koopman_graph.uq import ConformalKoopmanUQ, JointCoverageSpec
+from koopman_graph.uq.common import PredictionInterval, snapshot_with_features
+
+pytest.importorskip("osqp")
+
+
+def _nominal_boxes() -> tuple[torch.Tensor, torch.Tensor]:
+ return torch.tensor([-10.0, -10.0]), torch.tensor([0.3, 10.0])
+
+
+def _stuck_at_reference_plant(graph: Data, control: torch.Tensor) -> Data:
+ """Plant whose mean output is the reference [1, 0] regardless of u."""
+ del control
+ stuck = torch.tensor([[1.0, 0.0], [1.0, 0.0]], dtype=graph.x.dtype)
+ return snapshot_with_features(graph, stuck)
+
+
+def test_tube_types_are_off_root_and_on_mpc_all() -> None:
+ """Tube types stay off the root façade and on mpc.__all__."""
+ exported = set(koopman_graph.__all__)
+ assert "TubeKoopmanMPC" not in exported
+ assert "TubeMPCReport" not in exported
+ assert "ensemble_residual_radii" not in exported
+ assert "TubeKoopmanMPC" in koopman_graph.mpc.__all__
+ assert "TubeMPCReport" in koopman_graph.mpc.__all__
+ assert "ensemble_residual_radii" in koopman_graph.mpc.__all__
+
+
+def test_tube_module_cites_zhang_and_avoids_identification() -> None:
+ """Module cites Zhang et al. and must not import identification."""
+ import koopman_graph.mpc.tube as tube
+
+ doc = tube.__doc__
+ assert doc is not None
+ assert "Zhang2022TubeMPC" in doc
+ assert "10.1016/j.automatica.2021.110114" in doc
+ assert "recursive" in doc.lower()
+ tree = ast.parse(Path(tube.__file__).read_text(encoding="utf-8"))
+ imported: list[str] = []
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ imported.extend(alias.name for alias in node.names)
+ elif isinstance(node, ast.ImportFrom) and node.module:
+ imported.append(node.module)
+ assert all(not name.startswith("koopman_graph.identification") for name in imported)
+
+
+def test_tube_rejects_bilinear_control() -> None:
+ """Bilinear plants are refused (additive discrete only)."""
+ model = _identity_plant_model(control_mode="bilinear")
+ y_min, y_max = _nominal_boxes()
+ with pytest.raises(ValueError, match="additive"):
+ TubeKoopmanMPC(
+ model,
+ horizon=2,
+ Q=torch.eye(2),
+ R=torch.eye(1),
+ residual_source=torch.tensor([0.1, 0.1]),
+ y_min=y_min,
+ y_max=y_max,
+ )
+
+
+def test_tube_rejects_uncalibrated_conformal() -> None:
+ """Uncalibrated conformal residuals raise at construction."""
+ model = _identity_plant_model()
+ y_min, y_max = _nominal_boxes()
+ with pytest.raises(RuntimeError, match="not calibrated"):
+ TubeKoopmanMPC(
+ model,
+ horizon=2,
+ Q=torch.eye(2),
+ R=torch.eye(1),
+ residual_source=ConformalKoopmanUQ(model),
+ y_min=y_min,
+ y_max=y_max,
+ )
+
+
+def test_tube_requires_nominal_output_boxes() -> None:
+ """Tube construction requires both y_min and y_max."""
+ model = _identity_plant_model()
+ with pytest.raises(TypeError, match="y_min"):
+ TubeKoopmanMPC(
+ model,
+ horizon=2,
+ Q=torch.eye(2),
+ R=torch.eye(1),
+ residual_source=torch.tensor([0.1, 0.1]),
+ y_max=torch.tensor([1.0, 1.0]),
+ )
+
+
+def test_tube_rejects_unshipped_coverage() -> None:
+ """Simultaneous / event coverage targets raise (not a chance solver)."""
+ model = _identity_plant_model()
+ y_min, y_max = _nominal_boxes()
+ with pytest.raises(ValueError, match="per_node_marginal"):
+ TubeKoopmanMPC(
+ model,
+ horizon=2,
+ Q=torch.eye(2),
+ R=torch.eye(1),
+ residual_source=torch.tensor([0.1, 0.1]),
+ y_min=y_min,
+ y_max=y_max,
+ coverage=JointCoverageSpec(target="event"),
+ )
+
+
+def test_evaluate_reports_violation_rate_not_mse_alone() -> None:
+ """Toy plant at the reference still reports a constraint-violation rate.
+
+ The stuck plant sits at ``[1, 0]``, which matches the tracking
+ reference (near-zero tracking error) while leaving ``y_max[0] = 0.3``.
+ The report must expose ``violation_rate``, not an MSE-only summary.
+ """
+ model = _identity_plant_model()
+ y_min, y_max = _nominal_boxes()
+ controller = TubeKoopmanMPC(
+ model,
+ horizon=3,
+ Q=torch.eye(2),
+ R=0.05 * torch.eye(1),
+ residual_source=torch.zeros(3),
+ y_min=y_min,
+ y_max=y_max,
+ )
+ reference = torch.tensor([1.0, 0.0])
+ report = controller.evaluate(
+ _origin(),
+ reference,
+ steps=4,
+ plant=_stuck_at_reference_plant,
+ )
+ assert isinstance(report, TubeMPCReport)
+ assert not hasattr(report, "mse")
+ assert report.n_steps == 4
+ assert report.violation_rate == pytest.approx(1.0)
+ assert report.n_violations == 4
+ assert 0.0 <= report.feasibility_rate <= 1.0
+ assert report.coverage.target == "per_node_marginal"
+ mean_x = torch.tensor([1.0, 0.0])
+ tracking_mse = float(torch.mean((mean_x - reference) ** 2))
+ assert tracking_mse == pytest.approx(0.0)
+ assert report.violation_rate > tracking_mse
+
+
+def test_evaluate_conformal_and_ensemble_radii() -> None:
+ """Calibrated conformal quantiles and explicit ensemble radii both run."""
+ model = _identity_plant_model()
+ y_min = torch.tensor([-2.0, -2.0])
+ y_max = torch.tensor([2.0, 2.0])
+ conformal = TubeKoopmanMPC(
+ model,
+ horizon=3,
+ Q=torch.eye(2),
+ R=0.05 * torch.eye(1),
+ residual_source=_calibrated_uq(model, steps=4, residual=0.05),
+ y_min=y_min,
+ y_max=y_max,
+ )
+ conformal_report = conformal.evaluate(
+ _origin(),
+ torch.tensor([0.0, 0.0]),
+ steps=3,
+ )
+ assert conformal_report.violation_rate == pytest.approx(0.0)
+ assert conformal_report.feasibility_rate == pytest.approx(1.0)
+ assert math.isfinite(conformal_report.cost)
+
+ radii = torch.tensor([0.05, 0.05, 0.05])
+ ensemble = TubeKoopmanMPC(
+ model,
+ horizon=3,
+ Q=torch.eye(2),
+ R=0.05 * torch.eye(1),
+ residual_source=radii,
+ y_min=y_min,
+ y_max=y_max,
+ )
+ ensemble_report = ensemble.evaluate(
+ _origin(),
+ torch.tensor([0.0, 0.0]),
+ steps=3,
+ )
+ assert ensemble_report.violation_rate == pytest.approx(0.0)
+ assert ensemble_report.feasibility_rate == pytest.approx(1.0)
+
+
+def test_ensemble_residual_radii_from_interval() -> None:
+ """PredictionInterval half-widths pool to a per-step scalar radius."""
+ edge_index = torch.tensor([[0, 1], [1, 0]], dtype=torch.long)
+ template = Data(x=torch.zeros(2, 2), edge_index=edge_index)
+ mean = snapshot_with_features(template, torch.zeros(2, 2))
+ lower = snapshot_with_features(template, torch.full((2, 2), -0.4))
+ upper = snapshot_with_features(template, torch.full((2, 2), 0.4))
+ interval = PredictionInterval(
+ mean=(mean, mean),
+ lower=(lower, lower),
+ upper=(upper, upper),
+ level=0.9,
+ n_members=3,
+ )
+ radii = ensemble_residual_radii(interval)
+ assert radii.shape == (2,)
+ assert torch.allclose(radii, torch.tensor([0.4, 0.4]))
+
+ model = _identity_plant_model()
+ controller = TubeKoopmanMPC(
+ model,
+ horizon=2,
+ Q=torch.eye(2),
+ R=torch.eye(1),
+ residual_source=interval,
+ y_min=torch.tensor([-2.0, -2.0]),
+ y_max=torch.tensor([2.0, 2.0]),
+ )
+ action = controller.solve(_origin(), torch.tensor([0.0, 0.0]))
+ assert action.shape == (1,)
+
+
+def test_tube_solve_matches_koopman_mpc_without_residuals() -> None:
+ """Zero residual radii recover the additive KoopmanMPC first action."""
+ model = _identity_plant_model()
+ y_min = torch.tensor([-2.0, -2.0])
+ y_max = torch.tensor([2.0, 2.0])
+ baseline = KoopmanMPC(
+ model,
+ horizon=4,
+ Q=torch.eye(2),
+ R=0.05 * torch.eye(1),
+ u_min=torch.tensor([-2.0]),
+ u_max=torch.tensor([2.0]),
+ y_min=y_min,
+ y_max=y_max,
+ )
+ tube = TubeKoopmanMPC(
+ model,
+ horizon=4,
+ Q=torch.eye(2),
+ R=0.05 * torch.eye(1),
+ residual_source=torch.zeros(4),
+ u_min=torch.tensor([-2.0]),
+ u_max=torch.tensor([2.0]),
+ y_min=y_min,
+ y_max=y_max,
+ )
+ reference = torch.tensor([0.4, 0.0])
+ origin = _origin()
+ assert torch.allclose(
+ tube.solve(origin, reference),
+ baseline.solve(origin, reference),
+ )
diff --git a/tests/nn/test_constraint_decoders.py b/tests/nn/test_constraint_decoders.py
new file mode 100644
index 0000000..b27563c
--- /dev/null
+++ b/tests/nn/test_constraint_decoders.py
@@ -0,0 +1,254 @@
+"""Decoded-space mass, positivity, and linear-conservation heads."""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+import pytest
+import torch
+from torch import nn
+
+import koopman_graph
+from koopman_graph.nn import (
+ LinearConservingDecoder,
+ MassConservingDecoder,
+ PositivityDecoder,
+)
+from koopman_graph.nn.constraint_decoders import (
+ DEFAULT_CONSERVATION_ATOL,
+ project_linear_conservation,
+)
+from koopman_graph.operators import KoopmanOperator
+
+_ATOL = DEFAULT_CONSERVATION_ATOL
+_N_NODES = 6
+_LATENT_DIM = 4
+_STEPS = 8
+_MASS = 1.25
+_SEED = 0
+
+
+class _FirstChannelDecoder(nn.Module):
+ """Map latents to a one-channel table (decoded-space teaching stub)."""
+
+ def forward(
+ self,
+ z: torch.Tensor,
+ edge_index: torch.Tensor,
+ edge_weight: torch.Tensor | None = None,
+ ) -> torch.Tensor:
+ del edge_index, edge_weight
+ return z[:, :1]
+
+
+class _TwoChannelDecoder(nn.Module):
+ """Copy the first two latent columns as decoded features."""
+
+ def forward(
+ self,
+ z: torch.Tensor,
+ edge_index: torch.Tensor,
+ edge_weight: torch.Tensor | None = None,
+ ) -> torch.Tensor:
+ del edge_index, edge_weight
+ return z[:, :2]
+
+
+def _path_edges(num_nodes: int) -> torch.Tensor:
+ """Oriented path used only to satisfy the decoder signature."""
+ tails = torch.arange(num_nodes - 1, dtype=torch.long)
+ return torch.stack((tails, tails + 1), dim=0)
+
+
+def test_constraint_decoders_export_off_root() -> None:
+ """Constraint heads live on ``nn.__all__``, not the root façade."""
+ assert "MassConservingDecoder" in koopman_graph.nn.__all__
+ assert "PositivityDecoder" in koopman_graph.nn.__all__
+ assert "LinearConservingDecoder" in koopman_graph.nn.__all__
+ assert "MassConservingDecoder" not in koopman_graph.__all__
+ assert "PositivityDecoder" not in koopman_graph.__all__
+ assert "LinearConservingDecoder" not in koopman_graph.__all__
+
+
+def test_constraint_decoders_module_does_not_import_model() -> None:
+ """L3 decoder heads must not import L4 model."""
+ source = Path(__file__).resolve().parents[2] / (
+ "src/koopman_graph/nn/constraint_decoders.py"
+ )
+ tree = ast.parse(source.read_text(encoding="utf-8"))
+ imported: list[str] = []
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ imported.extend(alias.name for alias in node.names)
+ elif isinstance(node, ast.ImportFrom) and node.module is not None:
+ imported.append(node.module)
+ offenders = [
+ name
+ for name in imported
+ if name == "koopman_graph.model" or name.startswith("koopman_graph.model.")
+ ]
+ assert not offenders
+
+
+def test_affine_mass_bound_over_rollout() -> None:
+ """``||1^T x_t - M||_inf`` stays below ``1e-6`` for ``T`` decoded steps."""
+ torch.manual_seed(_SEED)
+ edge_index = _path_edges(_N_NODES)
+ operator = KoopmanOperator(_LATENT_DIM).double()
+ decoder = MassConservingDecoder(
+ _FirstChannelDecoder(),
+ channels=(0,),
+ mass=_MASS,
+ method="affine",
+ ).double()
+ latents = torch.randn(_N_NODES, _LATENT_DIM, dtype=torch.float64)
+ residuals: list[float] = []
+ for _ in range(_STEPS):
+ decoded = decoder(latents, edge_index)
+ residual = abs(float(decoded[:, 0].sum().item()) - _MASS)
+ residuals.append(residual)
+ latents = operator.advance(latents)
+ assert max(residuals) < _ATOL
+ assert decoded.shape == (_N_NODES, 1)
+
+
+def test_project_linear_conservation_two_partition_is_exact() -> None:
+ """A fat two-row ``C`` meets ``c_0`` after the Gram correction."""
+ constraint = torch.tensor(
+ [[1.0, 1.0, 1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0, 1.0, 1.0]],
+ dtype=torch.float64,
+ )
+ target = torch.tensor([0.5, 0.75], dtype=torch.float64)
+ torch.manual_seed(_SEED)
+ values = torch.randn(_N_NODES, dtype=torch.float64)
+ projected = project_linear_conservation(values, constraint, target)
+ residual = constraint @ projected - target
+ assert float(residual.abs().max().item()) < _ATOL
+
+
+def test_project_linear_conservation_rejects_rank_deficient_c() -> None:
+ """Repeated rows make ``C C^T`` singular; the head must refuse."""
+ constraint = torch.tensor(
+ [[1.0, 1.0, 1.0, 0.0, 0.0, 0.0], [1.0, 1.0, 1.0, 0.0, 0.0, 0.0]],
+ dtype=torch.float64,
+ )
+ target = torch.tensor([0.5, 0.5], dtype=torch.float64)
+ values = torch.ones(_N_NODES, dtype=torch.float64)
+ with pytest.raises(ValueError, match="full row rank"):
+ project_linear_conservation(values, constraint, target)
+
+
+def test_linear_conservation_bound_over_rollout() -> None:
+ """``||C x_t - c_0||_inf`` is bounded on a two-partition toy."""
+ torch.manual_seed(_SEED)
+ constraint = torch.tensor(
+ [[1.0, 1.0, 1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0, 1.0, 1.0]],
+ dtype=torch.float64,
+ )
+ target = torch.tensor([0.5, 0.75], dtype=torch.float64)
+ edge_index = _path_edges(_N_NODES)
+ operator = KoopmanOperator(_LATENT_DIM).double()
+ decoder = LinearConservingDecoder(
+ _FirstChannelDecoder(),
+ channels=(0,),
+ constraint=constraint,
+ target=target,
+ ).double()
+ latents = torch.randn(_N_NODES, _LATENT_DIM, dtype=torch.float64)
+ for _ in range(_STEPS):
+ decoded = decoder(latents, edge_index)
+ residual = constraint @ decoded[:, 0] - target
+ assert float(residual.abs().max().item()) < _ATOL
+ latents = operator.advance(latents)
+
+
+def test_softmax_mass_is_positive_simplex() -> None:
+ """Softmax mass is non-negative and sums to ``M``."""
+ decoder = MassConservingDecoder(
+ _FirstChannelDecoder(),
+ channels=(0,),
+ mass=_MASS,
+ method="softmax",
+ )
+ decoded = decoder(torch.randn(_N_NODES, _LATENT_DIM), _path_edges(_N_NODES))
+ assert float(decoded[:, 0].sum().item()) == pytest.approx(_MASS, abs=_ATOL)
+ assert bool((decoded[:, 0] > 0).all().item())
+
+
+def test_positivity_maps_named_channel() -> None:
+ """``exp`` forces a strictly positive named channel."""
+ decoder = PositivityDecoder(
+ _TwoChannelDecoder(),
+ channels=(0,),
+ method="exp",
+ )
+ raw = torch.tensor(
+ [[-2.0, 0.3], [0.5, -1.0], [1.0, 0.0], [0.0, 2.0], [-0.1, 0.2], [3.0, -4.0]]
+ )
+ decoded = decoder.project(raw)
+ assert bool((decoded[:, 0] > 0).all().item())
+ assert torch.allclose(decoded[:, 1], raw[:, 1])
+
+
+def test_symplectic_k_alone_does_not_conserve_decoded_mass() -> None:
+ """A symplectic latent map does not keep ``1^T x`` without a head.
+
+ ``Greydanus2019HNN``: structure on the latent vector field is not a
+ decoded-space conservation certificate. Teaching floor is
+ ``DEFAULT_CONSERVATION_ATOL`` (``1e-6``).
+ """
+ torch.manual_seed(_SEED)
+ edge_index = _path_edges(_N_NODES)
+ operator = KoopmanOperator(
+ _LATENT_DIM,
+ parameterization="symplectic",
+ ).double()
+ bare = _FirstChannelDecoder()
+ headed = MassConservingDecoder(
+ _FirstChannelDecoder(),
+ channels=(0,),
+ mass=_MASS,
+ method="affine",
+ ).double()
+ latents = torch.randn(_N_NODES, _LATENT_DIM, dtype=torch.float64)
+ bare_masses: list[float] = []
+ headed_masses: list[float] = []
+ state_bare = latents.clone()
+ state_headed = latents.clone()
+ for _ in range(_STEPS):
+ bare_masses.append(float(bare(state_bare, edge_index)[:, 0].sum().item()))
+ headed_masses.append(float(headed(state_headed, edge_index)[:, 0].sum().item()))
+ state_bare = operator.advance(state_bare)
+ state_headed = operator.advance(state_headed)
+ assert max(abs(mass - bare_masses[0]) for mass in bare_masses) > _ATOL
+ assert max(abs(mass - _MASS) for mass in headed_masses) < _ATOL
+
+
+def test_constraint_heads_reject_invalid_inputs() -> None:
+ """Channel, mass, and node-count guards raise ``ValueError``."""
+ inner = _FirstChannelDecoder()
+ with pytest.raises(ValueError, match="softmax mass"):
+ MassConservingDecoder(inner, channels=(0,), mass=0.0, method="softmax")
+ with pytest.raises(ValueError, match="method"):
+ MassConservingDecoder(inner, channels=(0,), mass=1.0, method="clip") # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="method"):
+ PositivityDecoder(inner, channels=(0,), method="relu") # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="n_eq"):
+ LinearConservingDecoder(
+ inner,
+ channels=(0,),
+ constraint=torch.zeros(0, _N_NODES),
+ target=torch.zeros(0),
+ )
+ headed = MassConservingDecoder(inner, channels=(5,), mass=1.0)
+ with pytest.raises(ValueError, match="outside"):
+ headed(torch.randn(_N_NODES, _LATENT_DIM), _path_edges(_N_NODES))
+ decoder = LinearConservingDecoder(
+ inner,
+ channels=(0,),
+ constraint=torch.ones(1, _N_NODES),
+ target=torch.ones(1),
+ )
+ with pytest.raises(ValueError, match="node count"):
+ decoder.project(torch.randn(_N_NODES - 1, 1))
diff --git a/tests/nn/test_predicted_topology.py b/tests/nn/test_predicted_topology.py
index 8532148..e281f5c 100644
--- a/tests/nn/test_predicted_topology.py
+++ b/tests/nn/test_predicted_topology.py
@@ -1,19 +1,83 @@
-"""Coverage and error-path tests for :mod:`koopman_graph.nn`."""
+"""Coverage and error-path tests for :mod:`koopman_graph.nn.predicted_topology`."""
from __future__ import annotations
+from pathlib import Path
+
import pytest
import torch
+from torch_geometric.data import Data
+from koopman_graph import GNNDecoder, GNNEncoder, GraphKoopmanModel
+from koopman_graph.data import GraphDynamicsConfig
from koopman_graph.nn import (
+ DENSE_TOPOLOGY_MAX_NODES,
PredictedTopologyHead,
+ SparseCandidateTopologyHead,
+ build_candidate_index,
+)
+from koopman_graph.serialization import (
+ FORMAT_VERSION,
+ build_model_config,
+ reconstruct_model,
)
+def _path_edge_index(num_nodes: int) -> torch.Tensor:
+ """Return a bidirectional path graph.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count (at least 2).
+
+ Returns
+ -------
+ Tensor
+ COO index ``(2, 2*(N-1))``.
+ """
+ src = list(range(num_nodes - 1)) + list(range(1, num_nodes))
+ dst = list(range(1, num_nodes)) + list(range(num_nodes - 1))
+ return torch.tensor([src, dst], dtype=torch.long)
+
+
+def _tiny_model(
+ *,
+ graph_dynamics: GraphDynamicsConfig | None = None,
+ learn_topology: str | None = None,
+) -> GraphKoopmanModel:
+ """Return a small GCN model.
+
+ Parameters
+ ----------
+ graph_dynamics : GraphDynamicsConfig or None, optional
+ Opt-in graph-state config.
+ learn_topology : str or None, optional
+ Adaptive-adjacency flag.
+
+ Returns
+ -------
+ GraphKoopmanModel
+ Untrained model.
+ """
+ return GraphKoopmanModel(
+ GNNEncoder(2, 8, 3, num_layers=1),
+ GNNDecoder(3, 8, 2, num_layers=1),
+ latent_dim=3,
+ time_step=0.1,
+ graph_dynamics=graph_dynamics,
+ learn_topology=learn_topology,
+ )
+
+
def test_predicted_topology_validation_empty_edges_and_forward() -> None:
"""Topology head covers invalid inputs, empty thresholding, and ``forward``."""
with pytest.raises(ValueError, match="latent_dim"):
PredictedTopologyHead(0)
+ with pytest.raises(ValueError, match="hidden_dim"):
+ PredictedTopologyHead(3, hidden_dim=0)
+ with pytest.raises(ValueError, match="max_nodes"):
+ PredictedTopologyHead(3, max_nodes=1)
head = PredictedTopologyHead(3, hidden_dim=8)
with pytest.raises(ValueError, match="shape \\(N, d\\)"):
head.pairwise_logits(torch.randn(3))
@@ -32,3 +96,147 @@ def test_predicted_topology_head_logits() -> None:
edges = head.edge_index(z, top_k=2)
assert edges.shape[0] == 2
assert edges.shape[1] == 10
+
+
+def test_predicted_topology_dense_ceiling() -> None:
+ """Dense MLP refuses :math:`N` above the documented ceiling."""
+ assert DENSE_TOPOLOGY_MAX_NODES == 64
+ head = PredictedTopologyHead(2, hidden_dim=4)
+ z_ok = torch.randn(5, 2)
+ assert head.pairwise_logits(z_ok).shape == (5, 5)
+ with pytest.raises(ValueError, match="max_nodes=64"):
+ head.pairwise_logits(torch.randn(65, 2))
+ tight = PredictedTopologyHead(2, hidden_dim=4, max_nodes=4)
+ with pytest.raises(ValueError, match="max_nodes=4"):
+ tight.pairwise_logits(torch.randn(5, 2))
+
+
+def test_build_candidate_index_caps_destinations() -> None:
+ """Candidate COO stays at most ``N * k`` and prefers existing edges."""
+ with pytest.raises(ValueError, match="num_nodes"):
+ build_candidate_index(1, 2)
+ with pytest.raises(ValueError, match="candidate_k"):
+ build_candidate_index(4, 0)
+ with pytest.raises(ValueError, match="edge_index must have shape"):
+ build_candidate_index(4, 2, torch.arange(4))
+ generator = torch.Generator().manual_seed(0)
+ num_nodes = 32
+ candidate_k = 8
+ index = build_candidate_index(
+ num_nodes,
+ candidate_k,
+ _path_edge_index(num_nodes),
+ generator=generator,
+ )
+ assert index.shape[0] == 2
+ assert int(index.shape[1]) <= num_nodes * candidate_k
+ src, dst = index[0], index[1]
+ assert int((src == dst).sum()) == 0
+ for source in range(num_nodes):
+ assert int((src == source).sum()) <= candidate_k
+ filled = build_candidate_index(5, 8, generator=torch.Generator().manual_seed(1))
+ assert int(filled.shape[1]) == 5 * 4
+
+
+def test_sparse_candidate_head_scores_pairs_not_dense() -> None:
+ """Sparse head returns ``(E,)`` logits with ``E <= N k``."""
+ with pytest.raises(ValueError, match="latent_dim"):
+ SparseCandidateTopologyHead(0)
+ with pytest.raises(ValueError, match="hidden_dim"):
+ SparseCandidateTopologyHead(3, hidden_dim=0)
+ with pytest.raises(ValueError, match="candidate_k"):
+ SparseCandidateTopologyHead(3, candidate_k=0)
+ head = SparseCandidateTopologyHead(4, hidden_dim=8, candidate_k=8)
+ z = torch.randn(32, 4)
+ candidates = build_candidate_index(
+ 32, 8, _path_edge_index(32), generator=torch.Generator().manual_seed(2)
+ )
+ logits = head.pair_logits(z, candidates)
+ assert logits.ndim == 1
+ assert logits.shape == (int(candidates.shape[1]),)
+ assert logits.shape[0] <= 32 * 8
+ assert head(z, candidates).shape == logits.shape
+ kept = head.edge_index(z, candidates, threshold=-1e9)
+ assert kept.shape[0] == 2
+ with pytest.raises(ValueError, match="shape \\(N, d\\)"):
+ head.pair_logits(torch.randn(4), candidates)
+ too_many = torch.zeros(2, 32 * 31 + 1, dtype=torch.long)
+ with pytest.raises(ValueError, match="N\\*\\(N-1\\)"):
+ head.pair_logits(z, too_many)
+ oversize_ok = torch.zeros(2, 32 * 8 + 1, dtype=torch.long)
+ logits = head.pair_logits(z, oversize_ok)
+ assert logits.shape == (32 * 8 + 1,)
+ bad = torch.tensor([[0, 99], [1, 0]], dtype=torch.long)
+ with pytest.raises(ValueError, match="outside"):
+ head.pair_logits(z[:5], bad)
+
+
+def test_graph_koopman_model_attaches_sparse_head_without_using_it() -> None:
+ """Default opt-in config attaches a sparse head unused by ``forward``."""
+ default = _tiny_model()
+ assert default.graph_dynamics is None
+ assert default.predicted_topology is None
+ assert not any(
+ name.startswith("predicted_topology") for name in default.state_dict()
+ )
+ with pytest.raises(TypeError, match="GraphDynamicsConfig"):
+ GraphKoopmanModel(
+ GNNEncoder(2, 8, 3, num_layers=1),
+ GNNDecoder(3, 8, 2, num_layers=1),
+ latent_dim=3,
+ time_step=0.1,
+ graph_dynamics="sparse_candidate", # type: ignore[arg-type]
+ )
+ none_head = _tiny_model(graph_dynamics=GraphDynamicsConfig(topology_head="none"))
+ assert none_head.predicted_topology is None
+ assert none_head.presence_head is not None
+ sparse = _tiny_model(graph_dynamics=GraphDynamicsConfig())
+ assert sparse.graph_dynamics is not None
+ assert sparse.graph_dynamics.topology_head == "sparse_candidate"
+ assert isinstance(sparse.predicted_topology, SparseCandidateTopologyHead)
+ assert sparse.presence_head is not None
+ assert sparse.predicted_topology.candidate_k == 8
+ dense = _tiny_model(graph_dynamics=GraphDynamicsConfig(topology_head="dense_mlp"))
+ assert isinstance(dense.predicted_topology, PredictedTopologyHead)
+ assert dense.predicted_topology.max_nodes == DENSE_TOPOLOGY_MAX_NODES
+ with pytest.raises(ValueError, match="self_adaptive"):
+ _tiny_model(
+ graph_dynamics=GraphDynamicsConfig(),
+ learn_topology="self_adaptive",
+ )
+ torch.manual_seed(0)
+ baseline = _tiny_model()
+ configured = _tiny_model(graph_dynamics=GraphDynamicsConfig())
+ configured.encoder.load_state_dict(baseline.encoder.state_dict())
+ configured.decoder.load_state_dict(baseline.decoder.state_dict())
+ configured.koopman.load_state_dict(baseline.koopman.state_dict())
+ data = Data(x=torch.randn(4, 2), edge_index=_path_edge_index(4))
+ baseline.eval()
+ configured.eval()
+ with torch.no_grad():
+ assert torch.allclose(baseline(data), configured(data), atol=1e-6)
+
+
+def test_graph_dynamics_format1_round_trip(tmp_path: Path) -> None:
+ """Additive ``graph_dynamics`` round-trips; absent keys stay 0.14."""
+ assert FORMAT_VERSION == 1
+ model = _tiny_model(graph_dynamics=GraphDynamicsConfig(candidate_k=3))
+ assert model.predicted_topology is not None
+ before = {
+ key: tensor.detach().clone() for key, tensor in model.state_dict().items()
+ }
+ path = tmp_path / "graph_dynamics"
+ model.save(path)
+ loaded = GraphKoopmanModel.load(path)
+ assert loaded.graph_dynamics == model.graph_dynamics
+ assert isinstance(loaded.predicted_topology, SparseCandidateTopologyHead)
+ assert loaded.predicted_topology.candidate_k == 3
+ for key, tensor in before.items():
+ torch.testing.assert_close(loaded.state_dict()[key], tensor)
+ config = build_model_config(_tiny_model())
+ assert config["graph_dynamics"] is None
+ absent = dict(config)
+ absent.pop("graph_dynamics", None)
+ restored = reconstruct_model(absent)
+ assert restored.graph_dynamics is None
+ assert restored.predicted_topology is None
diff --git a/tests/nn/test_receptive_field.py b/tests/nn/test_receptive_field.py
new file mode 100644
index 0000000..89c534d
--- /dev/null
+++ b/tests/nn/test_receptive_field.py
@@ -0,0 +1,154 @@
+"""Tests for encoder vs operator receptive-field checks."""
+
+from __future__ import annotations
+
+import warnings
+
+import pytest
+import torch
+from torch_geometric.data import Data
+
+from koopman_graph import GNNDecoder, GNNEncoder, GraphKoopmanModel
+from koopman_graph.data import GraphSnapshotSequence
+from koopman_graph.nn import (
+ DelayEmbeddingEncoder,
+ DiffConvEncoder,
+ ReceptiveFieldMismatchWarning,
+ check_encoder_operator_receptive_field,
+)
+from koopman_graph.operators import GraphKoopmanOperator, KoopmanOperator
+from koopman_graph.operators.continuous_graph import ContinuousGraphKoopmanOperator
+
+
+def _path_edge_index(num_nodes: int) -> torch.Tensor:
+ """Undirected path edge index."""
+ src = list(range(num_nodes - 1))
+ dst = list(range(1, num_nodes))
+ return torch.tensor([src + dst, dst + src], dtype=torch.long)
+
+
+def test_diffusion_steps_exceeding_filter_degree_warns() -> None:
+ """Acceptance: ``diffusion_steps > filter_degree`` warns (one DiffConv layer)."""
+ encoder = DiffConvEncoder(2, 4, 3, num_layers=1, diffusion_steps=2)
+ operator = GraphKoopmanOperator(3, init_mode="identity", filter_degree=1)
+ assert encoder.receptive_field_hops() == 2
+ with pytest.warns(ReceptiveFieldMismatchWarning, match="does not compensate"):
+ report = check_encoder_operator_receptive_field(encoder, operator)
+ assert report.warned is True
+ assert report.encoder_hops == 2
+ assert report.operator_hops == 1
+
+
+def test_gcn_depth_exceeding_filter_degree_warns() -> None:
+ """Default two-layer GCN vs one-hop graph ``K`` warns."""
+ encoder = GNNEncoder(2, 4, 3, num_layers=2)
+ operator = GraphKoopmanOperator(3, init_mode="identity", filter_degree=1)
+ with pytest.warns(ReceptiveFieldMismatchWarning, match="does not compensate"):
+ report = check_encoder_operator_receptive_field(encoder, operator)
+ assert report.encoder_hops == 2
+ assert report.operator_hops == 1
+
+
+def test_matched_hops_are_silent() -> None:
+ """Equal hop counts do not warn."""
+ encoder = GNNEncoder(2, 4, 3, num_layers=1)
+ operator = GraphKoopmanOperator(3, init_mode="identity", filter_degree=1)
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", ReceptiveFieldMismatchWarning)
+ report = check_encoder_operator_receptive_field(encoder, operator)
+ assert report.warned is False
+ assert report.encoder_hops == 1
+ assert report.operator_hops == 1
+
+
+def test_operator_longer_than_encoder_is_silent() -> None:
+ """Operator hops may exceed encoder hops without a warning."""
+ encoder = GNNEncoder(2, 4, 3, num_layers=2)
+ operator = GraphKoopmanOperator(3, init_mode="identity", filter_degree=2)
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", ReceptiveFieldMismatchWarning)
+ report = check_encoder_operator_receptive_field(encoder, operator)
+ assert report.warned is False
+ assert report.encoder_hops == 2
+ assert report.operator_hops == 2
+
+
+def test_pernode_operator_skips_check() -> None:
+ """Per-node ``K`` has no graph hop radius; the helper stays silent."""
+ encoder = GNNEncoder(2, 4, 3, num_layers=3)
+ operator = KoopmanOperator(3, init_mode="identity")
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", ReceptiveFieldMismatchWarning)
+ report = check_encoder_operator_receptive_field(encoder, operator)
+ assert report.warned is False
+ assert report.operator_hops is None
+ assert report.encoder_hops == 3
+
+
+def test_continuous_graph_operator_skips_check() -> None:
+ """Continuous graph has no ``filter_degree`` hop method."""
+ encoder = GNNEncoder(2, 4, 3, num_layers=2)
+ operator = ContinuousGraphKoopmanOperator(3, init_mode="identity")
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", ReceptiveFieldMismatchWarning)
+ report = check_encoder_operator_receptive_field(encoder, operator)
+ assert report.warned is False
+ assert report.operator_hops is None
+
+
+def test_diffconv_hops_are_layers_times_diffusion_steps() -> None:
+ """Stacked DiffConv radius is the product, not ``diffusion_steps`` alone."""
+ encoder = DiffConvEncoder(2, 4, 3, num_layers=2, diffusion_steps=3)
+ assert encoder.receptive_field_hops() == 6
+ operator = GraphKoopmanOperator(3, init_mode="identity", filter_degree=1)
+ with pytest.warns(ReceptiveFieldMismatchWarning, match="6 hop"):
+ report = check_encoder_operator_receptive_field(encoder, operator)
+ assert report.encoder_hops == 6
+
+
+def test_delay_wrapper_uses_base_encoder_hops() -> None:
+ """Delay stacking does not add spatial hops."""
+ base = GNNEncoder(6, 4, 3, num_layers=2)
+ encoder = DelayEmbeddingEncoder(base, n_delays=2)
+ assert encoder.receptive_field_hops() == 2
+ operator = GraphKoopmanOperator(3, init_mode="identity", filter_degree=1)
+ with pytest.warns(ReceptiveFieldMismatchWarning, match="does not compensate"):
+ report = check_encoder_operator_receptive_field(encoder, operator)
+ assert report.encoder_hops == 2
+
+
+def test_fit_warns_when_encoder_hops_exceed_filter_degree() -> None:
+ """``GraphKoopmanModel.fit`` emits the mismatch warning once."""
+ encoder = GNNEncoder(2, 4, 3, num_layers=2)
+ decoder = GNNDecoder(3, 4, 2, num_layers=1)
+ model = GraphKoopmanModel(
+ encoder,
+ decoder,
+ latent_dim=3,
+ time_step=0.1,
+ koopman="graph",
+ koopman_init_mode="identity",
+ )
+ edge_index = _path_edge_index(4)
+ snapshots = [Data(x=torch.randn(4, 2), edge_index=edge_index) for _ in range(2)]
+ with pytest.warns(ReceptiveFieldMismatchWarning, match="does not compensate"):
+ history = model.fit(GraphSnapshotSequence(snapshots), epochs=1)
+ assert history.epochs == 1
+
+
+def test_fit_pernode_default_is_silent() -> None:
+ """Default per-node ``K`` does not warn on a two-layer GCN."""
+ encoder = GNNEncoder(2, 4, 3, num_layers=2)
+ decoder = GNNDecoder(3, 4, 2, num_layers=1)
+ model = GraphKoopmanModel(
+ encoder,
+ decoder,
+ latent_dim=3,
+ time_step=0.1,
+ )
+ edge_index = _path_edge_index(4)
+ snapshots = [Data(x=torch.randn(4, 2), edge_index=edge_index) for _ in range(2)]
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", ReceptiveFieldMismatchWarning)
+ history = model.fit(GraphSnapshotSequence(snapshots), epochs=1)
+ assert history.epochs == 1
diff --git a/tests/nn/test_separable.py b/tests/nn/test_separable.py
new file mode 100644
index 0000000..d42341a
--- /dev/null
+++ b/tests/nn/test_separable.py
@@ -0,0 +1,96 @@
+"""Node-wise separable dictionary encoder / decoder."""
+
+from __future__ import annotations
+
+import pytest
+import torch
+from torch_geometric.data import Data
+
+import koopman_graph
+from koopman_graph.nn import (
+ GNNEncoder,
+ SeparableDictionaryDecoder,
+ SeparableDictionaryEncoder,
+ is_separable_dictionary,
+)
+from koopman_graph.nn.delay import DelayEmbeddingEncoder
+
+
+def _path_edge_index(num_nodes: int) -> torch.Tensor:
+ """Return a bidirectional path.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count (at least 2).
+
+ Returns
+ -------
+ Tensor
+ COO index.
+ """
+ src = list(range(num_nodes - 1)) + list(range(1, num_nodes))
+ dst = list(range(1, num_nodes)) + list(range(num_nodes - 1))
+ return torch.tensor([src, dst], dtype=torch.long)
+
+
+def test_separable_encoder_ignores_edges_and_permutes_with_nodes() -> None:
+ """Node permutation permutes latents; swapping edges does not."""
+ torch.manual_seed(0)
+ encoder = SeparableDictionaryEncoder(3, 8, 4, num_layers=2)
+ x = torch.randn(5, 3)
+ path = _path_edge_index(5)
+ cycle_src = list(range(5)) + list(range(1, 5)) + [0]
+ cycle_dst = list(range(1, 5)) + [0] + list(range(5))
+ cycle = torch.tensor([cycle_src, cycle_dst], dtype=torch.long)
+ z_path = encoder(x, path)
+ z_cycle = encoder(x, cycle)
+ torch.testing.assert_close(z_path, z_cycle, atol=1e-6, rtol=0.0)
+ perm = torch.tensor([2, 0, 4, 1, 3])
+ z_perm = encoder(x[perm], path)
+ torch.testing.assert_close(z_perm, z_path[perm], atol=1e-5, rtol=0.0)
+ data = Data(x=x, edge_index=path)
+ torch.testing.assert_close(encoder(data), z_path, atol=1e-6, rtol=0.0)
+
+
+def test_gnn_encoder_is_not_separable() -> None:
+ """Default GCN lifts are not a separable dictionary."""
+ gnn = GNNEncoder(3, 8, 4, num_layers=1)
+ assert not is_separable_dictionary(gnn)
+ separable = SeparableDictionaryEncoder(3, 8, 4, num_layers=1)
+ assert is_separable_dictionary(separable)
+ wrapped = DelayEmbeddingEncoder(separable, n_delays=1)
+ assert is_separable_dictionary(wrapped)
+ assert separable.receptive_field_hops() == 0
+
+
+def test_separable_decoder_round_trip_shape() -> None:
+ """Decoder maps ``(N, d)`` to ``(N, F)`` without using edges."""
+ torch.manual_seed(1)
+ decoder = SeparableDictionaryDecoder(4, 8, 3, num_layers=1)
+ z = torch.randn(6, 4)
+ edges = _path_edge_index(6)
+ out = decoder(z, edges)
+ assert out.shape == (6, 3)
+ torch.testing.assert_close(out, decoder(z, None), atol=0.0, rtol=0.0)
+
+
+def test_separable_rejects_bad_feature_width() -> None:
+ """Wrong channel width raises."""
+ encoder = SeparableDictionaryEncoder(3, 8, 4, num_layers=1)
+ with pytest.raises(ValueError, match="expects features"):
+ encoder(torch.randn(4, 2))
+ decoder = SeparableDictionaryDecoder(4, 8, 3, num_layers=1)
+ with pytest.raises(ValueError, match="expects latents"):
+ decoder(torch.randn(4, 2))
+
+
+def test_separable_types_stay_off_root_all() -> None:
+ """Separable dictionary types are ``nn`` exports only (Q6)."""
+ assert "SeparableDictionaryEncoder" in koopman_graph.nn.__all__
+ assert "SeparableDictionaryDecoder" in koopman_graph.nn.__all__
+ assert "is_separable_dictionary" in koopman_graph.nn.__all__
+ exported = set(koopman_graph.__all__)
+ assert "SeparableDictionaryEncoder" not in exported
+ assert "SeparableDictionaryDecoder" not in exported
+ assert "is_separable_dictionary" not in exported
diff --git a/tests/nn/test_tdl_teaching.py b/tests/nn/test_tdl_teaching.py
new file mode 100644
index 0000000..3a965e3
--- /dev/null
+++ b/tests/nn/test_tdl_teaching.py
@@ -0,0 +1,139 @@
+"""Order-2 TDL teaching path and cochain-operator hook."""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+import pytest
+import torch
+
+import koopman_graph
+from koopman_graph.model.factory import parse_koopman_arg
+from koopman_graph.nn import (
+ MAX_CELL_COMPLEX_DEGREE,
+ Order2CochainTeaching,
+ SheafGNNEncoder,
+ bind_cochain_operator,
+ cell_complex_boundary_nilpotency,
+ order2_cochain_teaching,
+ teaching_order2_triangle,
+ teaching_order3_tetrahedron,
+)
+from koopman_graph.nn.cell_complex import boundary_operator
+from koopman_graph.operators import CochainKoopmanOperator, CochainState
+from koopman_graph.operators.cochain import DEFAULT_NILPOTENCY_ATOL
+
+_ATOL = DEFAULT_NILPOTENCY_ATOL
+_LATENT_DIM = 2
+
+
+def test_tdl_teaching_exports_off_root() -> None:
+ """Teaching helpers live on ``nn.__all__``, not the root façade."""
+ assert "order2_cochain_teaching" in koopman_graph.nn.__all__
+ assert "bind_cochain_operator" in koopman_graph.nn.__all__
+ assert "Order2CochainTeaching" in koopman_graph.nn.__all__
+ assert "teaching_order2_triangle" in koopman_graph.nn.__all__
+ assert "order2_cochain_teaching" not in koopman_graph.__all__
+ assert "bind_cochain_operator" not in koopman_graph.__all__
+ assert "Order2CochainTeaching" not in koopman_graph.__all__
+
+
+def test_operators_cochain_still_does_not_import_nn() -> None:
+ """L2 cochain module must not import L3 nn (hook lives in nn)."""
+ source = Path(__file__).resolve().parents[2] / (
+ "src/koopman_graph/operators/cochain.py"
+ )
+ tree = ast.parse(source.read_text(encoding="utf-8"))
+ imported: list[str] = []
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ imported.extend(alias.name for alias in node.names)
+ elif isinstance(node, ast.ImportFrom) and node.module is not None:
+ imported.append(node.module)
+ offenders = [
+ name
+ for name in imported
+ if name == "koopman_graph.nn" or name.startswith("koopman_graph.nn.")
+ ]
+ assert not offenders
+
+
+def test_order2_teaching_binds_cochain_and_is_nilpotent() -> None:
+ """Filled triangle binds ``CochainKoopmanOperator`` with ``B1 B2 = 0``."""
+ path = order2_cochain_teaching(latent_dim=_LATENT_DIM)
+ assert isinstance(path, Order2CochainTeaching)
+ assert isinstance(path.operator, CochainKoopmanOperator)
+ assert path.max_cell_degree == MAX_CELL_COMPLEX_DEGREE == 3
+ assert path.complex.num_cells(0) == 3
+ assert path.complex.num_cells(1) == 3
+ assert path.complex.num_cells(2) == 1
+ assert path.nilpotency.nilpotent is True
+ assert path.nilpotency.max_abs <= _ATOL
+ assert path.operator.num_nodes == 3
+ assert path.operator.num_edges == 3
+
+
+def test_bound_operator_copies_face_latents() -> None:
+ """Face table is stored and copied; ``k=2`` is not evolved."""
+ path = order2_cochain_teaching(latent_dim=_LATENT_DIM)
+ face = torch.arange(4, dtype=torch.float32).reshape(1, 4)
+ state = CochainState(
+ node=torch.ones(3, _LATENT_DIM),
+ edge=torch.full((3, _LATENT_DIM), 0.5),
+ face=face,
+ )
+ nxt = path.operator.advance(state)
+ assert nxt.face is not None
+ assert torch.equal(nxt.face, face)
+ assert not torch.equal(nxt.node, state.node)
+
+
+def test_bind_cochain_operator_on_degree3_tetrahedron() -> None:
+ """Degree-3 ceiling fixture still binds a ``k<=1`` operator."""
+ complex_ = teaching_order3_tetrahedron()
+ assert complex_.num_cells(3) == 1
+ report = cell_complex_boundary_nilpotency(complex_)
+ assert report.nilpotent is True
+ assert report.max_abs <= _ATOL
+ operator = bind_cochain_operator(complex_, latent_dim=_LATENT_DIM)
+ assert operator.num_nodes == 4
+ assert operator.num_edges == 6
+ product_b2_b3 = boundary_operator(complex_, 2) @ boundary_operator(complex_, 3)
+ assert torch.allclose(
+ product_b2_b3,
+ torch.zeros_like(product_b2_b3),
+ atol=_ATOL,
+ rtol=0.0,
+ )
+ with pytest.raises(ValueError, match="teaching MVP"):
+ boundary_operator(complex_, MAX_CELL_COMPLEX_DEGREE + 1)
+
+
+def test_sheaf_restriction_maps_remain_optional() -> None:
+ """Sheaf peers keep diagonal restriction maps unless opted in."""
+ encoder = SheafGNNEncoder(2, 4, 4, num_layers=1)
+ assert encoder.restriction_maps == "diagonal"
+
+
+def test_cochain_is_still_not_a_factory_kind() -> None:
+ """Order-2 teaching does not add a ``koopman="cochain"`` kind."""
+ kind, injected = parse_koopman_arg(None)
+ assert kind == "pernode"
+ assert injected is None
+ with pytest.raises(ValueError, match="cochain"):
+ parse_koopman_arg("cochain")
+
+
+def test_teaching_helpers_reject_invalid_inputs() -> None:
+ """Hook guards stay on CellComplex and require 2-cells."""
+ with pytest.raises(TypeError, match="CellComplex"):
+ bind_cochain_operator(object(), latent_dim=_LATENT_DIM) # type: ignore[arg-type]
+ skeleton = teaching_order2_triangle()
+ object.__setattr__(
+ skeleton,
+ "face_index",
+ torch.empty((3, 0), dtype=torch.long),
+ )
+ with pytest.raises(ValueError, match="2-cell"):
+ cell_complex_boundary_nilpotency(skeleton)
diff --git a/tests/observables/test_observables_and_factory_guards.py b/tests/observables/test_observables_and_factory_guards.py
index 8340f66..d43f613 100644
--- a/tests/observables/test_observables_and_factory_guards.py
+++ b/tests/observables/test_observables_and_factory_guards.py
@@ -189,6 +189,14 @@ def obs(z: torch.Tensor) -> torch.Tensor:
GraphKoopmanModel(enc, dec, latent_dim=2, time_step=0.1, n_delays=0)
with pytest.raises(ValueError, match="koopman string kind"):
GraphKoopmanModel(enc, dec, latent_dim=2, time_step=0.1, koopman="bogus") # type: ignore[arg-type]
+ parametric = GraphKoopmanModel(
+ enc,
+ dec,
+ latent_dim=2,
+ time_step=0.1,
+ koopman="parametric",
+ )
+ assert parametric.koopman_kind == "parametric"
with pytest.raises(ValueError, match="koopman_auxiliary_hidden_dims"):
GraphKoopmanModel(
enc,
diff --git a/tests/operators/test_cochain.py b/tests/operators/test_cochain.py
new file mode 100644
index 0000000..e901391
--- /dev/null
+++ b/tests/operators/test_cochain.py
@@ -0,0 +1,219 @@
+"""Cochain state, k<=1 operator, and boundary nilpotency tests."""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+import pytest
+import torch
+
+import koopman_graph
+from koopman_graph import GNNDecoder, GNNEncoder, GraphKoopmanModel
+from koopman_graph.model.factory import parse_koopman_arg
+from koopman_graph.nn.cell_complex import boundary_incidence_b2
+from koopman_graph.observables import boundary_incidence_b1
+from koopman_graph.operators import (
+ CochainKoopmanOperator,
+ CochainState,
+ HodgeKoopmanOperator,
+ KoopmanOperator,
+ boundary_nilpotency,
+)
+from koopman_graph.operators.cochain import DEFAULT_NILPOTENCY_ATOL
+
+# float32 dense B1 B2 on the teaching triangle; same floor as cell-complex tests.
+_ATOL = DEFAULT_NILPOTENCY_ATOL
+_ORACLE_REL = 1e-6
+_ORACLE_ABS = 1e-8
+_LATENT_DIM = 2
+
+
+def _filled_triangle_edges() -> tuple[torch.Tensor, torch.Tensor]:
+ """Oriented 1-skeleton and one face of the filled triangle."""
+ edge_index = torch.tensor([[0, 1, 0], [1, 2, 2]], dtype=torch.long)
+ face_index = torch.tensor([[0], [1], [2]], dtype=torch.long)
+ return edge_index, face_index
+
+
+def test_package_exports_cochain_off_root() -> None:
+ """Cochain helpers live on ``operators.__all__``, not the root façade."""
+ assert "CochainState" in koopman_graph.operators.__all__
+ assert "CochainKoopmanOperator" in koopman_graph.operators.__all__
+ assert "boundary_nilpotency" in koopman_graph.operators.__all__
+ assert "CochainState" not in koopman_graph.__all__
+ assert "CochainKoopmanOperator" not in koopman_graph.__all__
+
+
+def test_cochain_module_does_not_import_model_data_or_nn() -> None:
+ """L2 operator must not import L4 model, L1 data, or L3 nn."""
+ source = Path(__file__).resolve().parents[2] / (
+ "src/koopman_graph/operators/cochain.py"
+ )
+ tree = ast.parse(source.read_text(encoding="utf-8"))
+ imported: list[str] = []
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ imported.extend(alias.name for alias in node.names)
+ elif isinstance(node, ast.ImportFrom) and node.module is not None:
+ imported.append(node.module)
+ forbidden = (
+ "koopman_graph.model",
+ "koopman_graph.data",
+ "koopman_graph.nn",
+ )
+ offenders = [
+ name
+ for name in imported
+ if any(name == prefix or name.startswith(f"{prefix}.") for prefix in forbidden)
+ ]
+ assert not offenders
+
+
+def test_boundary_nilpotency_on_filled_triangle() -> None:
+ """``B_1 B_2 = 0`` on the oriented teaching triangle to ``1e-6``."""
+ edge_index, face_index = _filled_triangle_edges()
+ b1 = boundary_incidence_b1(edge_index, num_nodes=3)
+ b2 = boundary_incidence_b2(edge_index, face_index, num_nodes=3)
+ report = boundary_nilpotency(b1, b2, atol=_ATOL)
+ assert report.product.shape == (3, 1)
+ assert report.nilpotent is True
+ assert report.max_abs <= _ATOL
+ torch.testing.assert_close(
+ report.product,
+ torch.zeros_like(report.product),
+ rtol=0.0,
+ atol=_ATOL,
+ )
+
+
+def test_default_factory_stays_node_centered() -> None:
+ """``koopman=None`` remains per-node LTI; ``cochain`` is not a kind."""
+ kind, injected = parse_koopman_arg(None)
+ assert kind == "pernode"
+ assert injected is None
+ with pytest.raises(ValueError, match="cochain"):
+ parse_koopman_arg("cochain")
+ model = GraphKoopmanModel(
+ encoder=GNNEncoder(2, 4, 4, num_layers=1),
+ decoder=GNNDecoder(4, 4, 2, num_layers=1),
+ latent_dim=4,
+ time_step=0.1,
+ )
+ assert model.koopman_kind == "pernode"
+ assert isinstance(model.koopman, KoopmanOperator)
+ assert not isinstance(model.koopman, CochainKoopmanOperator)
+ assert not isinstance(model.koopman, HodgeKoopmanOperator)
+
+
+def test_commuting_residual_vanishes_when_maps_match() -> None:
+ """Feature-axis intertwining is exact when ``K_0 = K_1``."""
+ edge_index, _ = _filled_triangle_edges()
+ operator = CochainKoopmanOperator(
+ _LATENT_DIM,
+ edge_index,
+ num_nodes=3,
+ ).double()
+ shared = torch.diag(torch.tensor([0.4, -0.2], dtype=torch.float64))
+ with torch.no_grad():
+ operator.k_node.copy_(shared)
+ operator.k_edge.copy_(shared)
+ state = CochainState(
+ node=torch.tensor(
+ [[1.0, 0.0], [0.5, -0.25], [0.0, 0.75]],
+ dtype=torch.float64,
+ ),
+ edge=torch.zeros(3, _LATENT_DIM, dtype=torch.float64),
+ )
+ residual = operator.commuting_residual(state)
+ torch.testing.assert_close(
+ residual,
+ torch.zeros_like(residual),
+ rtol=_ORACLE_REL,
+ atol=_ORACLE_ABS,
+ )
+ assert float(operator.commuting_loss(state).item()) == pytest.approx(
+ 0.0, abs=_ORACLE_ABS
+ )
+
+
+def test_commuting_residual_flags_mismatched_maps() -> None:
+ """Distinct feature maps leave a nonzero coboundary residual."""
+ edge_index, _ = _filled_triangle_edges()
+ operator = CochainKoopmanOperator(_LATENT_DIM, edge_index, num_nodes=3)
+ with torch.no_grad():
+ operator.k_node.copy_(0.2 * torch.eye(_LATENT_DIM))
+ operator.k_edge.copy_(0.8 * torch.eye(_LATENT_DIM))
+ state = CochainState(
+ node=torch.tensor([[1.0, 0.0], [0.0, 1.0], [-1.0, 0.5]]),
+ edge=torch.zeros(3, _LATENT_DIM),
+ )
+ residual = operator.commuting_residual(state)
+ assert float(residual.abs().max().item()) > 0.1
+ assert float(operator.commuting_loss(state).item()) > 0.01
+
+
+def test_cross_degree_matches_independent_incidence_step() -> None:
+ """Cross terms are ``B_1 z_1`` / ``B_1ᵀ z_0`` times the C maps."""
+ edge_index, _ = _filled_triangle_edges()
+ operator = CochainKoopmanOperator(
+ _LATENT_DIM,
+ edge_index,
+ num_nodes=3,
+ use_cross_degree=True,
+ ).double()
+ k0 = torch.diag(torch.tensor([0.5, 0.25], dtype=torch.float64))
+ k1 = torch.diag(torch.tensor([0.3, -0.1], dtype=torch.float64))
+ c01 = torch.diag(torch.tensor([0.2, 0.4], dtype=torch.float64))
+ c10 = torch.diag(torch.tensor([-0.15, 0.05], dtype=torch.float64))
+ with torch.no_grad():
+ operator.k_node.copy_(k0)
+ operator.k_edge.copy_(k1)
+ operator.c_node_from_edge.copy_(c01)
+ operator.c_edge_from_node.copy_(c10)
+ state = CochainState(
+ node=torch.arange(6, dtype=torch.float64).reshape(3, 2) / 10.0,
+ edge=torch.arange(6, 12, dtype=torch.float64).reshape(3, 2) / 10.0,
+ )
+ incidence = operator.incidence.to(dtype=torch.float64)
+ expected_node = state.node @ k0.T + (incidence @ state.edge) @ c01.T
+ expected_edge = state.edge @ k1.T + (incidence.T @ state.node) @ c10.T
+ got = operator.advance(state)
+ torch.testing.assert_close(
+ got.node, expected_node, rtol=_ORACLE_REL, atol=_ORACLE_ABS
+ )
+ torch.testing.assert_close(
+ got.edge, expected_edge, rtol=_ORACLE_REL, atol=_ORACLE_ABS
+ )
+ assert got.face is None
+
+
+def test_face_latents_are_copied_not_evolved() -> None:
+ """Optional ``k=2`` tables pass through unchanged."""
+ edge_index, _ = _filled_triangle_edges()
+ operator = CochainKoopmanOperator(_LATENT_DIM, edge_index, num_nodes=3)
+ face = torch.tensor([[0.3, -0.2]])
+ state = CochainState(
+ node=torch.ones(3, _LATENT_DIM),
+ edge=torch.zeros(3, _LATENT_DIM),
+ face=face,
+ )
+ got = operator.advance(state)
+ torch.testing.assert_close(got.face, face)
+ assert got.node.shape == (3, _LATENT_DIM)
+
+
+def test_cochain_rejects_invalid_inputs() -> None:
+ """Boundary validation names the broken constraint."""
+ edge_index, _ = _filled_triangle_edges()
+ with pytest.raises(ValueError, match="latent width"):
+ CochainState(node=torch.ones(3, 2), edge=torch.ones(3, 3))
+ with pytest.raises(ValueError, match="finite"):
+ CochainState(node=torch.tensor([[float("nan"), 0.0]]), edge=torch.zeros(1, 2))
+ with pytest.raises(ValueError, match="num_nodes"):
+ CochainKoopmanOperator(2, edge_index, num_nodes=0)
+ operator = CochainKoopmanOperator(2, edge_index, num_nodes=3)
+ with pytest.raises(ValueError, match="num_nodes"):
+ operator.advance(CochainState(node=torch.ones(2, 2), edge=torch.ones(3, 2)))
+ with pytest.raises(ValueError, match="b1 columns"):
+ boundary_nilpotency(torch.ones(3, 2), torch.ones(3, 1))
diff --git a/tests/operators/test_continuous.py b/tests/operators/test_continuous.py
index 2aa72dd..9b95bfb 100644
--- a/tests/operators/test_continuous.py
+++ b/tests/operators/test_continuous.py
@@ -31,8 +31,29 @@
van_loan_generator_from_discrete,
)
from koopman_graph.operators.continuous import negative_strict_diagonal_values
+from koopman_graph.spectrum_types import DefectiveSpectrumError
from koopman_graph.training import LossWeights
+# Independent algebraic identity: J = I+N with N^2=0 ⇒ log J = N.
+# Float64 SciPy logm / matrix-exp residual vs that closed form.
+_JORDAN_LOG_REL = 1e-10
+_JORDAN_LOG_ABS = 1e-12
+
+
+def _assert_tensor_approx(
+ actual: Tensor,
+ expected: Tensor,
+ *,
+ rel: float,
+ abs_: float,
+) -> None:
+ """Compare tensors with a documented float64 residual."""
+ assert actual.detach().cpu().numpy() == pytest.approx(
+ expected.detach().cpu().numpy(),
+ rel=rel,
+ abs=abs_,
+ )
+
def test_matrix_log_round_trips_spd_matrix() -> None:
"""Principal log should satisfy exp(log(M)) ≈ M for well-conditioned SPD M."""
@@ -56,6 +77,78 @@ def test_matrix_log_of_matrix_exp_recovers_symmetric_generator() -> None:
assert torch.allclose(recovered, generator, atol=1e-5)
+def test_matrix_log_jordan_block_raises_by_default() -> None:
+ """A 2×2 Jordan block of 1 is not diagonalizable; default does not invert V."""
+ jordan = torch.tensor([[1.0, 1.0], [0.0, 1.0]], dtype=torch.float64)
+ with pytest.raises(DefectiveSpectrumError, match="Schur") as exc_info:
+ matrix_log(jordan)
+ assert isinstance(exc_info.value, torch.linalg.LinAlgError)
+
+
+def test_matrix_log_schur_path_matches_jordan_closed_form() -> None:
+ """SciPy logm on J=[[1,1],[0,1]] is [[0,1],[0,0]] (N^2=0 ⇒ exp(N)=I+N).
+
+ Independent algebraic identity, not a literature table. Float64 SciPy
+ residual vs that closed form.
+ """
+ pytest.importorskip("scipy")
+ jordan = torch.tensor([[1.0, 1.0], [0.0, 1.0]], dtype=torch.float64)
+ expected = torch.tensor([[0.0, 1.0], [0.0, 0.0]], dtype=torch.float64)
+ logged = matrix_log(jordan, defective="schur")
+ _assert_tensor_approx(logged, expected, rel=_JORDAN_LOG_REL, abs_=_JORDAN_LOG_ABS)
+ _assert_tensor_approx(
+ torch.linalg.matrix_exp(logged),
+ jordan,
+ rel=_JORDAN_LOG_REL,
+ abs_=_JORDAN_LOG_ABS,
+ )
+
+
+def test_matrix_log_diagonalizable_nonnormal_still_logs() -> None:
+ """A triangular but diagonalizable map is not treated as Jordan."""
+ matrix = torch.tensor([[1.0, 10.0], [0.0, 2.0]], dtype=torch.float64)
+ logged = matrix_log(matrix)
+ _assert_tensor_approx(
+ torch.linalg.matrix_exp(logged),
+ matrix,
+ rel=_JORDAN_LOG_REL,
+ abs_=_JORDAN_LOG_ABS,
+ )
+
+
+def test_matrix_log_does_not_use_condition_warn_as_error() -> None:
+ """κ(V) above CONDITION_WARN=1e6 can still be diagonalizable.
+
+ Default rcond is n·ε (~1e-16), not the amplitude warning threshold.
+ """
+ matrix = torch.tensor([[1.0, 1.0e7], [0.0, 2.0]], dtype=torch.float64)
+ logged = matrix_log(matrix)
+ assert bool(torch.isfinite(logged).all().item())
+
+
+def test_matrix_log_rejects_invalid_defective_and_rcond() -> None:
+ """Keyword validation is explicit."""
+ matrix = torch.eye(2, dtype=torch.float64)
+ with pytest.raises(ValueError, match="defective must be"):
+ matrix_log(matrix, defective="nope") # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="rcond must be a finite value > 0"):
+ matrix_log(matrix, rcond=0.0)
+ with pytest.raises(ValueError, match="rcond must be a finite value > 0"):
+ matrix_log(matrix, rcond=float("nan"))
+ with pytest.raises(ValueError, match="rcond must be a finite value > 0"):
+ matrix_log(matrix, rcond=float("inf"))
+ with pytest.raises(DefectiveSpectrumError, match="Schur"):
+ matrix_log(matrix, rcond=2.0)
+
+
+def test_van_loan_generator_from_discrete_raises_on_jordan_k() -> None:
+ """Van Loan inversion inherits matrix_log(..., defective='error')."""
+ discrete_k = torch.tensor([[1.0, 1.0], [0.0, 1.0]], dtype=torch.float64)
+ discrete_b = torch.zeros(1, 2, dtype=torch.float64)
+ with pytest.raises(DefectiveSpectrumError, match="Schur"):
+ van_loan_generator_from_discrete(discrete_k, discrete_b, 0.1)
+
+
@pytest.fixture
def tiny_continuous_model(synthetic_edge_index: torch.Tensor) -> GraphKoopmanModel:
"""Return a small continuous-time model for synthetic tests."""
diff --git a/tests/operators/test_coverage_gaps.py b/tests/operators/test_coverage_gaps.py
new file mode 100644
index 0000000..4330682
--- /dev/null
+++ b/tests/operators/test_coverage_gaps.py
@@ -0,0 +1,667 @@
+"""Targeted coverage for operator and spectrum validation branches."""
+
+from __future__ import annotations
+
+import math
+from dataclasses import replace
+from typing import Any
+from unittest.mock import patch
+
+import pytest
+import torch
+
+from koopman_graph.operators import (
+ BoundaryNilpotencyReport,
+ CochainKoopmanOperator,
+ CochainState,
+ GraphKoopmanOperator,
+ MatrixFreeGraphLinearOperator,
+ ParametricKoopmanOperator,
+ PolynomialGraphLinearOperator,
+ boundary_nilpotency,
+ leave_one_regime_out,
+)
+from koopman_graph.operators.linear import (
+ _SymmetricGraphLinearMap,
+ _taylor_expm_action,
+)
+from koopman_graph.operators.parametric import LENGTHSCALE_FLOOR
+from koopman_graph.spectrum_types import (
+ DefectiveSpectrumError,
+ KoopmanSpectrum,
+ SpectralDiagnostics,
+ compute_generator_spectrum,
+ compute_spectral_diagnostics,
+ compute_spectrum,
+ discrete_spectrum_at_delta_t,
+)
+
+_N_NODES = 3
+_LATENT_DIM = 2
+_ATOL = 1e-6
+
+
+def _path_edges(num_nodes: int) -> torch.Tensor:
+ """Undirected path used as a small teaching graph."""
+ tails = torch.arange(num_nodes - 1, dtype=torch.long)
+ forward = torch.stack((tails, tails + 1), dim=0)
+ return torch.cat((forward, forward.flip(0)), dim=1)
+
+
+def _identity_pair() -> tuple[torch.Tensor, torch.Tensor]:
+ """Well-conditioned self / neighbor factors."""
+ identity = torch.eye(_LATENT_DIM, dtype=torch.float64)
+ return 0.6 * identity, 0.05 * identity
+
+
+def _one_tap_operator() -> MatrixFreeGraphLinearOperator:
+ """Tiny one-tap wrapper for public validation paths."""
+ k_self, k_nbr = _identity_pair()
+ return MatrixFreeGraphLinearOperator(
+ k_self,
+ k_nbr,
+ edge_index=_path_edges(_N_NODES),
+ num_nodes=_N_NODES,
+ )
+
+
+def _poly_operator() -> PolynomialGraphLinearOperator:
+ """Degree-2 wrapper on the same path graph."""
+ k_self, k_nbr = _identity_pair()
+ k_two = 0.01 * torch.eye(_LATENT_DIM, dtype=torch.float64)
+ return PolynomialGraphLinearOperator(
+ (k_self, k_nbr, k_two),
+ edge_index=_path_edges(_N_NODES),
+ num_nodes=_N_NODES,
+ )
+
+
+def _valid_diagnostics(*, n: int = 2) -> SpectralDiagnostics:
+ """Minimal finite diagnostics aligned with ``n`` modes."""
+ return SpectralDiagnostics(
+ eigenvector_condition=1.0,
+ eigenvalue_sensitivities=torch.ones(n, dtype=torch.float64),
+ departure_from_normality=0.0,
+ departure_from_normality_relative=0.0,
+ nyquist_frequency=0.5,
+ aliasing_warning=torch.zeros(n, dtype=torch.bool),
+ sign_of_eigenvalue=torch.ones(n, dtype=torch.float64),
+ )
+
+
+def _spectrum_from_vectors(
+ vectors: torch.Tensor,
+ *,
+ diagnostics: SpectralDiagnostics | None = None,
+) -> KoopmanSpectrum:
+ """Build a spectrum with prescribed eigenvectors."""
+ n = int(vectors.shape[0])
+ eigenvalues = torch.ones(n, dtype=torch.complex128)
+ return KoopmanSpectrum(
+ eigenvalues=eigenvalues,
+ eigenvectors=vectors.to(dtype=torch.complex128),
+ magnitudes=eigenvalues.abs(),
+ growth_rates=torch.zeros(n, dtype=torch.float64),
+ frequencies=torch.zeros(n, dtype=torch.float64),
+ time_step=1.0,
+ diagnostics=diagnostics,
+ )
+
+
+# ---------------------------------------------------------------------------
+# cochain.py
+# ---------------------------------------------------------------------------
+
+
+def test_cochain_state_rejects_non_tensor_and_bad_dtypes() -> None:
+ """Feature tables must be real finite floating-point matrices."""
+ edge = torch.ones(2, 2)
+ with pytest.raises(TypeError, match="must be a Tensor"):
+ CochainState(node="latents", edge=edge) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="must be real"):
+ CochainState(node=torch.ones(2, 2, dtype=torch.complex64), edge=edge)
+ with pytest.raises(ValueError, match="floating-point"):
+ CochainState(node=torch.ones(2, 2, dtype=torch.int64), edge=edge)
+ with pytest.raises(ValueError, match="n_cells, d"):
+ CochainState(node=torch.ones(2), edge=torch.ones(2))
+ with pytest.raises(ValueError, match="latent width must be >= 1"):
+ CochainState(node=torch.ones(2, 0), edge=torch.ones(2, 0))
+
+
+def test_boundary_nilpotency_report_and_helper_guards() -> None:
+ """Nilpotency report scalars and incidence tables are validated."""
+ product = torch.zeros(2, 1)
+ with pytest.raises(ValueError, match="product must have shape"):
+ BoundaryNilpotencyReport(
+ product=torch.zeros(3),
+ max_abs=0.0,
+ atol=1e-6,
+ nilpotent=True,
+ )
+ with pytest.raises(ValueError, match="max_abs"):
+ BoundaryNilpotencyReport(
+ product=product,
+ max_abs=float("nan"),
+ atol=1e-6,
+ nilpotent=False,
+ )
+ with pytest.raises(ValueError, match="max_abs"):
+ BoundaryNilpotencyReport(
+ product=product,
+ max_abs=-0.1,
+ atol=1e-6,
+ nilpotent=False,
+ )
+ with pytest.raises(ValueError, match="atol"):
+ BoundaryNilpotencyReport(
+ product=product,
+ max_abs=0.0,
+ atol=-1.0,
+ nilpotent=True,
+ )
+ with pytest.raises(TypeError, match="must be tensors"):
+ boundary_nilpotency(torch.ones(2, 2), "b2") # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="must be 2-D"):
+ boundary_nilpotency(torch.ones(3), torch.ones(3, 1))
+ with pytest.raises(ValueError, match="must be finite"):
+ boundary_nilpotency(
+ torch.tensor([[float("nan"), 0.0]]),
+ torch.ones(2, 1),
+ )
+ with pytest.raises(ValueError, match="atol must be a finite"):
+ boundary_nilpotency(torch.ones(2, 2), torch.ones(2, 1), atol=float("inf"))
+
+
+def test_cochain_operator_constructor_and_advance_guards() -> None:
+ """Operator construction and incidence alignment name the broken field."""
+ edge_index = torch.tensor([[0, 1], [1, 2]], dtype=torch.long)
+ with pytest.raises(ValueError, match="latent_dim"):
+ CochainKoopmanOperator(True, edge_index, num_nodes=3) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="latent_dim"):
+ CochainKoopmanOperator(0, edge_index, num_nodes=3)
+ with pytest.raises(ValueError, match="num_nodes"):
+ CochainKoopmanOperator(2, edge_index, num_nodes=True) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="use_cross_degree"):
+ CochainKoopmanOperator(2, edge_index, num_nodes=3, use_cross_degree=1) # type: ignore[arg-type]
+ operator = CochainKoopmanOperator(2, edge_index, num_nodes=3)
+ with pytest.raises(ValueError, match="num_edges"):
+ operator.advance(
+ CochainState(node=torch.ones(3, 2), edge=torch.ones(1, 2)),
+ )
+ wide = CochainState(node=torch.ones(3, 3), edge=torch.ones(2, 3))
+ with pytest.raises(ValueError, match="latent width must equal latent_dim"):
+ operator.advance(wide)
+ state = CochainState(node=torch.ones(3, 2), edge=torch.zeros(2, 2))
+ got = operator.forward(state)
+ assert got.node.shape == (3, 2)
+ assert got.edge.shape == (2, 2)
+
+
+# ---------------------------------------------------------------------------
+# linear.py
+# ---------------------------------------------------------------------------
+
+
+def test_linear_operator_flat_vector_guards() -> None:
+ """Public matvec / residual paths refuse non-flat real vectors."""
+ operator = _one_tap_operator()
+ dim = operator.dim
+ with pytest.raises(TypeError, match="must be a Tensor"):
+ operator.matvec([0.0] * dim) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="must be real"):
+ operator.matvec(torch.ones(dim, dtype=torch.complex128))
+ with pytest.raises(ValueError, match="floating-point"):
+ operator.matvec(torch.ones(dim, dtype=torch.int64))
+ with pytest.raises(ValueError, match="must have shape"):
+ operator.matvec(torch.ones(2, 2, dtype=torch.float64))
+ with pytest.raises(ValueError, match="must be finite"):
+ operator.residual_norm(
+ torch.ones(dim, dtype=torch.float64),
+ torch.tensor([float("nan")] * dim, dtype=torch.float64),
+ )
+
+
+def test_linear_operator_expm_action_guards_and_zero_time() -> None:
+ """Taylor action rejects bad time / term counts and is identity at t=0."""
+ operator = _one_tap_operator()
+ rhs = torch.arange(operator.dim, dtype=torch.float64)
+ with pytest.raises(ValueError, match="t must be a finite float"):
+ operator.expm_action(True, rhs) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="t must be finite"):
+ operator.expm_action(float("inf"), rhs)
+ got = operator.expm_action(0.0, rhs)
+ assert torch.equal(got, rhs)
+ assert got is not rhs
+ with pytest.raises(ValueError, match="n_terms"):
+ _taylor_expm_action(operator.matvec, 0.1, rhs, n_terms=0)
+
+
+def test_linear_operator_hop_and_topology_guards() -> None:
+ """Hop banks and topology are checked before any matvec."""
+ edges = _path_edges(_N_NODES)
+ identity = torch.eye(_LATENT_DIM, dtype=torch.float64)
+ empty = torch.zeros(0, 0, dtype=torch.float64)
+ with pytest.raises(ValueError, match="hop_matrices must be non-empty"):
+ _SymmetricGraphLinearMap((), edges, _N_NODES, None)
+ with pytest.raises(ValueError, match="latent_dim must be >= 1"):
+ PolynomialGraphLinearOperator(
+ (empty, empty, empty),
+ edge_index=edges,
+ num_nodes=_N_NODES,
+ )
+ with pytest.raises(TypeError, match="hop_matrices\\[1\\] must be a Tensor"):
+ PolynomialGraphLinearOperator(
+ (identity, "nbr", identity), # type: ignore[list-item]
+ edge_index=edges,
+ num_nodes=_N_NODES,
+ )
+ with pytest.raises(ValueError, match="must have shape"):
+ PolynomialGraphLinearOperator(
+ (identity, torch.ones(2, 3, dtype=torch.float64), identity),
+ edge_index=edges,
+ num_nodes=_N_NODES,
+ )
+ with pytest.raises(ValueError, match="real floating-point"):
+ PolynomialGraphLinearOperator(
+ (identity, identity.to(dtype=torch.complex128), identity),
+ edge_index=edges,
+ num_nodes=_N_NODES,
+ )
+ nan_hop = identity.clone()
+ nan_hop[0, 0] = float("nan")
+ with pytest.raises(ValueError, match="hop_matrices must be finite"):
+ PolynomialGraphLinearOperator(
+ (identity, nan_hop, identity),
+ edge_index=edges,
+ num_nodes=_N_NODES,
+ )
+ with pytest.raises(ValueError, match="num_nodes"):
+ PolynomialGraphLinearOperator(
+ (identity, identity, identity),
+ edge_index=edges,
+ num_nodes=True, # type: ignore[arg-type]
+ )
+ with pytest.raises(ValueError, match="num_nodes"):
+ MatrixFreeGraphLinearOperator(
+ identity,
+ identity,
+ edge_index=edges,
+ num_nodes=0,
+ )
+ with pytest.raises(ValueError, match="edge_index must have shape"):
+ MatrixFreeGraphLinearOperator(
+ identity,
+ identity,
+ edge_index=torch.ones(3),
+ num_nodes=_N_NODES,
+ )
+ with pytest.raises(ValueError, match="edge_index must have shape"):
+ MatrixFreeGraphLinearOperator(
+ identity,
+ identity,
+ edge_index=torch.ones(3, 2, dtype=torch.long),
+ num_nodes=_N_NODES,
+ )
+
+
+def test_linear_operator_leading_eigpairs_guards_and_polynomial_path() -> None:
+ """Arnoldi wrappers validate ``k``; the polynomial path returns Ritz pairs."""
+ one_tap = _one_tap_operator()
+ poly = _poly_operator()
+ with pytest.raises(ValueError, match="k must be a positive int"):
+ one_tap.leading_eigpairs(True) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="k must be a positive int"):
+ poly.leading_eigpairs(0)
+ with pytest.raises(ValueError, match="k must be <="):
+ poly.leading_eigpairs(poly.dim + 1)
+ result = poly.leading_eigpairs(1, tol=1e-3)
+ assert result.eigenvalues.shape == (1,)
+ assert result.residual_norms.shape == (1,)
+
+
+# ---------------------------------------------------------------------------
+# graph.py
+# ---------------------------------------------------------------------------
+
+
+def test_graph_higher_hop_reset_bound_and_set_dense_guards() -> None:
+ """``P>=2`` allocates hop banks; dense writes check factor lengths."""
+ identity = torch.eye(2)
+ dual = GraphKoopmanOperator(
+ 2,
+ init_mode="identity",
+ filter_degree=2,
+ adjacency="dual_random_walk",
+ )
+ dual.reset_parameters()
+ metric = dual.bound_metric()
+ assert metric.ndim == 0
+ assert torch.isfinite(metric)
+ unit = GraphKoopmanOperator(2, init_mode="identity")
+ with pytest.raises(ValueError, match="hop_matrices requires filter_degree"):
+ unit.set_dense_matrices(identity, identity, hop_matrices=(identity,))
+ poly = GraphKoopmanOperator(2, init_mode="identity", filter_degree=2)
+ with pytest.raises(ValueError, match="hop_matrices must have length"):
+ poly.set_dense_matrices(identity, identity, hop_matrices=(identity, identity))
+ with pytest.raises(ValueError, match="bwd_hop_matrices is only valid"):
+ poly.set_dense_matrices(identity, identity, bwd_hop_matrices=(identity,))
+ with pytest.raises(ValueError, match="bwd_hop_matrices must have length"):
+ dual.set_dense_matrices(
+ identity,
+ identity,
+ bwd_hop_matrices=(identity, identity),
+ )
+ assert unit._extra_hop_matrices() == ()
+ assert unit._extra_bwd_hop_matrices() == ()
+
+
+def test_graph_higher_hop_symmetric_random_walk_and_dual() -> None:
+ """Dense and sparse ``P=2`` paths run for every adjacency kind."""
+ edges = _path_edges(3)
+ identity = 0.5 * torch.eye(2)
+ hop = 0.04 * torch.eye(2)
+ z = torch.tensor([[0.2, -0.1], [0.0, 0.3], [0.4, 0.1]])
+ for adjacency in ("symmetric", "random_walk", "dual_random_walk"):
+ operator = GraphKoopmanOperator(
+ 2,
+ init_mode="identity",
+ filter_degree=2,
+ adjacency=adjacency, # type: ignore[arg-type]
+ )
+ kwargs: dict[str, Any] = {"hop_matrices": (hop,)}
+ if adjacency == "dual_random_walk":
+ kwargs["k_bwd"] = 0.08 * torch.eye(2)
+ kwargs["bwd_hop_matrices"] = (0.02 * torch.eye(2),)
+ operator.set_dense_matrices(identity, 0.1 * torch.eye(2), **kwargs)
+ advanced = operator(z, edges)
+ assembled = operator.effective_matrix(edges, 3)
+ dense_next = (assembled @ z.reshape(-1)).view_as(z)
+ assert torch.allclose(advanced, dense_next, atol=1e-5), adjacency
+
+
+def test_graph_effective_matrix_self_block_overrides() -> None:
+ """``P=0`` and ``P>=1`` reject malformed per-node self blocks."""
+ edges = _path_edges(3)
+ identity = torch.eye(2)
+ self_only = GraphKoopmanOperator(2, init_mode="identity", filter_degree=0)
+ kron = self_only.effective_matrix(edges, 3, k_self=0.4 * identity)
+ assert kron.shape == (6, 6)
+ assert torch.allclose(kron, torch.kron(torch.eye(3), 0.4 * identity), atol=_ATOL)
+ with pytest.raises(ValueError, match="k_self_blocks must have shape"):
+ self_only.effective_matrix(edges, 3, k_self_blocks=torch.ones(2, 2))
+ blocks = torch.stack([0.4 * identity] * 3)
+ blocked = self_only.effective_matrix(edges, 3, k_self_blocks=blocks)
+ assert blocked.shape == (6, 6)
+ assert torch.allclose(blocked, torch.block_diag(*blocks.unbind(0)), atol=_ATOL)
+ one_hop = GraphKoopmanOperator(2, init_mode="identity")
+ with pytest.raises(ValueError, match="k_self_blocks must have shape"):
+ one_hop.effective_matrix(edges, 3, k_self_blocks=torch.ones(3, 2))
+
+
+def test_graph_bilinear_inverse_requires_control_after_dim_cleared() -> None:
+ """Inner bilinear inverse still demands a control once ``control_dim`` is 0."""
+ edges = _path_edges(3)
+ operator = GraphKoopmanOperator(
+ 2,
+ init_mode="identity",
+ control_dim=1,
+ control_mode="bilinear",
+ )
+ operator.control_dim = 0
+ z = torch.randn(3, 2)
+ with pytest.raises(ValueError, match="control input is required"):
+ operator.inverse_advance(z, edge_index=edges)
+ with pytest.raises(ValueError, match="Per-node control has"):
+ operator.inverse_advance(z, control=torch.ones(2, 1), edge_index=edges)
+ with pytest.raises(ValueError, match="control input must have shape"):
+ operator.inverse_advance(z, control=torch.ones(1, 1, 1), edge_index=edges)
+
+
+# ---------------------------------------------------------------------------
+# parametric.py
+# ---------------------------------------------------------------------------
+
+
+def test_parametric_ridge_edmd_and_lengthscale_guards() -> None:
+ """Leave-one-out helpers and length-scale writes reject invalid inputs."""
+ z = torch.eye(2)
+ with pytest.raises(ValueError, match="2-D latents"):
+ leave_one_regime_out(
+ [
+ (torch.tensor([0.0]), torch.ones(3), torch.ones(3)),
+ (torch.tensor([1.0]), z, z),
+ ],
+ hold_out=1,
+ )
+ with pytest.raises(ValueError, match="must share shape"):
+ leave_one_regime_out(
+ [
+ (torch.tensor([0.0]), torch.ones(3, 2), torch.ones(2, 2)),
+ (torch.tensor([1.0]), z, z),
+ ],
+ hold_out=1,
+ )
+ with pytest.raises(ValueError, match="at least one pair"):
+ leave_one_regime_out(
+ [
+ (torch.tensor([0.0]), torch.zeros(0, 2), torch.zeros(0, 2)),
+ (torch.tensor([1.0]), z, z),
+ ],
+ hold_out=1,
+ )
+ with pytest.raises(ValueError, match="held-out mu"):
+ leave_one_regime_out(
+ [
+ (torch.tensor(0.0), z, z),
+ (torch.tensor([1.0]), z, z),
+ ],
+ hold_out=0,
+ )
+ operator = ParametricKoopmanOperator(2)
+ with pytest.raises(ValueError, match="positivity floor"):
+ operator.set_lengthscale(LENGTHSCALE_FLOOR)
+ with pytest.raises(ValueError, match="anchors must be finite"):
+ operator.set_anchors(torch.tensor([[0.0], [float("nan")]]))
+ with pytest.raises(ValueError, match="parameters must be finite"):
+ operator.advance(torch.randn(3, 2), parameters=torch.tensor([float("nan")]))
+
+
+def test_leave_one_regime_out_requires_a_train_regime() -> None:
+ """A length-2 sequence that only yields the hold-out has no train map."""
+
+ class _HoldOutOnly:
+ """Sequence whose iteration stops after the held-out regime."""
+
+ def __len__(self) -> int:
+ return 2
+
+ def __getitem__(
+ self, index: int
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
+ if index != 0:
+ raise IndexError
+ pairs = torch.eye(2)
+ return torch.tensor([0.0]), pairs, pairs
+
+ with pytest.raises(ValueError, match="at least one train regime"):
+ leave_one_regime_out(_HoldOutOnly(), hold_out=0) # type: ignore[arg-type]
+
+
+# ---------------------------------------------------------------------------
+# spectrum_types.py
+# ---------------------------------------------------------------------------
+
+
+def test_spectral_diagnostics_field_guards() -> None:
+ """Diagnostic tensors and scalars reject empty, NaN, and out-of-range values."""
+ good = _valid_diagnostics()
+ with pytest.raises(ValueError, match="nonempty 1-D"):
+ replace(good, eigenvalue_sensitivities=torch.ones(2, 1))
+ with pytest.raises(ValueError, match="must not contain NaN"):
+ replace(
+ good,
+ eigenvalue_sensitivities=torch.tensor([1.0, float("nan")]),
+ )
+ with pytest.raises(ValueError, match="must be non-negative"):
+ replace(good, eigenvalue_sensitivities=torch.tensor([1.0, -0.1]))
+ with pytest.raises(ValueError, match="aliasing_warning must have shape"):
+ replace(good, aliasing_warning=torch.zeros(3, dtype=torch.bool))
+ with pytest.raises(ValueError, match="aliasing_warning must be a bool"):
+ replace(good, aliasing_warning=torch.zeros(2))
+ with pytest.raises(ValueError, match="sign_of_eigenvalue must have shape"):
+ replace(good, sign_of_eigenvalue=torch.ones(1))
+ with pytest.raises(ValueError, match="sign_of_eigenvalue must be real"):
+ replace(
+ good,
+ sign_of_eigenvalue=torch.ones(2, dtype=torch.complex128),
+ )
+ with pytest.raises(ValueError, match="sign_of_eigenvalue must not contain NaN"):
+ replace(good, sign_of_eigenvalue=torch.tensor([1.0, float("nan")]))
+ with pytest.raises(ValueError, match=r"must be in \{-1, 0, 1\}"):
+ replace(good, sign_of_eigenvalue=torch.tensor([1.0, 2.0]))
+ with pytest.raises(ValueError, match="nyquist_frequency"):
+ replace(good, nyquist_frequency=0.0)
+ with pytest.raises(ValueError, match="must not be NaN"):
+ replace(good, eigenvector_condition=float("nan"))
+ with pytest.raises(ValueError, match="must be non-negative"):
+ replace(good, departure_from_normality=-1.0)
+
+
+def test_koopman_spectrum_rejects_misaligned_diagnostics() -> None:
+ """Attached diagnostics must match the stored eigenpair count."""
+ diag = _valid_diagnostics(n=2)
+ with pytest.raises(ValueError, match="diagnostics.eigenvalue_sensitivities"):
+ KoopmanSpectrum(
+ eigenvalues=torch.ones(3, dtype=torch.complex128),
+ eigenvectors=torch.eye(3, dtype=torch.complex128),
+ magnitudes=torch.ones(3),
+ growth_rates=torch.zeros(3),
+ frequencies=torch.zeros(3),
+ time_step=1.0,
+ diagnostics=diag,
+ )
+
+
+def test_compute_spectral_diagnostics_shape_guards() -> None:
+ """The diagnostic helper names square-size and emptiness failures."""
+ matrix = torch.eye(2, dtype=torch.float64)
+ vectors = torch.eye(2, dtype=torch.complex128)
+ values = torch.ones(2, dtype=torch.complex128)
+ with pytest.raises(ValueError, match="matrix must be square"):
+ compute_spectral_diagnostics(
+ torch.ones(2, 3),
+ vectors,
+ eigenvalues=values,
+ )
+ with pytest.raises(ValueError, match="eigenvectors must be square"):
+ compute_spectral_diagnostics(
+ matrix,
+ torch.ones(2, 3, dtype=torch.complex128),
+ eigenvalues=values,
+ )
+ with pytest.raises(ValueError, match="must share dimension"):
+ compute_spectral_diagnostics(
+ matrix,
+ torch.eye(3, dtype=torch.complex128),
+ eigenvalues=torch.ones(3, dtype=torch.complex128),
+ )
+ with pytest.raises(ValueError, match="matrix must be non-empty"):
+ compute_spectral_diagnostics(
+ torch.zeros(0, 0),
+ torch.zeros(0, 0),
+ eigenvalues=torch.zeros(0),
+ )
+
+
+def test_mode_amplitudes_wraps_solve_failure_as_defective() -> None:
+ """A finite stored κ(V) still raises when the solve is singular."""
+ vectors = torch.tensor(
+ [[1.0 + 0.0j, 0.0 + 0.0j], [0.0 + 0.0j, 0.0 + 0.0j]],
+ )
+ spectrum = _spectrum_from_vectors(vectors, diagnostics=_valid_diagnostics())
+ with pytest.raises(DefectiveSpectrumError, match="Schur"):
+ spectrum.mode_amplitudes(torch.ones(2, dtype=torch.float64))
+
+
+def test_koopman_spectrum_residual_and_amplitude_guards() -> None:
+ """Residuals must align with eigenpairs; amplitudes need a trailing dim."""
+ eigenvalues = torch.ones(2, dtype=torch.complex128)
+ vectors = torch.eye(2, dtype=torch.complex128)
+ kwargs = {
+ "eigenvalues": eigenvalues,
+ "eigenvectors": vectors,
+ "magnitudes": eigenvalues.abs(),
+ "growth_rates": torch.zeros(2),
+ "frequencies": torch.zeros(2),
+ "time_step": 1.0,
+ }
+ with pytest.raises(ValueError, match="residuals must have shape"):
+ KoopmanSpectrum(**kwargs, residuals=torch.ones(3))
+ with pytest.raises(ValueError, match="residuals must be finite"):
+ KoopmanSpectrum(**kwargs, residuals=torch.tensor([1.0, float("nan")]))
+ with pytest.raises(ValueError, match="residuals must be non-negative"):
+ KoopmanSpectrum(**kwargs, residuals=torch.tensor([1.0, -0.1]))
+ spectrum = KoopmanSpectrum(**kwargs, residuals=torch.zeros(2))
+ with pytest.raises(ValueError, match="Expected trailing dimension"):
+ spectrum.mode_amplitudes(torch.tensor(1.0))
+ with pytest.raises(ValueError, match="Expected trailing dimension"):
+ spectrum.mode_amplitudes(torch.ones(2, 3))
+
+
+def test_spectrum_assembly_helpers_reject_invalid_operators() -> None:
+ """Discrete / generator assembly names shape, emptiness, and Δt failures."""
+ with pytest.raises(ValueError, match="operator must be a square"):
+ compute_spectrum(torch.ones(2, 3), time_step=1.0)
+ with pytest.raises(ValueError, match="operator must be non-empty"):
+ compute_spectrum(torch.zeros(0, 0), time_step=1.0)
+ with pytest.raises(ValueError, match="time_step must be positive"):
+ compute_spectrum(torch.eye(2), time_step=0.0)
+ with pytest.raises(TypeError, match="floating-point or complex"):
+ compute_spectrum(torch.eye(2, dtype=torch.int64), time_step=1.0)
+ with pytest.raises(ValueError, match="generator must be a square"):
+ compute_generator_spectrum(torch.ones(3))
+ with pytest.raises(ValueError, match="generator must be non-empty"):
+ compute_generator_spectrum(torch.zeros(0, 0))
+ with pytest.raises(TypeError, match="floating-point or complex"):
+ compute_generator_spectrum(torch.eye(2, dtype=torch.int64))
+ with pytest.raises(ValueError, match="delta_t must be positive"):
+ discrete_spectrum_at_delta_t(torch.eye(2), delta_t=-0.1)
+ spectrum = discrete_spectrum_at_delta_t(
+ torch.diag(torch.tensor([-0.5, -1.0])),
+ delta_t=0.1,
+ )
+ assert spectrum.eigenvalues.numel() == 2
+
+
+def test_condition_number_failures_are_reported_as_infinite() -> None:
+ """``cond`` errors or non-finite values become ``+inf`` diagnostics."""
+ matrix = torch.eye(2, dtype=torch.float64)
+ vectors = torch.eye(2, dtype=torch.complex128)
+ values = torch.ones(2, dtype=torch.complex128)
+ spectrum = _spectrum_from_vectors(vectors)
+ with patch(
+ "koopman_graph.spectrum_types.torch.linalg.cond",
+ side_effect=RuntimeError("cond failed"),
+ ):
+ with pytest.raises(DefectiveSpectrumError, match="Schur"):
+ spectrum.mode_amplitudes(torch.ones(2, dtype=torch.float64))
+ diag = compute_spectral_diagnostics(
+ matrix,
+ vectors,
+ eigenvalues=values,
+ time_step=1.0,
+ )
+ assert math.isinf(diag.eigenvector_condition)
+ with patch(
+ "koopman_graph.spectrum_types.torch.linalg.cond",
+ return_value=torch.tensor(float("inf")),
+ ):
+ with pytest.raises(DefectiveSpectrumError, match="Schur"):
+ spectrum.mode_amplitudes(torch.ones(2, dtype=torch.float64))
+ inf_diag = compute_spectral_diagnostics(
+ matrix,
+ vectors,
+ eigenvalues=values,
+ time_step=1.0,
+ )
+ assert math.isinf(inf_diag.eigenvector_condition)
diff --git a/tests/operators/test_equivariant.py b/tests/operators/test_equivariant.py
index 6c1b2e6..6a73d89 100644
--- a/tests/operators/test_equivariant.py
+++ b/tests/operators/test_equivariant.py
@@ -1,24 +1,60 @@
-"""Coverage and error-path tests for :mod:`koopman_graph.operators`."""
+"""Block-equivariant Koopman operator, including the l=2 tensor irrep."""
from __future__ import annotations
+import ast
+from pathlib import Path
+
import pytest
import torch
-from koopman_graph.operators import (
- EquivariantKoopmanOperator,
+import koopman_graph
+from koopman_graph.model.factory import parse_koopman_arg
+from koopman_graph.operators import EquivariantKoopmanOperator
+from koopman_graph.operators.equivariant import (
+ TENSOR_IRREP_DIM,
+ TENSOR_IRREP_L,
+ VECTOR_IRREP_DIM,
)
+# Independent construction: scale * I_l commutes with Wigner D^{(l)}.
+_ORACLE_ABS = 1e-6
+_SEED = 0
+
+
+def test_equivariant_operator_off_root() -> None:
+ """Block operator lives on ``operators.__all__``, not the root façade."""
+ assert "EquivariantKoopmanOperator" in koopman_graph.operators.__all__
+ assert "EquivariantKoopmanOperator" not in koopman_graph.__all__
+
+
+def test_equivariant_module_does_not_import_e3nn() -> None:
+ """Generator leaf stays extra-free; rotation tests import e3nn."""
+ source = Path(__file__).resolve().parents[2] / (
+ "src/koopman_graph/operators/equivariant.py"
+ )
+ tree = ast.parse(source.read_text(encoding="utf-8"))
+ imported: list[str] = []
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ imported.extend(alias.name for alias in node.names)
+ elif isinstance(node, ast.ImportFrom) and node.module is not None:
+ imported.append(node.module)
+ offenders = [
+ name for name in imported if name == "e3nn" or name.startswith("e3nn.")
+ ]
+ assert not offenders
+
def test_equivariant_operator_blocks_inverse_and_forward() -> None:
"""Scalar/vector operator covers validation, vector-only, inverse, forward."""
with pytest.raises(ValueError, match="non-negative"):
EquivariantKoopmanOperator(n_scalars=-1, n_vectors=1)
with pytest.raises(ValueError, match="at least one"):
- EquivariantKoopmanOperator(n_scalars=0, n_vectors=0)
+ EquivariantKoopmanOperator(n_scalars=0, n_vectors=0, n_tensors=0)
vectors = EquivariantKoopmanOperator(n_scalars=0, n_vectors=1)
- z = torch.randn(2, 3)
- assert vectors.matrix.shape == (3, 3)
+ z = torch.randn(2, VECTOR_IRREP_DIM)
+ assert vectors.matrix.shape == (VECTOR_IRREP_DIM, VECTOR_IRREP_DIM)
assert vectors.bound_metric().ndim == 0
assert vectors.inverse_advance(z).shape == z.shape
assert vectors(z).shape == z.shape
@@ -34,4 +70,74 @@ def test_equivariant_block_operator() -> None:
matrix = op.matrix
assert matrix.shape == (5, 5)
vec_block = matrix[2:, 2:]
- assert torch.allclose(vec_block, op.vector_scales[0] * torch.eye(3), atol=1e-6)
+ assert torch.allclose(
+ vec_block,
+ op.vector_scales[0] * torch.eye(VECTOR_IRREP_DIM),
+ atol=_ORACLE_ABS,
+ )
+
+
+def test_scalar_only_bound_metric_does_not_require_vector_scales() -> None:
+ """Empty vector / tensor scale banks are skipped in ``bound_metric``."""
+ op = EquivariantKoopmanOperator(n_scalars=2)
+ assert op.n_tensors == 0
+ assert op.latent_dim == 2
+ assert op.bound_metric().ndim == 0
+
+
+def test_l2_tensor_block_is_scale_times_i5() -> None:
+ """The additional irrep is ``scale * I_5``, not a mixed Clebsch–Gordan map."""
+ op = EquivariantKoopmanOperator(n_scalars=0, n_vectors=0, n_tensors=1)
+ assert TENSOR_IRREP_L == 2
+ assert op.latent_dim == TENSOR_IRREP_DIM
+ with torch.no_grad():
+ op.tensor_scales.copy_(torch.tensor([-0.4]))
+ block = op.matrix
+ assert torch.allclose(
+ block,
+ op.tensor_scales[0] * torch.eye(TENSOR_IRREP_DIM),
+ atol=_ORACLE_ABS,
+ )
+ z = torch.randn(3, TENSOR_IRREP_DIM)
+ assert op.advance(z).shape == z.shape
+ assert op.bound_metric().item() == pytest.approx(0.4, abs=_ORACLE_ABS)
+
+
+def test_l2_tensor_block_commutes_with_rotation() -> None:
+ """``K`` commutes with the SO(3) representation on ``0e ⊕ 1o ⊕ 2e``.
+
+ Independent oracle: Schur says each irrep block is a scalar times
+ identity, so it commutes with the Wigner matrix of that irrep.
+ Requires ``e3nn`` (``[equivariance]``).
+ """
+ o3 = pytest.importorskip("e3nn.o3")
+ op = EquivariantKoopmanOperator(
+ n_scalars=1,
+ n_vectors=1,
+ n_tensors=1,
+ ).double()
+ with torch.no_grad():
+ op.vector_scales.copy_(torch.tensor([0.7], dtype=torch.float64))
+ op.tensor_scales.copy_(torch.tensor([-0.4], dtype=torch.float64))
+ torch.manual_seed(_SEED)
+ latents = torch.randn(4, op.latent_dim, dtype=torch.float64)
+ rotation = o3.rand_matrix()
+ irreps = o3.Irreps("1x0e + 1x1o + 1x2e")
+ if hasattr(irreps, "D_from_matrix"):
+ representation = irreps.D_from_matrix(rotation)
+ else:
+ representation = irreps.D_from_angles(*o3.matrix_to_angles(rotation))
+ representation = representation.to(dtype=torch.float64)
+ rotated = latents @ representation.transpose(-1, -2)
+ left = op.advance(rotated)
+ right = op.advance(latents) @ representation.transpose(-1, -2)
+ assert torch.allclose(left, right, atol=_ORACLE_ABS, rtol=0.0)
+
+
+def test_equivariant_is_not_a_factory_kind() -> None:
+ """``koopman=None`` stays per-node; ``equivariant`` is not a kind."""
+ kind, injected = parse_koopman_arg(None)
+ assert kind == "pernode"
+ assert injected is None
+ with pytest.raises(ValueError, match="hodge"):
+ parse_koopman_arg("equivariant")
diff --git a/tests/operators/test_graph_operator.py b/tests/operators/test_graph_operator.py
index eab6ad9..6ceee0b 100644
--- a/tests/operators/test_graph_operator.py
+++ b/tests/operators/test_graph_operator.py
@@ -153,13 +153,13 @@ def test_spectrum_eligible_default_uses_kronecker_path(
from koopman_graph.operators import graph as graph_mod
calls: list[dict[str, object]] = []
- original = graph_mod.spectrum_k_eff_kronecker_sum
+ original = graph_mod.spectrum_k_eff_kronecker_polynomial
def _spy(**kwargs: object):
calls.append(kwargs)
return original(**kwargs)
- monkeypatch.setattr(graph_mod, "spectrum_k_eff_kronecker_sum", _spy)
+ monkeypatch.setattr(graph_mod, "spectrum_k_eff_kronecker_polynomial", _spy)
edge_index = _path_edge_index(3)
op = GraphKoopmanOperator(2, init_mode="identity")
spectrum = op.spectrum(edge_index, 3, time_step=0.25)
@@ -178,7 +178,7 @@ def _boom(**_kwargs: object):
msg = "kronecker helper should not run on ineligible spectrum paths"
raise AssertionError(msg)
- monkeypatch.setattr(graph_mod, "spectrum_k_eff_kronecker_sum", _boom)
+ monkeypatch.setattr(graph_mod, "spectrum_k_eff_kronecker_polynomial", _boom)
edge_index = _path_edge_index(3)
time_step = 0.1
@@ -235,7 +235,7 @@ def _boom(**_kwargs: object):
msg = "kronecker helper should not run for isotypic self banks"
raise AssertionError(msg)
- monkeypatch.setattr(graph_mod, "spectrum_k_eff_kronecker_sum", _boom)
+ monkeypatch.setattr(graph_mod, "spectrum_k_eff_kronecker_polynomial", _boom)
star = _star_edge_index(5)
time_step = 0.1
op = GraphKoopmanOperator(2, init_mode="identity", isotypic_symmetry=True)
@@ -302,13 +302,13 @@ def test_operator_spectrum_block_diagonal_uses_kronecker(
from koopman_graph.operators import graph as graph_mod
calls: list[int] = []
- original = graph_mod.spectrum_k_eff_kronecker_sum
+ original = graph_mod.spectrum_k_eff_kronecker_polynomial
def _spy(**kwargs: object):
calls.append(1)
return original(**kwargs)
- monkeypatch.setattr(graph_mod, "spectrum_k_eff_kronecker_sum", _spy)
+ monkeypatch.setattr(graph_mod, "spectrum_k_eff_kronecker_polynomial", _spy)
edge_index = _path_edge_index(4)
op = GraphKoopmanOperator(2, init_mode="identity", sparsity="block_diagonal")
spectrum = op.spectrum(edge_index, 4, time_step=0.1)
@@ -332,13 +332,13 @@ def test_operator_spectrum_moderate_n_kronecker_smoke(
from koopman_graph.operators import graph as graph_mod
calls: list[int] = []
- original = graph_mod.spectrum_k_eff_kronecker_sum
+ original = graph_mod.spectrum_k_eff_kronecker_polynomial
def _spy(**kwargs: object):
calls.append(1)
return original(**kwargs)
- monkeypatch.setattr(graph_mod, "spectrum_k_eff_kronecker_sum", _spy)
+ monkeypatch.setattr(graph_mod, "spectrum_k_eff_kronecker_polynomial", _spy)
num_nodes = 100
latent_dim = 2
ambient = num_nodes * latent_dim
diff --git a/tests/operators/test_graphon.py b/tests/operators/test_graphon.py
index d2672d1..d186297 100644
--- a/tests/operators/test_graphon.py
+++ b/tests/operators/test_graphon.py
@@ -1,13 +1,81 @@
-"""Coverage and error-path tests for :mod:`koopman_graph.operators`."""
+"""Coverage and oracle tests for :mod:`koopman_graph.operators.graphon`."""
from __future__ import annotations
import pytest
+import torch
+from torch import Tensor
+from torch_geometric.data import Data, HeteroData
from koopman_graph.operators import (
+ MAX_GRAPHON_NODES,
+ estimate_graphon,
sample_graphon_adjacency,
)
+# Constant-oracle abs tolerance: N=40, 12 graphs, p=0.4 gives ~9e3 Bernoulli
+# trials; SE of the mean is about 0.005, so 0.05 is a conservative floor.
+_CONSTANT_ABS = 0.05
+# Product Pearson floor: degree scores on 16 mean-aggregated graphs at N=40.
+_PRODUCT_PEARSON = 0.85
+
+
+def _generator(seed: int) -> torch.Generator:
+ """Return a CPU generator with ``seed``.
+
+ Parameters
+ ----------
+ seed : int
+ RNG seed.
+
+ Returns
+ -------
+ torch.Generator
+ Seeded generator.
+ """
+ generator = torch.Generator()
+ generator.manual_seed(seed)
+ return generator
+
+
+def _as_data(num_nodes: int, edge_index: Tensor) -> Data:
+ """Wrap an adjacency as homogeneous ``Data``.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count.
+ edge_index : Tensor
+ COO index.
+
+ Returns
+ -------
+ Data
+ Snapshot with a dummy feature column.
+ """
+ return Data(x=torch.zeros(num_nodes, 1), edge_index=edge_index)
+
+
+def _pearson(left: Tensor, right: Tensor) -> float:
+ """Return Pearson correlation of two 1-D tensors.
+
+ Parameters
+ ----------
+ left, right : Tensor
+ Equal-length vectors.
+
+ Returns
+ -------
+ float
+ Sample Pearson correlation.
+ """
+ centered_left = left - left.mean()
+ centered_right = right - right.mean()
+ denom = float(centered_left.norm() * centered_right.norm())
+ if denom == 0.0:
+ return 0.0
+ return float((centered_left * centered_right).sum() / denom)
+
def test_graphon_product_kernel_and_small_n() -> None:
"""Product graphon samples; ``N < 2`` is rejected."""
@@ -17,7 +85,164 @@ def test_graphon_product_kernel_and_small_n() -> None:
assert edges.shape[0] == 2
-def test_graphon_unknown_kernel() -> None:
- """Unknown graphon kernels raise an actionable error."""
+def test_sample_graphon_rejects_bad_density_and_positions_shape() -> None:
+ """Constant density and latent shape are validated."""
+ with pytest.raises(ValueError, match="density must lie"):
+ sample_graphon_adjacency(4, density=1.5)
+ with pytest.raises(ValueError, match="positions must have shape"):
+ sample_graphon_adjacency(3, kernel="product", positions=torch.zeros(2))
with pytest.raises(ValueError, match="unknown graphon"):
sample_graphon_adjacency(4, kernel="bogus")
+
+
+def test_estimate_graphon_rejects_unusable_snapshots() -> None:
+ """Missing node count and out-of-range endpoints raise."""
+ with pytest.raises(ValueError, match="cannot infer"):
+ estimate_graphon([Data()], kernel_family="constant")
+ graph = Data(
+ x=torch.zeros(3, 1),
+ edge_index=torch.tensor([[0, 5], [1, 0]]),
+ )
+ with pytest.raises(ValueError, match="edge_index endpoints"):
+ estimate_graphon([graph], kernel_family="constant")
+
+
+def test_sample_graphon_rejects_positions_outside_unit_interval() -> None:
+ """Latent coordinates must lie in ``[0, 1]``."""
+ with pytest.raises(ValueError, match="positions must lie"):
+ sample_graphon_adjacency(
+ 3,
+ kernel="product",
+ positions=torch.tensor([0.0, 0.5, 1.2]),
+ )
+
+
+def test_estimate_graphon_recovers_constant_density() -> None:
+ """Mean off-diagonal density tracks a seeded constant graphon."""
+ num_nodes = 40
+ density = 0.4
+ graphs = [
+ _as_data(
+ num_nodes,
+ sample_graphon_adjacency(
+ num_nodes,
+ kernel="constant",
+ density=density,
+ generator=_generator(seed),
+ ),
+ )
+ for seed in range(12)
+ ]
+ estimate = estimate_graphon(graphs, kernel_family="constant")
+ assert estimate.kernel_family == "constant"
+ assert estimate.num_nodes == num_nodes
+ assert estimate.n_graphs == 12
+ assert estimate.latent_scores is None
+ assert estimate.density == pytest.approx(density, abs=_CONSTANT_ABS)
+ upper = torch.triu_indices(num_nodes, num_nodes, offset=1)
+ assert float(
+ estimate.probability_matrix[upper[0], upper[1]].mean()
+ ) == pytest.approx(estimate.density, abs=1e-12)
+ assert torch.diag(estimate.probability_matrix).abs().max() == pytest.approx(
+ 0.0, abs=1e-12
+ )
+
+
+def test_estimate_graphon_recovers_product_latents() -> None:
+ """Degree scores correlate with fixed product-kernel positions."""
+ num_nodes = 40
+ positions = torch.linspace(0.2, 0.9, num_nodes)
+ graphs = [
+ _as_data(
+ num_nodes,
+ sample_graphon_adjacency(
+ num_nodes,
+ kernel="product",
+ positions=positions,
+ generator=_generator(seed),
+ ),
+ )
+ for seed in range(16)
+ ]
+ estimate = estimate_graphon(graphs, kernel_family="product")
+ assert estimate.latent_scores is not None
+ corr = _pearson(estimate.latent_scores, positions.to(dtype=torch.float64))
+ assert corr == pytest.approx(1.0, abs=1.0 - _PRODUCT_PEARSON)
+ assert torch.diag(estimate.probability_matrix).abs().max() == pytest.approx(
+ 0.0, abs=1e-12
+ )
+
+
+def test_estimate_graphon_accepts_explicit_num_nodes() -> None:
+ """``num_nodes`` on ``Data`` is used when ``x`` is absent."""
+ graph = Data(
+ num_nodes=4,
+ edge_index=sample_graphon_adjacency(4, generator=_generator(3)),
+ )
+ estimate = estimate_graphon([graph], kernel_family="constant")
+ assert estimate.num_nodes == 4
+
+
+def test_estimate_graphon_low_rank_is_clipped_sketch() -> None:
+ """Low-rank SVD is a teaching sketch, not an oracle claim."""
+ num_nodes = 12
+ graphs = [
+ _as_data(
+ num_nodes,
+ sample_graphon_adjacency(
+ num_nodes,
+ kernel="constant",
+ density=0.35,
+ generator=_generator(0),
+ ),
+ )
+ ]
+ estimate = estimate_graphon(graphs, kernel_family="low_rank", rank=3)
+ assert estimate.rank == 3
+ assert estimate.factors is not None
+ assert tuple(estimate.factors.shape) == (num_nodes, 3)
+ assert float(estimate.probability_matrix.min()) >= 0.0
+ assert float(estimate.probability_matrix.max()) <= 1.0
+ assert torch.diag(estimate.probability_matrix).abs().max() == pytest.approx(
+ 0.0, abs=1e-12
+ )
+
+
+def test_estimate_graphon_refuses_mixed_and_unbounded_n() -> None:
+ """Mixed ``N``, tiny ``N``, and the dense ceiling raise."""
+ small = _as_data(4, sample_graphon_adjacency(4, generator=_generator(1)))
+ other = _as_data(6, sample_graphon_adjacency(6, generator=_generator(2)))
+ with pytest.raises(ValueError, match="shared N"):
+ estimate_graphon([small, other], kernel_family="constant")
+ singleton = Data(
+ x=torch.zeros(1, 1),
+ edge_index=torch.empty(2, 0, dtype=torch.long),
+ )
+ with pytest.raises(ValueError, match=">= 2"):
+ estimate_graphon([singleton], kernel_family="constant")
+ huge = Data(
+ x=torch.zeros(MAX_GRAPHON_NODES + 1, 1),
+ edge_index=torch.empty(2, 0, dtype=torch.long),
+ )
+ with pytest.raises(ValueError, match="teaching ceiling"):
+ estimate_graphon([huge], kernel_family="constant")
+ with pytest.raises(ValueError, match="at least one graph"):
+ estimate_graphon([], kernel_family="constant")
+ with pytest.raises(ValueError, match="kernel_family"):
+ estimate_graphon([small], kernel_family="bogus") # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="rank must be"):
+ estimate_graphon([small], kernel_family="low_rank", rank=0)
+ hetero = HeteroData()
+ hetero["n"].x = torch.zeros(3, 1)
+ with pytest.raises(TypeError, match="homogeneous Data"):
+ estimate_graphon([hetero], kernel_family="constant")
+
+
+def test_graphon_module_does_not_import_data_package() -> None:
+ """Operators graphon helpers take PyG ``Data`` without importing ``data``."""
+ import inspect
+
+ import koopman_graph.operators.graphon as graphon_mod
+
+ source = inspect.getsource(graphon_mod)
+ assert "koopman_graph.data" not in source
diff --git a/tests/operators/test_kronecker_spectrum.py b/tests/operators/test_kronecker_spectrum.py
index 766fa3b..418e7af 100644
--- a/tests/operators/test_kronecker_spectrum.py
+++ b/tests/operators/test_kronecker_spectrum.py
@@ -7,15 +7,22 @@
import pytest
import torch
+from koopman_graph.graph_utils.topology import (
+ dense_random_walk_normalized_adjacency,
+ dense_symmetric_normalized_adjacency,
+)
from koopman_graph.operators.continuous_graph import ContinuousGraphKoopmanOperator
from koopman_graph.operators.graph import GraphKoopmanOperator
from koopman_graph.operators.kronecker_spectrum import (
DEFAULT_KRONECKER_ADJ_RESIDUAL_TOL,
+ eigenvalues_k_eff_kronecker_polynomial,
eigenvalues_k_eff_kronecker_sum,
kronecker_sum_spectrum_eligible,
+ spectrum_k_eff_kronecker_polynomial,
spectrum_k_eff_kronecker_sum,
spectrum_l_eff_kronecker_sum,
)
+from koopman_graph.operators.polynomial_graph import dense_polynomial_kronecker
from koopman_graph.spectrum_types import compute_generator_spectrum, compute_spectrum
@@ -142,10 +149,10 @@ def test_nonfinite_factors_return_none() -> None:
)
-def test_dual_adjacency_returns_none() -> None:
- """Dual adjacency is unsupported by the eigenvalue helper."""
+def test_dual_adjacency_raises() -> None:
+ """Dual adjacency is structurally ineligible and raises on the helper."""
edge_index = _path_edge_index(3)
- assert (
+ with pytest.raises(ValueError, match="non-commuting"):
eigenvalues_k_eff_kronecker_sum(
k_self=torch.eye(2),
k_nbr=torch.zeros(2, 2),
@@ -153,8 +160,6 @@ def test_dual_adjacency_returns_none() -> None:
num_nodes=3,
adjacency="dual_random_walk",
)
- is None
- )
def test_random_walk_residual_failure_returns_none() -> None:
@@ -438,3 +443,140 @@ def test_spectrum_l_eff_matches_generator_spectrum_and_residuals(
generator_c @ vector - reduced.eigenvalues[mode] * vector
) / torch.linalg.vector_norm(vector).clamp_min(1e-30)
assert float(residual.detach()) <= 1e-4
+
+
+_POLY_RTOL = 1e-6
+_POLY_ATOL = 1e-8
+
+
+def test_polynomial_p1_matches_sum_bit_identical() -> None:
+ """Length-2 pencil stays bit-identical to the one-tap wrapper."""
+ torch.manual_seed(0)
+ edge_index = _path_edge_index(4)
+ k_self = torch.randn(2, 2)
+ k_nbr = 0.1 * torch.randn(2, 2)
+ one_tap = eigenvalues_k_eff_kronecker_sum(
+ k_self=k_self,
+ k_nbr=k_nbr,
+ edge_index=edge_index,
+ num_nodes=4,
+ adjacency="symmetric",
+ )
+ poly = eigenvalues_k_eff_kronecker_polynomial(
+ hop_matrices=(k_self, k_nbr),
+ edge_index=edge_index,
+ num_nodes=4,
+ adjacency="symmetric",
+ )
+ assert one_tap is not None
+ assert poly is not None
+ assert torch.equal(one_tap, poly)
+
+
+@pytest.mark.parametrize("filter_degree", [0, 2])
+@pytest.mark.parametrize("adjacency", ["symmetric", "random_walk"])
+def test_polynomial_eigenvalues_match_dense_assembly(
+ filter_degree: int,
+ adjacency: str,
+) -> None:
+ """Kronecker polynomial eigvals match dense Kronecker assembly (float64).
+
+ Tolerance: relative 1e-6 with absolute floor 1e-8 on N=4, d=2 teaching
+ graphs — independent ``torch.linalg.eigvals`` of the assembled map.
+ """
+ torch.manual_seed(5)
+ num_nodes = 4
+ edge_index = _path_edge_index(num_nodes)
+ dtype = torch.float64
+ k0 = torch.tensor([[0.45, 0.12], [0.08, 0.38]], dtype=dtype)
+ k1 = torch.tensor([[0.10, 0.04], [0.03, 0.07]], dtype=dtype)
+ k2 = torch.tensor([[0.02, -0.01], [0.01, 0.03]], dtype=dtype)
+ hops = (k0,) if filter_degree == 0 else (k0, k1, k2)
+ if adjacency == "symmetric":
+ adj = dense_symmetric_normalized_adjacency(edge_index, num_nodes, dtype=dtype)
+ else:
+ adj = dense_random_walk_normalized_adjacency(
+ edge_index,
+ num_nodes,
+ dtype=dtype,
+ direction="forward",
+ )
+ dense = torch.linalg.eigvals(dense_polynomial_kronecker(adj, hops))
+ reduced = eigenvalues_k_eff_kronecker_polynomial(
+ hop_matrices=hops,
+ edge_index=edge_index,
+ num_nodes=num_nodes,
+ adjacency=adjacency,
+ )
+ assert reduced is not None
+ assert _eigvals_match(reduced, dense, rtol=_POLY_RTOL, atol=_POLY_ATOL)
+
+
+def test_polynomial_spectrum_matches_dense_p2() -> None:
+ """Full P=2 Kronecker spectrum matches dense compute_spectrum (float64)."""
+ torch.manual_seed(6)
+ num_nodes = 4
+ edge_index = _path_edge_index(num_nodes)
+ dtype = torch.float64
+ hops = (
+ torch.tensor([[0.50, 0.10], [0.00, 0.40]], dtype=dtype),
+ torch.tensor([[0.08, 0.02], [0.01, 0.05]], dtype=dtype),
+ torch.tensor([[0.03, 0.00], [0.00, 0.02]], dtype=dtype),
+ )
+ adj = dense_symmetric_normalized_adjacency(edge_index, num_nodes, dtype=dtype)
+ effective = dense_polynomial_kronecker(adj, hops)
+ time_step = 0.25
+ dense = compute_spectrum(effective, time_step)
+ reduced = spectrum_k_eff_kronecker_polynomial(
+ hop_matrices=hops,
+ edge_index=edge_index,
+ num_nodes=num_nodes,
+ adjacency="symmetric",
+ time_step=time_step,
+ )
+ assert reduced is not None
+ assert _eigvals_match(
+ reduced.eigenvalues,
+ dense.eigenvalues,
+ rtol=_POLY_RTOL,
+ atol=_POLY_ATOL,
+ )
+ effective_c = effective.to(dtype=reduced.eigenvectors.dtype)
+ for mode in range(num_nodes * hops[0].shape[0]):
+ vector = reduced.eigenvectors[:, mode]
+ residual = torch.linalg.vector_norm(
+ effective_c @ vector - reduced.eigenvalues[mode] * vector
+ ) / torch.linalg.vector_norm(vector).clamp_min(1e-30)
+ assert float(residual.detach()) <= 1e-8
+
+
+def test_polynomial_anti_oracle_rejects_independent_factor_eigs() -> None:
+ """Mixing independent factor eigenvalues fails; the pencil matches dense.
+
+ ``K_0`` is upper triangular and ``K_1`` is lower triangular, so they do
+ not share an eigenbasis. Pairing ``ν_j^{(0)} + λ_i ν_j^{(1)}`` by sorted
+ real part is the naive formula this reduction must not use.
+ """
+ num_nodes = 4
+ edge_index = _path_edge_index(num_nodes)
+ dtype = torch.float64
+ k0 = torch.tensor([[0.50, 0.40], [0.00, 0.20]], dtype=dtype)
+ k1 = torch.tensor([[0.10, 0.00], [0.35, 0.30]], dtype=dtype)
+ adj = dense_symmetric_normalized_adjacency(edge_index, num_nodes, dtype=dtype)
+ dense = torch.linalg.eigvals(dense_polynomial_kronecker(adj, (k0, k1)))
+ reduced = eigenvalues_k_eff_kronecker_polynomial(
+ hop_matrices=(k0, k1),
+ edge_index=edge_index,
+ num_nodes=num_nodes,
+ adjacency="symmetric",
+ )
+ assert reduced is not None
+ assert _eigvals_match(reduced, dense, rtol=_POLY_RTOL, atol=_POLY_ATOL)
+
+ adj_eigs = torch.linalg.eigvalsh(adj)
+ nu0 = torch.linalg.eigvals(k0)
+ nu1 = torch.linalg.eigvals(k1)
+ nu0 = nu0[torch.argsort(nu0.real)]
+ nu1 = nu1[torch.argsort(nu1.real)]
+ naive = torch.cat([nu0 + lam * nu1 for lam in adj_eigs])
+ assert not _eigvals_match(naive, dense, rtol=_POLY_RTOL, atol=_POLY_ATOL)
diff --git a/tests/operators/test_kronecker_spectrum_guards.py b/tests/operators/test_kronecker_spectrum_guards.py
index 67aff8a..603d767 100644
--- a/tests/operators/test_kronecker_spectrum_guards.py
+++ b/tests/operators/test_kronecker_spectrum_guards.py
@@ -17,8 +17,10 @@
_factors_finite,
_k_eff_eigenpairs_kronecker_sum,
_random_walk_eigendecomposition,
+ eigenvalues_k_eff_kronecker_polynomial,
eigenvalues_k_eff_kronecker_sum,
kronecker_sum_spectrum_eligible,
+ spectrum_k_eff_kronecker_polynomial,
spectrum_k_eff_kronecker_sum,
spectrum_l_eff_kronecker_sum,
)
@@ -153,6 +155,44 @@ def test_adjacency_eigenpairs_unknown_mode_returns_none() -> None:
)
+def test_dual_adjacency_raises_on_kronecker_helpers() -> None:
+ """Dual adjacency is structurally ineligible and raises on the helper."""
+ edge_index = _path_edge_index(3)
+ with pytest.raises(ValueError, match="non-commuting"):
+ eigenvalues_k_eff_kronecker_sum(
+ k_self=torch.eye(2),
+ k_nbr=torch.zeros(2, 2),
+ edge_index=edge_index,
+ num_nodes=3,
+ adjacency="dual_random_walk",
+ )
+ with pytest.raises(ValueError, match="non-commuting"):
+ spectrum_k_eff_kronecker_sum(
+ k_self=torch.eye(2),
+ k_nbr=torch.zeros(2, 2),
+ edge_index=edge_index,
+ num_nodes=3,
+ adjacency="dual_random_walk",
+ time_step=1.0,
+ )
+ with pytest.raises(ValueError, match="non-commuting"):
+ spectrum_k_eff_kronecker_polynomial(
+ hop_matrices=(torch.eye(2),),
+ edge_index=edge_index,
+ num_nodes=3,
+ adjacency="dual_random_walk",
+ time_step=1.0,
+ )
+ with pytest.raises(ValueError, match="non-commuting"):
+ spectrum_l_eff_kronecker_sum(
+ l_self=torch.eye(2),
+ l_nbr=torch.zeros(2, 2),
+ edge_index=edge_index,
+ num_nodes=3,
+ adjacency="dual_random_walk",
+ )
+
+
@pytest.mark.parametrize(
("k_self", "k_nbr", "num_nodes"),
[
@@ -270,7 +310,7 @@ def test_spectrum_helpers_return_none_when_pairs_fail() -> None:
"""Public spectrum wrappers propagate None from the eigenpair core."""
edge_index = _path_edge_index(3)
with patch(
- "koopman_graph.operators.kronecker_spectrum._k_eff_eigenpairs_kronecker_sum",
+ "koopman_graph.operators.kronecker_spectrum._k_eff_eigenpairs_kronecker_polynomial",
return_value=None,
):
assert (
@@ -294,6 +334,25 @@ def test_spectrum_helpers_return_none_when_pairs_fail() -> None:
)
is None
)
+ assert (
+ spectrum_k_eff_kronecker_polynomial(
+ hop_matrices=(torch.eye(2), torch.zeros(2, 2)),
+ edge_index=edge_index,
+ num_nodes=3,
+ adjacency="symmetric",
+ time_step=1.0,
+ )
+ is None
+ )
+ assert (
+ eigenvalues_k_eff_kronecker_polynomial(
+ hop_matrices=(torch.eye(2), torch.zeros(2, 2)),
+ edge_index=edge_index,
+ num_nodes=3,
+ adjacency="symmetric",
+ )
+ is None
+ )
assert (
spectrum_l_eff_kronecker_sum(
l_self=torch.eye(2),
@@ -311,7 +370,9 @@ def test_operator_spectrum_falls_back_when_kronecker_returns_none(
) -> None:
"""Eligible discrete spectrum dense-routes when the helper returns None."""
- monkeypatch.setattr(graph_mod, "spectrum_k_eff_kronecker_sum", lambda **_: None)
+ monkeypatch.setattr(
+ graph_mod, "spectrum_k_eff_kronecker_polynomial", lambda **_: None
+ )
edge_index = _path_edge_index(3)
op = GraphKoopmanOperator(2, init_mode="identity")
spectrum = op.spectrum(edge_index, 3, time_step=0.2)
diff --git a/tests/operators/test_linear_operator.py b/tests/operators/test_linear_operator.py
new file mode 100644
index 0000000..3fa764f
--- /dev/null
+++ b/tests/operators/test_linear_operator.py
@@ -0,0 +1,234 @@
+"""LinearOperatorProtocol wrappers for polynomial and one-tap graph maps."""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+import pytest
+import torch
+
+import koopman_graph
+from koopman_graph.graph_utils import dense_symmetric_normalized_adjacency
+from koopman_graph.operators import (
+ LinearOperatorProtocol,
+ MatrixFreeGraphLinearOperator,
+ PolynomialGraphLinearOperator,
+)
+from koopman_graph.operators.linear import MAX_DENSE_LINEAR_OPERATOR_SIZE
+from koopman_graph.operators.matrix_free import apply_k_eff_graph, flatten_node_latents
+from koopman_graph.operators.polynomial_graph import dense_polynomial_kronecker
+
+_ATOL = 1e-5
+_SPECTRUM_ATOL = 1e-4
+_RESIDUAL_ATOL = 1e-6
+_N_NODES = 6
+_LATENT_DIM = 2
+_SEED = 0
+
+
+def _path_edges(num_nodes: int) -> torch.Tensor:
+ """Undirected path used as a modest sparse smoke graph."""
+ tails = torch.arange(num_nodes - 1, dtype=torch.long)
+ forward = torch.stack((tails, tails + 1), dim=0)
+ return torch.cat((forward, forward.flip(0)), dim=1)
+
+
+def _one_tap_factors() -> tuple[torch.Tensor, torch.Tensor]:
+ """Well-conditioned self / neighbor factors for Richardson."""
+ k_self = torch.tensor([[0.7, 0.05], [0.0, 0.6]], dtype=torch.float64)
+ k_nbr = torch.tensor([[0.04, 0.0], [0.0, 0.03]], dtype=torch.float64)
+ return k_self, k_nbr
+
+
+def test_linear_operator_export_off_root() -> None:
+ """Protocol and wrappers live on ``operators.__all__``, not the root."""
+ assert "LinearOperatorProtocol" in koopman_graph.operators.__all__
+ assert "PolynomialGraphLinearOperator" in koopman_graph.operators.__all__
+ assert "MatrixFreeGraphLinearOperator" in koopman_graph.operators.__all__
+ assert "LinearOperatorProtocol" not in koopman_graph.__all__
+ assert "PolynomialGraphLinearOperator" not in koopman_graph.__all__
+ assert "MatrixFreeGraphLinearOperator" not in koopman_graph.__all__
+
+
+def test_linear_module_does_not_import_model() -> None:
+ """L2 operator algebra must not import L4 model."""
+ source = (
+ Path(__file__).resolve().parents[2] / "src/koopman_graph/operators/linear.py"
+ )
+ tree = ast.parse(source.read_text(encoding="utf-8"))
+ imported: list[str] = []
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ imported.extend(alias.name for alias in node.names)
+ elif isinstance(node, ast.ImportFrom) and node.module is not None:
+ imported.append(node.module)
+ offenders = [
+ name
+ for name in imported
+ if name == "koopman_graph.model" or name.startswith("koopman_graph.model.")
+ ]
+ assert not offenders
+
+
+def test_wrappers_satisfy_linear_operator_protocol() -> None:
+ """Both teaching wrappers are runtime-checkable protocol implementers."""
+ k_self, k_nbr = _one_tap_factors()
+ edge_index = _path_edges(_N_NODES)
+ one_tap = MatrixFreeGraphLinearOperator(
+ k_self,
+ k_nbr,
+ edge_index=edge_index,
+ num_nodes=_N_NODES,
+ )
+ poly = PolynomialGraphLinearOperator(
+ (k_self, k_nbr, 0.01 * torch.eye(2, dtype=torch.float64)),
+ edge_index=edge_index,
+ num_nodes=_N_NODES,
+ )
+ assert isinstance(one_tap, LinearOperatorProtocol)
+ assert isinstance(poly, LinearOperatorProtocol)
+
+
+def test_matrix_free_wrapper_matches_apply_and_dense() -> None:
+ """One-tap wrapper matches ``apply_k_eff_graph`` and dense ``K @ x``."""
+ torch.manual_seed(_SEED)
+ k_self, k_nbr = _one_tap_factors()
+ edge_index = _path_edges(_N_NODES)
+ operator = MatrixFreeGraphLinearOperator(
+ k_self,
+ k_nbr,
+ edge_index=edge_index,
+ num_nodes=_N_NODES,
+ )
+ flat = flatten_node_latents(torch.randn(_N_NODES, _LATENT_DIM, dtype=torch.float64))
+ expected = apply_k_eff_graph(
+ flat,
+ k_self=k_self,
+ k_nbr=k_nbr,
+ edge_index=edge_index,
+ num_nodes=_N_NODES,
+ )
+ matrix = operator.dense_matrix()
+ dense = matrix @ flat
+ got = operator.matvec(flat)
+ assert torch.allclose(got, expected, atol=_ATOL)
+ assert torch.allclose(got, dense, atol=_ATOL)
+ assert torch.allclose(operator.rmatvec(flat), matrix.T @ flat, atol=_ATOL)
+
+
+def test_polynomial_wrapper_matches_dense_kronecker() -> None:
+ """Degree-2 polynomial matvec matches assembled Kronecker ``K_eff``."""
+ torch.manual_seed(_SEED)
+ k_self, k_nbr = _one_tap_factors()
+ k_two = torch.tensor([[0.01, 0.0], [0.0, 0.008]], dtype=torch.float64)
+ edge_index = _path_edges(_N_NODES)
+ hops = (k_self, k_nbr, k_two)
+ operator = PolynomialGraphLinearOperator(
+ hops,
+ edge_index=edge_index,
+ num_nodes=_N_NODES,
+ )
+ adjacency = dense_symmetric_normalized_adjacency(
+ edge_index,
+ _N_NODES,
+ dtype=k_self.dtype,
+ )
+ dense = dense_polynomial_kronecker(adjacency, hops)
+ flat = flatten_node_latents(torch.randn(_N_NODES, _LATENT_DIM, dtype=torch.float64))
+ assert torch.allclose(operator.matvec(flat), dense @ flat, atol=_ATOL)
+ assert torch.allclose(operator.rmatvec(flat), dense.T @ flat, atol=_ATOL)
+
+
+def test_solve_and_residual_on_both_wrappers() -> None:
+ """Richardson solve recovers ``x``; residual stays under ``1e-6``."""
+ torch.manual_seed(_SEED)
+ k_self, k_nbr = _one_tap_factors()
+ edge_index = _path_edges(_N_NODES)
+ x_true = flatten_node_latents(
+ torch.randn(_N_NODES, _LATENT_DIM, dtype=torch.float64)
+ )
+ one_tap = MatrixFreeGraphLinearOperator(
+ k_self,
+ k_nbr,
+ edge_index=edge_index,
+ num_nodes=_N_NODES,
+ )
+ poly = PolynomialGraphLinearOperator(
+ (k_self, k_nbr, 0.01 * torch.eye(2, dtype=torch.float64)),
+ edge_index=edge_index,
+ num_nodes=_N_NODES,
+ )
+ for operator in (one_tap, poly):
+ rhs = operator.matvec(x_true)
+ solved = operator.solve(rhs, tol=_RESIDUAL_ATOL, max_iters=64)
+ assert torch.allclose(solved, x_true, atol=_ATOL)
+ residual = float(operator.residual_norm(solved, rhs).item())
+ # Richardson stops on a relative residual; absolute norm may sit
+ # just above ``1e-6`` on this modest path (same ``1e-5`` floor as
+ # ``tests/operators/test_matrix_free.py``).
+ assert residual == pytest.approx(0.0, abs=_ATOL)
+
+
+def test_expm_action_matches_dense_matrix_exp() -> None:
+ """Taylor ``exp(t A) b`` matches a dense matrix exponential on a toy."""
+ torch.manual_seed(_SEED)
+ k_self, k_nbr = _one_tap_factors()
+ edge_index = _path_edges(_N_NODES)
+ operator = MatrixFreeGraphLinearOperator(
+ k_self,
+ k_nbr,
+ edge_index=edge_index,
+ num_nodes=_N_NODES,
+ )
+ flat = flatten_node_latents(torch.randn(_N_NODES, _LATENT_DIM, dtype=torch.float64))
+ time = 0.1
+ dense = torch.linalg.matrix_exp(time * operator.dense_matrix()) @ flat
+ got = operator.expm_action(time, flat)
+ assert torch.allclose(got, dense, atol=_ATOL)
+
+
+def test_leading_eigpairs_report_residuals() -> None:
+ """Arnoldi residuals stay under the requested teaching tolerance."""
+ k_self, k_nbr = _one_tap_factors()
+ edge_index = _path_edges(_N_NODES)
+ operator = MatrixFreeGraphLinearOperator(
+ k_self,
+ k_nbr,
+ edge_index=edge_index,
+ num_nodes=_N_NODES,
+ )
+ result = operator.leading_eigpairs(2, tol=_SPECTRUM_ATOL)
+ assert result.converged
+ assert result.eigenvalues.shape == (2,)
+ assert bool((result.residual_norms <= _SPECTRUM_ATOL + 1e-12).all().item())
+
+
+def test_dense_assembly_refused_above_ceiling() -> None:
+ """``N·d`` above the teaching ceiling refuses dense assembly."""
+ latent_dim = 64
+ num_nodes = (MAX_DENSE_LINEAR_OPERATOR_SIZE // latent_dim) + 1
+ assert num_nodes * latent_dim > MAX_DENSE_LINEAR_OPERATOR_SIZE
+ k_self = 0.5 * torch.eye(latent_dim)
+ k_nbr = 0.01 * torch.eye(latent_dim)
+ operator = MatrixFreeGraphLinearOperator(
+ k_self,
+ k_nbr,
+ edge_index=_path_edges(num_nodes),
+ num_nodes=num_nodes,
+ )
+ assert operator.memory_estimate.dense_allowed is False
+ assert "trainer DDP" in operator.memory_estimate.notes
+ with pytest.raises(ValueError, match="dense assembly refused"):
+ operator.dense_matrix()
+
+
+def test_polynomial_wrapper_rejects_one_tap() -> None:
+ """A two-factor bank is the one-tap wrapper, not the polynomial class."""
+ k_self, k_nbr = _one_tap_factors()
+ with pytest.raises(ValueError, match="P>=2"):
+ PolynomialGraphLinearOperator(
+ (k_self, k_nbr),
+ edge_index=_path_edges(_N_NODES),
+ num_nodes=_N_NODES,
+ )
diff --git a/tests/operators/test_parametric.py b/tests/operators/test_parametric.py
new file mode 100644
index 0000000..ada114a
--- /dev/null
+++ b/tests/operators/test_parametric.py
@@ -0,0 +1,259 @@
+"""Tests for the discrete parametric interpolant :math:`K(\\mu)`."""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+import pytest
+import torch
+
+from koopman_graph.model import GraphKoopmanModel
+from koopman_graph.nn import GNNDecoder, GNNEncoder
+from koopman_graph.operators import (
+ LeaveOneRegimeOutReport,
+ ParametricKoopmanOperator,
+ leave_one_regime_out,
+)
+from koopman_graph.operators.parametric import INTERPOLANT_SAFE_PARAMETERIZATIONS
+
+
+def _true_k(mu: float, k0: torch.Tensor, k1: torch.Tensor) -> torch.Tensor:
+ """Affine interpolant :math:`(1-\\mu)K_0 + \\mu K_1`.
+
+ Parameters
+ ----------
+ mu : float
+ Scalar regime coordinate in ``[0, 1]``.
+ k0, k1 : Tensor
+ Endpoint maps with shape ``(d, d)``.
+
+ Returns
+ -------
+ Tensor
+ Mixed map.
+ """
+ return (1.0 - mu) * k0 + mu * k1
+
+
+def test_parametric_rbf_midpoint_is_equal_mix() -> None:
+ """RBF weights at the midpoint of two unit-interval anchors are 1/2, 1/2."""
+ operator = ParametricKoopmanOperator(3, num_modes=2, parameter_dim=1)
+ k0 = 0.9 * torch.eye(3)
+ k1 = torch.diag(torch.tensor([0.2, 0.4, 0.6]))
+ operator.modes[0].set_dense_matrix(k0)
+ operator.modes[1].set_dense_matrix(k1)
+ operator.set_anchors(torch.tensor([[0.0], [1.0]]))
+ weights = operator.interpolation_weights(torch.tensor([0.5])).detach()
+ expected_weights = torch.tensor([0.5, 0.5])
+ torch.testing.assert_close(weights, expected_weights, rtol=0.0, atol=1e-6)
+ mixed = operator.effective_matrix(torch.tensor([0.5]))
+ expected = 0.5 * (k0 + k1)
+ torch.testing.assert_close(mixed, expected, rtol=1e-6, atol=1e-8)
+
+
+def test_parametric_simplex_weights_are_a_simplex() -> None:
+ """Affine-softmax weights are nonnegative and sum to one."""
+ operator = ParametricKoopmanOperator(
+ 2, num_modes=3, parameter_dim=2, weight_kind="simplex"
+ )
+ weights = operator.interpolation_weights(torch.tensor([0.3, -0.2]))
+ assert torch.all(weights.detach() >= 0)
+ assert float(weights.detach().sum()) == pytest.approx(1.0, rel=0.0, abs=1e-6)
+
+
+def test_parametric_refuses_symplectic_mix() -> None:
+ """Convex combination would silently drop the symplectic constraint."""
+ with pytest.raises(ValueError, match="symplectic"):
+ ParametricKoopmanOperator(4, parameterization="symplectic")
+ for name in ("odo", "schur", "dissipative", "lyapunov", "auxiliary_spectral"):
+ with pytest.raises(ValueError, match=name):
+ ParametricKoopmanOperator(3, parameterization=name) # type: ignore[arg-type]
+ assert "dense" in INTERPOLANT_SAFE_PARAMETERIZATIONS
+ _ = ParametricKoopmanOperator(3, parameterization="row_stochastic")
+ _ = ParametricKoopmanOperator(3, parameterization="doubly_stochastic")
+
+
+def test_parametric_requires_parameters() -> None:
+ """Advance without μ raises rather than falling back to a latent gate."""
+ operator = ParametricKoopmanOperator(2)
+ z = torch.randn(4, 2)
+ with pytest.raises(ValueError, match="requires parameters"):
+ operator.advance(z)
+ operator.set_parameters(torch.tensor([0.25]))
+ assert operator.advance(z).shape == z.shape
+ assert operator.inverse_advance(z).shape == z.shape
+ assert operator(z, parameters=torch.tensor([0.1])).shape == z.shape
+ with pytest.raises(ValueError, match="parameters must have shape"):
+ operator.advance(z, parameters=torch.zeros(2))
+ mixed = operator.matrix
+ assert mixed.shape == (2, 2)
+ assert operator.bound_metric().ndim == 0
+
+
+def test_leave_one_regime_out_beats_pooled_lti() -> None:
+ """Hold-out interior μ: interpolant one-step MSE is below pooled LTI.
+
+ Truth is the affine mix :math:`K(\\mu)=(1-\\mu)K_0+\\mu K_1`. Train
+ regimes are unbalanced (many pairs at μ=0, few at μ=1) so pooled
+ least squares is biased toward :math:`K_0`. The held-out regime is
+ μ=0.5. Synthetic oracle; rel/abs from noiseless construction.
+ """
+ torch.manual_seed(0)
+ dim = 3
+ k0 = 0.85 * torch.eye(dim)
+ k1 = torch.tensor(
+ [[0.15, 0.40, 0.00], [0.00, 0.25, 0.45], [0.10, 0.00, 0.55]],
+ dtype=torch.float32,
+ )
+
+ def _pairs(mu: float, n_pairs: int) -> tuple[torch.Tensor, torch.Tensor]:
+ z = torch.randn(n_pairs, dim)
+ k_mu = _true_k(mu, k0, k1)
+ return z, z @ k_mu.T
+
+ z0, y0 = _pairs(0.0, 64)
+ z1, y1 = _pairs(1.0, 8)
+ z_h, y_h = _pairs(0.5, 32)
+ report = leave_one_regime_out(
+ [
+ (torch.tensor([0.0]), z0, y0),
+ (torch.tensor([1.0]), z1, y1),
+ (torch.tensor([0.5]), z_h, y_h),
+ ],
+ hold_out=2,
+ ridge=1e-6,
+ )
+ assert isinstance(report, LeaveOneRegimeOutReport)
+ assert report.n_train_pairs == 72
+ assert report.n_holdout_pairs == 32
+ assert report.interpolant_mse < report.pooled_lti_mse
+ # Noiseless affine interpolant at the midpoint of two anchors.
+ assert report.interpolant_mse == pytest.approx(0.0, rel=0.0, abs=1e-5)
+
+
+def test_factory_builds_parametric_kind() -> None:
+ """``koopman='parametric'`` constructs the interpolant, not a refusal."""
+ model = GraphKoopmanModel(
+ GNNEncoder(2, 4, 2),
+ GNNDecoder(2, 4, 2),
+ latent_dim=2,
+ time_step=0.1,
+ koopman="parametric",
+ koopman_parameter_dim=1,
+ koopman_num_modes=2,
+ koopman_weight_kind="rbf",
+ )
+ assert model.koopman_kind == "parametric"
+ assert isinstance(model.koopman, ParametricKoopmanOperator)
+ assert model.koopman.parameter_dim == 1
+ with pytest.raises(ValueError, match="koopman_parameter_dim"):
+ GraphKoopmanModel(
+ GNNEncoder(2, 4, 2),
+ GNNDecoder(2, 4, 2),
+ latent_dim=2,
+ time_step=0.1,
+ koopman="pernode",
+ koopman_parameter_dim=3,
+ )
+ with pytest.raises(ValueError, match="dynamics_mode='discrete'"):
+ GraphKoopmanModel(
+ GNNEncoder(2, 4, 2),
+ GNNDecoder(2, 4, 2),
+ latent_dim=2,
+ time_step=0.1,
+ koopman="parametric",
+ dynamics_mode="continuous",
+ )
+ with pytest.raises(ValueError, match="stochastic"):
+ GraphKoopmanModel(
+ GNNEncoder(2, 4, 2),
+ GNNDecoder(2, 4, 2),
+ latent_dim=2,
+ time_step=0.1,
+ koopman="parametric",
+ dynamics_mode="stochastic",
+ )
+
+
+def test_parametric_module_does_not_import_model_or_data() -> None:
+ """L2 interpolant must not import L4 model or L1 data."""
+ source = Path(__file__).resolve().parents[2] / (
+ "src/koopman_graph/operators/parametric.py"
+ )
+ tree = ast.parse(source.read_text(encoding="utf-8"))
+ imported: list[str] = []
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ imported.extend(alias.name for alias in node.names)
+ elif isinstance(node, ast.ImportFrom) and node.module is not None:
+ imported.append(node.module)
+ forbidden = ("koopman_graph.model", "koopman_graph.data")
+ offenders = [
+ name
+ for name in imported
+ if any(name == prefix or name.startswith(f"{prefix}.") for prefix in forbidden)
+ ]
+ assert not offenders
+
+
+def test_parametric_init_and_helper_guards() -> None:
+ """Constructor and leave-one-out helpers reject invalid inputs."""
+ with pytest.raises(ValueError, match="num_modes"):
+ ParametricKoopmanOperator(2, num_modes=0)
+ with pytest.raises(ValueError, match="parameter_dim"):
+ ParametricKoopmanOperator(2, parameter_dim=0)
+ with pytest.raises(ValueError, match="weight_kind"):
+ ParametricKoopmanOperator(2, weight_kind="barycentric") # type: ignore[arg-type]
+ operator = ParametricKoopmanOperator(2)
+ with pytest.raises(ValueError, match="anchors"):
+ operator.set_anchors(torch.zeros(3, 1))
+ with pytest.raises(ValueError, match="lengthscale"):
+ operator.set_lengthscale(0.0)
+ with pytest.raises(ValueError, match="at least 2 regimes"):
+ leave_one_regime_out([], hold_out=0)
+ z = torch.eye(2)
+ with pytest.raises(ValueError, match="hold_out"):
+ leave_one_regime_out(
+ [
+ (torch.tensor([0.0]), z, z),
+ (torch.tensor([1.0]), z, z),
+ ],
+ hold_out=9,
+ )
+ with pytest.raises(ValueError, match="ridge"):
+ leave_one_regime_out(
+ [
+ (torch.tensor([0.0]), z, z),
+ (torch.tensor([1.0]), z, z),
+ ],
+ hold_out=0,
+ ridge=-1.0,
+ )
+ with pytest.raises(ValueError, match="mu shape"):
+ leave_one_regime_out(
+ [
+ (torch.tensor([0.0]), z, z),
+ (torch.tensor([1.0, 0.0]), z, z),
+ ],
+ hold_out=0,
+ )
+
+
+def test_parametric_forward_uses_mu() -> None:
+ """Homogeneous forward threads μ into the interpolant."""
+ model = GraphKoopmanModel(
+ GNNEncoder(2, 4, 2, num_layers=1),
+ GNNDecoder(2, 4, 2, num_layers=1),
+ latent_dim=2,
+ time_step=0.1,
+ koopman="parametric",
+ )
+ from torch_geometric.data import Data
+
+ graph = Data(
+ x=torch.randn(3, 2),
+ edge_index=torch.tensor([[0, 1], [1, 2]], dtype=torch.long),
+ )
+ out = model(graph, parameters=torch.tensor([0.4]))
+ assert out.shape == graph.x.shape
diff --git a/tests/operators/test_polynomial_graph.py b/tests/operators/test_polynomial_graph.py
new file mode 100644
index 0000000..8527ea4
--- /dev/null
+++ b/tests/operators/test_polynomial_graph.py
@@ -0,0 +1,449 @@
+"""Tests for monomial ``filter_degree`` on discrete graph operators."""
+
+from __future__ import annotations
+
+import tempfile
+from pathlib import Path
+
+import pytest
+import torch
+from torch_geometric.data import Data
+
+from koopman_graph import (
+ GNNDecoder,
+ GNNEncoder,
+ GraphKoopmanModel,
+ GraphKoopmanOperator,
+ KoopmanOperator,
+)
+from koopman_graph.graph_utils.topology import (
+ dense_random_walk_normalized_adjacency,
+ dense_symmetric_normalized_adjacency,
+)
+from koopman_graph.operators.polynomial_graph import (
+ apply_monomial_powers,
+ dense_polynomial_kronecker,
+ validate_filter_degree,
+)
+from koopman_graph.serialization import (
+ FORMAT_VERSION,
+ build_model_config,
+ load_checkpoint,
+)
+
+# Independent Kronecker oracle vs operator forward (float64).
+_POLY_RTOL = 1e-6
+_POLY_ATOL = 1e-8
+
+
+def _path_edge_index(num_nodes: int) -> torch.Tensor:
+ """Build an undirected path graph edge index."""
+ edges: list[list[int]] = []
+ for node in range(num_nodes - 1):
+ edges.extend([[node, node + 1], [node + 1, node]])
+ return torch.tensor(edges, dtype=torch.long).t().contiguous()
+
+
+def _tiny_graph_model(**kwargs: object) -> GraphKoopmanModel:
+ """Build a small discrete graph model for factory tests."""
+ encoder = GNNEncoder(2, 4, 3, num_layers=1)
+ decoder = GNNDecoder(3, 4, 2, num_layers=1)
+ return GraphKoopmanModel(
+ encoder,
+ decoder,
+ latent_dim=3,
+ time_step=0.1,
+ koopman="graph",
+ koopman_init_mode="identity",
+ **kwargs, # type: ignore[arg-type]
+ )
+
+
+def test_validate_filter_degree_rejects_invalid_values() -> None:
+ """Non-integers, booleans, and negative degrees raise ``ValueError``."""
+ with pytest.raises(ValueError, match="must be >= 0"):
+ validate_filter_degree(-1)
+ with pytest.raises(ValueError, match="non-negative int"):
+ validate_filter_degree(True)
+ with pytest.raises(ValueError, match="non-negative int"):
+ validate_filter_degree(1.5)
+ assert validate_filter_degree(0) == 0
+ assert validate_filter_degree(2) == 2
+
+
+def test_dense_polynomial_kronecker_matches_explicit_sum() -> None:
+ """Helper Kronecker sum matches an explicit ``I, A, A^2`` construction."""
+ adjacency = torch.tensor(
+ [[0.0, 1.0, 0.0], [1.0, 0.0, 1.0], [0.0, 1.0, 0.0]],
+ dtype=torch.float64,
+ )
+ identity = torch.eye(3, dtype=torch.float64)
+ k0 = torch.tensor([[0.5, 0.1], [0.0, 0.4]], dtype=torch.float64)
+ k1 = torch.tensor([[0.2, 0.0], [0.1, 0.3]], dtype=torch.float64)
+ k2 = torch.tensor([[0.05, 0.02], [-0.01, 0.04]], dtype=torch.float64)
+ assembled = dense_polynomial_kronecker(adjacency, (k0, k1, k2), start_power=0)
+ expected = (
+ torch.kron(identity, k0)
+ + torch.kron(adjacency, k1)
+ + torch.kron(adjacency @ adjacency, k2)
+ )
+ assert torch.allclose(assembled, expected, rtol=_POLY_RTOL, atol=_POLY_ATOL)
+
+
+def test_dense_polynomial_kronecker_rejects_empty_or_invalid() -> None:
+ """Assembly helper rejects empty banks and non-square adjacency."""
+ adjacency = torch.eye(2)
+ with pytest.raises(ValueError, match="non-empty"):
+ dense_polynomial_kronecker(adjacency, ())
+ with pytest.raises(ValueError, match="start_power"):
+ dense_polynomial_kronecker(adjacency, (torch.eye(2),), start_power=-1)
+ with pytest.raises(ValueError, match="square 2-D"):
+ dense_polynomial_kronecker(torch.ones(2, 3), (torch.eye(2),))
+ """Repeated matvec matches ``A^k Z @ K_k.T`` for ``k = 1, 2``."""
+ adjacency = torch.tensor([[0.0, 0.5], [0.5, 0.0]], dtype=torch.float64)
+ states = torch.tensor([[1.0, -0.5], [0.25, 0.75]], dtype=torch.float64)
+ k1 = torch.tensor([[0.2, 0.1], [0.0, 0.3]], dtype=torch.float64)
+ k2 = torch.tensor([[0.05, 0.0], [0.02, 0.04]], dtype=torch.float64)
+ got = apply_monomial_powers(states, (k1, k2), adjacency.matmul, min_power=1)
+ hop1 = adjacency @ states
+ hop2 = adjacency @ hop1
+ expected = hop1 @ k1.T + hop2 @ k2.T
+ assert torch.allclose(got, expected, rtol=_POLY_RTOL, atol=_POLY_ATOL)
+
+
+def test_default_filter_degree_is_one_and_state_dict_omits_hop_modules() -> None:
+ """Default ``P=1`` matches an explicit ``filter_degree=1`` operator."""
+ torch.manual_seed(0)
+ implicit = GraphKoopmanOperator(3, init_mode="identity_noise", init_scale=0.05)
+ torch.manual_seed(0)
+ explicit = GraphKoopmanOperator(
+ 3,
+ init_mode="identity_noise",
+ init_scale=0.05,
+ filter_degree=1,
+ )
+ assert implicit.filter_degree == 1
+ assert implicit.receptive_field_hops() == 1
+ assert set(implicit.state_dict()) == set(explicit.state_dict())
+ for key, value in implicit.state_dict().items():
+ assert torch.equal(value, explicit.state_dict()[key])
+ assert not any("hop_factors" in key for key in implicit.state_dict())
+ edge_index = _path_edge_index(4)
+ latents = torch.randn(4, 3)
+ assert torch.equal(implicit(latents, edge_index), explicit(latents, edge_index))
+
+
+def test_filter_degree_zero_matches_pernode_even_with_nonzero_neighbor() -> None:
+ """``P=0`` ignores ``K_nbr`` and matches a per-node self map."""
+ k_self = torch.tensor([[0.7, 0.1], [0.0, 0.4]])
+ k_nbr = torch.tensor([[9.0, 9.0], [9.0, 9.0]])
+ graph_op = GraphKoopmanOperator(2, init_mode="identity", filter_degree=0)
+ graph_op.set_dense_matrices(k_self, k_nbr)
+ pernode = KoopmanOperator(2, init_mode="identity")
+ pernode.set_dense_matrix(k_self)
+ edge_index = _path_edge_index(3)
+ latents = torch.randn(3, 2)
+ assert torch.allclose(graph_op(latents, edge_index), pernode(latents), atol=1e-6)
+
+
+def test_filter_degree_two_forward_matches_independent_kronecker() -> None:
+ """``P=2`` sparse forward matches dense ``sum_k A^k ⊗ K_k`` (float64)."""
+ num_nodes = 3
+ latent_dim = 2
+ edge_index = _path_edge_index(num_nodes)
+ k0 = torch.tensor([[0.6, 0.05], [-0.02, 0.55]], dtype=torch.float64)
+ k1 = torch.tensor([[0.12, 0.03], [0.04, 0.08]], dtype=torch.float64)
+ k2 = torch.tensor([[0.03, -0.01], [0.02, 0.05]], dtype=torch.float64)
+ operator = GraphKoopmanOperator(latent_dim, init_mode="identity", filter_degree=2)
+ operator = operator.double()
+ operator.set_dense_matrices(k0, k1, hop_matrices=(k2,))
+ latents = torch.tensor(
+ [[0.4, -0.2], [0.1, 0.3], [-0.5, 0.2]],
+ dtype=torch.float64,
+ )
+ got = operator(latents, edge_index)
+ adjacency = dense_symmetric_normalized_adjacency(
+ edge_index,
+ num_nodes,
+ dtype=torch.float64,
+ )
+ identity = torch.eye(num_nodes, dtype=torch.float64)
+ effective = (
+ torch.kron(identity, k0)
+ + torch.kron(adjacency, k1)
+ + torch.kron(adjacency @ adjacency, k2)
+ )
+ expected = (effective @ latents.reshape(-1)).view_as(latents)
+ assert torch.allclose(got, expected, rtol=_POLY_RTOL, atol=_POLY_ATOL)
+ assembled = operator.effective_matrix(edge_index, num_nodes)
+ assert torch.allclose(assembled, effective, rtol=_POLY_RTOL, atol=_POLY_ATOL)
+
+
+def test_filter_degree_two_dual_matches_independent_kronecker() -> None:
+ """Dual ``P=2`` uses independent powers of each random-walk shift."""
+ num_nodes = 3
+ latent_dim = 2
+ edge_index = torch.tensor([[0, 1, 1], [1, 2, 0]], dtype=torch.long)
+ k0 = torch.tensor([[0.5, 0.0], [0.1, 0.4]], dtype=torch.float64)
+ k1 = torch.tensor([[0.1, 0.02], [0.0, 0.08]], dtype=torch.float64)
+ k2 = torch.tensor([[0.04, 0.01], [-0.01, 0.03]], dtype=torch.float64)
+ k_bwd = torch.tensor([[0.07, 0.0], [0.02, 0.06]], dtype=torch.float64)
+ k_bwd2 = torch.tensor([[0.02, 0.0], [0.01, 0.03]], dtype=torch.float64)
+ operator = GraphKoopmanOperator(
+ latent_dim,
+ init_mode="identity",
+ filter_degree=2,
+ adjacency="dual_random_walk",
+ ).double()
+ operator.set_dense_matrices(
+ k0,
+ k1,
+ k_bwd=k_bwd,
+ hop_matrices=(k2,),
+ bwd_hop_matrices=(k_bwd2,),
+ )
+ latents = torch.randn(num_nodes, latent_dim, dtype=torch.float64)
+ got = operator(latents, edge_index)
+ adj_fwd = dense_random_walk_normalized_adjacency(
+ edge_index,
+ num_nodes,
+ dtype=torch.float64,
+ direction="forward",
+ )
+ adj_bwd = dense_random_walk_normalized_adjacency(
+ edge_index,
+ num_nodes,
+ dtype=torch.float64,
+ direction="backward",
+ )
+ identity = torch.eye(num_nodes, dtype=torch.float64)
+ effective = (
+ torch.kron(identity, k0)
+ + torch.kron(adj_fwd, k1)
+ + torch.kron(adj_fwd @ adj_fwd, k2)
+ + torch.kron(adj_bwd, k_bwd)
+ + torch.kron(adj_bwd @ adj_bwd, k_bwd2)
+ )
+ expected = (effective @ latents.reshape(-1)).view_as(latents)
+ assert torch.allclose(got, expected, rtol=_POLY_RTOL, atol=_POLY_ATOL)
+
+
+def test_filter_degree_two_dense_inverse_round_trip() -> None:
+ """Dense inverse recovers the preimage for a well-conditioned ``P=2`` map."""
+ num_nodes = 3
+ latent_dim = 2
+ edge_index = _path_edge_index(num_nodes)
+ operator = GraphKoopmanOperator(latent_dim, init_mode="identity", filter_degree=2)
+ operator = operator.double()
+ identity = torch.eye(latent_dim, dtype=torch.float64)
+ operator.set_dense_matrices(
+ 0.5 * identity,
+ 0.1 * identity,
+ hop_matrices=(0.05 * identity,),
+ )
+ latents = torch.randn(num_nodes, latent_dim, dtype=torch.float64)
+ advanced = operator(latents, edge_index)
+ recovered = operator.inverse_advance(advanced, edge_index=edge_index)
+ assert torch.allclose(recovered, latents, rtol=_POLY_RTOL, atol=_POLY_ATOL)
+
+
+@pytest.mark.parametrize("filter_degree", [0, 2])
+def test_non_unit_filter_degree_uses_kronecker_spectrum_helper(
+ monkeypatch: pytest.MonkeyPatch,
+ filter_degree: int,
+) -> None:
+ """Eligible ``P!=1`` spectrum routes through the polynomial Kronecker helper."""
+ from koopman_graph.operators import graph as graph_mod
+
+ calls: list[object] = []
+ original = graph_mod.spectrum_k_eff_kronecker_polynomial
+
+ def _spy(**kwargs: object) -> object:
+ calls.append(kwargs)
+ return original(**kwargs)
+
+ monkeypatch.setattr(graph_mod, "spectrum_k_eff_kronecker_polynomial", _spy)
+ edge_index = _path_edge_index(3)
+ operator = GraphKoopmanOperator(
+ 2, init_mode="identity", filter_degree=filter_degree
+ )
+ if filter_degree == 2:
+ identity = torch.eye(2)
+ operator.set_dense_matrices(
+ 0.5 * identity,
+ 0.1 * identity,
+ hop_matrices=(0.05 * identity,),
+ )
+ spectrum = operator.spectrum(edge_index, 3, time_step=0.1)
+ assert len(calls) == 1
+ effective = operator.effective_matrix(edge_index, 3)
+ eigvals = torch.linalg.eigvals(effective)
+ assert torch.allclose(
+ spectrum.magnitudes.sort().values,
+ eigvals.abs().sort().values,
+ atol=1e-5,
+ )
+
+
+def test_filter_degree_two_block_diagonal_uses_kronecker(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Eligible block-diagonal ``P=2`` spectrum still Kronecker-routes."""
+ from koopman_graph.operators import graph as graph_mod
+
+ calls: list[int] = []
+ original = graph_mod.spectrum_k_eff_kronecker_polynomial
+
+ def _spy(**kwargs: object) -> object:
+ calls.append(1)
+ return original(**kwargs)
+
+ monkeypatch.setattr(graph_mod, "spectrum_k_eff_kronecker_polynomial", _spy)
+ edge_index = _path_edge_index(3)
+ operator = GraphKoopmanOperator(
+ 2,
+ init_mode="identity",
+ filter_degree=2,
+ sparsity="block_diagonal",
+ )
+ spectrum = operator.spectrum(edge_index, 3, time_step=0.1)
+ assert len(calls) == 1
+ oracle = torch.linalg.eigvals(operator.effective_matrix(edge_index, 3))
+ assert torch.allclose(
+ spectrum.magnitudes.sort().values,
+ oracle.abs().sort().values,
+ atol=1e-5,
+ )
+
+
+def test_non_unit_filter_degree_refuses_block_diagonal_and_distributed() -> None:
+ """One-hop inverse / Arnoldi paths refuse ``P!=1``."""
+ edge_index = _path_edge_index(3)
+ latents = torch.randn(3, 2)
+ block = GraphKoopmanOperator(
+ 2,
+ init_mode="identity",
+ filter_degree=2,
+ sparsity="block_diagonal",
+ )
+ with pytest.raises(ValueError, match="filter_degree=1"):
+ block.inverse_advance(latents, edge_index=edge_index)
+ distributed = GraphKoopmanOperator(
+ 2,
+ init_mode="identity",
+ filter_degree=2,
+ sparsity="distributed",
+ )
+ with pytest.raises(ValueError, match="filter_degree=1"):
+ distributed.inverse_advance(latents, edge_index=edge_index)
+ with pytest.raises(ValueError, match="filter_degree=1"):
+ distributed.spectrum(edge_index, 3)
+
+
+def test_factory_default_matches_explicit_filter_degree_one() -> None:
+ """Omitting ``koopman_filter_degree`` matches an explicit ``1``."""
+ torch.manual_seed(0)
+ implicit = _tiny_graph_model()
+ torch.manual_seed(0)
+ explicit = _tiny_graph_model(koopman_filter_degree=1)
+ assert implicit.koopman.filter_degree == 1
+ assert set(implicit.koopman.state_dict()) == set(explicit.koopman.state_dict())
+ for key, value in implicit.koopman.state_dict().items():
+ assert torch.equal(value, explicit.koopman.state_dict()[key])
+
+
+def test_factory_rejects_filter_degree_for_non_discrete_graph() -> None:
+ """Non-default hop degree is rejected off the discrete graph kind."""
+ with pytest.raises(ValueError, match="only meaningful for discrete"):
+ GraphKoopmanModel(
+ encoder=GNNEncoder(2, 4, 3, num_layers=1),
+ decoder=GNNDecoder(3, 4, 2, num_layers=1),
+ latent_dim=3,
+ time_step=0.1,
+ koopman="pernode",
+ koopman_filter_degree=2,
+ )
+ with pytest.raises(ValueError, match="only meaningful for discrete"):
+ GraphKoopmanModel(
+ encoder=GNNEncoder(2, 4, 3, num_layers=1),
+ decoder=GNNDecoder(3, 4, 2, num_layers=1),
+ latent_dim=3,
+ time_step=0.1,
+ dynamics_mode="continuous",
+ koopman="graph",
+ koopman_filter_degree=2,
+ )
+
+
+def test_factory_filter_degree_two_allocates_hop_modules() -> None:
+ """Factory ``koopman_filter_degree=2`` builds extra hop parameters."""
+ model = _tiny_graph_model(koopman_filter_degree=2)
+ assert model.koopman.filter_degree == 2
+ assert model.koopman.receptive_field_hops() == 2
+ assert any("_hop_factors." in key for key in model.koopman.state_dict())
+
+
+def test_checkpoint_without_filter_degree_loads_as_one(tmp_path: Path) -> None:
+ """Additive ``filter_degree``: absent key reconstructs ``P=1``."""
+ model = _tiny_graph_model()
+ config = build_model_config(model)
+ assert config["filter_degree"] == 1
+ assert FORMAT_VERSION == 1
+ del config["filter_degree"]
+ path = tmp_path / "missing_filter_degree.pt"
+ torch.save(
+ {
+ "format_version": 1,
+ "package_version": "0.14.0",
+ "config": config,
+ "state_dict": model.state_dict(),
+ },
+ path,
+ )
+ loaded = load_checkpoint(path)
+ assert loaded.koopman.filter_degree == 1
+ edge_index = _path_edge_index(3)
+ data_x = torch.randn(3, 2)
+ snapshot = Data(x=data_x, edge_index=edge_index)
+ model.eval()
+ loaded.eval()
+ with torch.no_grad():
+ before = model.predict(snapshot, steps=1)
+ after = loaded.predict(snapshot, steps=1)
+ assert torch.allclose(before[0].x, after[0].x, atol=1e-5)
+
+
+def test_filter_degree_two_checkpoint_round_trip() -> None:
+ """``P=2`` save/load preserves hop factors and predictions."""
+ model = _tiny_graph_model(koopman_filter_degree=2)
+ identity = torch.eye(3)
+ model.koopman.set_dense_matrices(
+ 0.6 * identity,
+ 0.1 * identity,
+ hop_matrices=(0.05 * identity,),
+ )
+ edge_index = _path_edge_index(3)
+ snapshot = Data(x=torch.randn(3, 2), edge_index=edge_index)
+ model.eval()
+ with torch.no_grad():
+ before = model.predict(snapshot, steps=2)
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "poly_graph.pt"
+ model.save(path)
+ loaded = GraphKoopmanModel.load(path)
+ assert loaded.koopman.filter_degree == 2
+ for key, value in model.koopman.state_dict().items():
+ assert torch.allclose(value, loaded.koopman.state_dict()[key])
+ loaded.eval()
+ with torch.no_grad():
+ after = loaded.predict(snapshot, steps=2)
+ for left, right in zip(before, after, strict=True):
+ assert torch.allclose(left.x, right.x, atol=1e-5)
+
+
+def test_operator_rejects_invalid_filter_degree() -> None:
+ """Constructor validation matches the shared helper."""
+ with pytest.raises(ValueError, match="filter_degree"):
+ GraphKoopmanOperator(2, filter_degree=-1)
+ with pytest.raises(ValueError, match="filter_degree"):
+ GraphKoopmanOperator(2, filter_degree=True)
diff --git a/tests/operators/test_stochastic_sde.py b/tests/operators/test_stochastic_sde.py
new file mode 100644
index 0000000..fe10237
--- /dev/null
+++ b/tests/operators/test_stochastic_sde.py
@@ -0,0 +1,126 @@
+"""Tests for the opt-in drift–diffusion stepper."""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+import pytest
+import torch
+
+from koopman_graph.model import GraphKoopmanModel
+from koopman_graph.nn import GNNDecoder, GNNEncoder
+from koopman_graph.operators import DriftDiffusionKoopman
+from koopman_graph.operators.stochastic_sde import DEFAULT_DIFFUSION_RANK
+
+
+def test_euler_maruyama_mean_matches_affine_drift() -> None:
+ """EM conditional mean is z + Δt z Lᵀ."""
+ operator = DriftDiffusionKoopman(2, step_kind="euler_maruyama")
+ drift = torch.tensor([[-0.4, 0.1], [0.0, -0.5]])
+ operator.set_drift(drift)
+ z = torch.tensor([[1.0, -2.0], [0.5, 0.25]])
+ delta_t = 0.2
+ expected = z + delta_t * (z @ drift.transpose(-1, -2))
+ torch.testing.assert_close(operator.mean_advance(z, delta_t), expected)
+ torch.testing.assert_close(operator(z, delta_t), expected)
+
+
+def test_yosida_mean_matches_implicit_resolvent() -> None:
+ """Yosida mean solves (I − Δt L) z_nextᵀ = zᵀ."""
+ operator = DriftDiffusionKoopman(2, step_kind="yosida")
+ drift = -torch.eye(2)
+ operator.set_drift(drift)
+ z = torch.ones(3, 2)
+ delta_t = 0.1
+ left = torch.eye(2) - delta_t * drift
+ expected = torch.linalg.solve(left, z.transpose(-1, -2)).transpose(-1, -2)
+ torch.testing.assert_close(
+ operator.mean_advance(z, delta_t),
+ expected,
+ atol=1e-6,
+ rtol=1e-6,
+ )
+
+
+def test_sampled_paths_match_mean_and_diffusion_scale() -> None:
+ """Seeded EM samples recover the mean and Δt FFᵀ covariance."""
+ operator = DriftDiffusionKoopman(2, diffusion_rank=2)
+ operator.set_drift(-torch.eye(2))
+ factor = torch.diag(torch.tensor([0.4, 0.2]))
+ operator.set_diffusion(factor)
+ z = torch.zeros(1, 2)
+ delta_t = 0.25
+ mean = operator.mean_advance(z, delta_t)
+ draws: list[torch.Tensor] = []
+ for seed in range(4000):
+ generator = torch.Generator().manual_seed(seed)
+ draws.append(operator.advance(z, delta_t, generator=generator))
+ samples = torch.stack(draws, dim=0)
+ torch.testing.assert_close(samples.mean(dim=0), mean, atol=0.03, rtol=0.0)
+ expected_cov = delta_t * (factor @ factor.T)
+ centered = samples[:, 0, :] - mean[0]
+ empirical = centered.T @ centered / float(centered.shape[0] - 1)
+ torch.testing.assert_close(empirical, expected_cov, atol=0.03, rtol=0.15)
+
+
+def test_default_stochastic_factory_is_still_diagonal_noise() -> None:
+ """Factory dynamics_mode='stochastic' does not build the SDE stepper."""
+ model = GraphKoopmanModel(
+ GNNEncoder(2, 4, 2, num_layers=1),
+ GNNDecoder(2, 4, 2, num_layers=1),
+ latent_dim=2,
+ time_step=0.1,
+ dynamics_mode="stochastic",
+ )
+ assert not isinstance(model.koopman, DriftDiffusionKoopman)
+ assert getattr(model.koopman, "stochastic", False)
+ assert hasattr(model.koopman, "process_log_std")
+
+
+def test_drift_diffusion_guards() -> None:
+ """Constructor and setters refuse invalid ranks, kinds, and controls."""
+ with pytest.raises(ValueError, match="latent_dim"):
+ DriftDiffusionKoopman(0)
+ with pytest.raises(ValueError, match="diffusion_rank"):
+ DriftDiffusionKoopman(2, diffusion_rank=3)
+ with pytest.raises(ValueError, match="step_kind"):
+ DriftDiffusionKoopman(2, step_kind="milstein") # type: ignore[arg-type]
+ operator = DriftDiffusionKoopman(2, diffusion_rank=DEFAULT_DIFFUSION_RANK)
+ with pytest.raises(ValueError, match="drift"):
+ operator.set_drift(torch.zeros(2, 3))
+ with pytest.raises(ValueError, match="diffusion"):
+ operator.set_diffusion(torch.zeros(2, 2))
+ z = torch.ones(2, 2)
+ with pytest.raises(ValueError, match="delta_t"):
+ operator.advance(z)
+ with pytest.raises(ValueError, match="uncontrolled"):
+ operator.advance(z, 0.1, control=torch.ones(1))
+ with pytest.raises(ValueError, match="trailing"):
+ operator.mean_advance(torch.ones(2, 3), 0.1)
+ torch.testing.assert_close(operator.mean_advance(z, 0.0), z)
+
+
+def test_stochastic_sde_module_does_not_import_model_data_or_uq() -> None:
+ """L2 stepper must not import L4 model, L1 data, or L5 uq."""
+ source = Path(__file__).resolve().parents[2] / (
+ "src/koopman_graph/operators/stochastic_sde.py"
+ )
+ tree = ast.parse(source.read_text(encoding="utf-8"))
+ imported: list[str] = []
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ imported.extend(alias.name for alias in node.names)
+ elif isinstance(node, ast.ImportFrom) and node.module is not None:
+ imported.append(node.module)
+ forbidden = (
+ "koopman_graph.model",
+ "koopman_graph.data",
+ "koopman_graph.uq",
+ )
+ offenders = [
+ name
+ for name in imported
+ if any(name == prefix or name.startswith(f"{prefix}.") for prefix in forbidden)
+ ]
+ assert not offenders
diff --git a/tests/operators/test_switched_mixture.py b/tests/operators/test_switched_mixture.py
index 04ce84e..c85adbe 100644
--- a/tests/operators/test_switched_mixture.py
+++ b/tests/operators/test_switched_mixture.py
@@ -4,7 +4,11 @@
import pytest
import torch
+from torch_geometric.data import Data
+from koopman_graph.graph_utils import propagate_latent
+from koopman_graph.model import GraphKoopmanModel
+from koopman_graph.nn import GNNDecoder, GNNEncoder
from koopman_graph.operators import (
MixtureKoopmanOperator,
SwitchedKoopmanOperator,
@@ -32,3 +36,57 @@ def test_switched_and_mixture_operator_surface() -> None:
assert mixture.bound_metric().ndim == 0
assert mixture.inverse_advance(z).shape == z.shape
assert mixture(z).shape == z.shape
+
+
+def _two_mode_switched() -> tuple[SwitchedKoopmanOperator, torch.Tensor]:
+ """Return a two-mode bank with distinct maps and a latent batch."""
+ switched = SwitchedKoopmanOperator(2, num_modes=2)
+ switched.modes[0].set_dense_matrix(0.5 * torch.eye(2))
+ switched.modes[1].set_dense_matrix(torch.diag(torch.tensor([0.2, 0.8])))
+ switched.set_mode(0)
+ return switched, torch.ones(3, 2)
+
+
+def test_switched_phase_index_overrides_without_mutating_mode() -> None:
+ """phase_index selects a mode for one step and leaves mode_index."""
+ switched, z = _two_mode_switched()
+ expected = switched.modes[1].advance(z)
+ out = switched.advance(z, phase_index=1)
+ torch.testing.assert_close(out, expected)
+ assert switched.mode_index == 0
+ inverse = switched.inverse_advance(expected, phase_index=1)
+ torch.testing.assert_close(inverse, z, atol=1e-5, rtol=1e-5)
+ assert switched.mode_index == 0
+ with pytest.raises(ValueError, match="phase_index"):
+ switched.advance(z, phase_index=2)
+ forwarded = propagate_latent(switched, z, phase_index=1)
+ torch.testing.assert_close(forwarded, expected)
+ ignored = propagate_latent(MixtureKoopmanOperator(2, num_modes=2), z, phase_index=1)
+ assert ignored.shape == z.shape
+
+
+def test_model_forward_threads_phase_index() -> None:
+ """Homogeneous forward uses phase_index without latching the mode."""
+ model = GraphKoopmanModel(
+ GNNEncoder(2, 4, 2, num_layers=1),
+ GNNDecoder(2, 4, 2, num_layers=1),
+ latent_dim=2,
+ time_step=0.1,
+ koopman="switched",
+ koopman_num_modes=2,
+ )
+ operator = model.koopman
+ assert isinstance(operator, SwitchedKoopmanOperator)
+ operator.modes[0].set_dense_matrix(0.5 * torch.eye(2))
+ operator.modes[1].set_dense_matrix(torch.diag(torch.tensor([0.25, 0.75])))
+ operator.set_mode(0)
+ graph = Data(
+ x=torch.randn(3, 2),
+ edge_index=torch.tensor([[0, 1, 2], [1, 2, 0]], dtype=torch.long),
+ )
+ out_latched = model(graph)
+ out_phase = model(graph, phase_index=1)
+ assert operator.mode_index == 0
+ assert out_latched.shape == graph.x.shape
+ assert out_phase.shape == graph.x.shape
+ assert not torch.allclose(out_latched, out_phase)
diff --git a/tests/package/test_docs.py b/tests/package/test_docs.py
index c3e14bc..5b65768 100644
--- a/tests/package/test_docs.py
+++ b/tests/package/test_docs.py
@@ -233,6 +233,47 @@ def test_source_definitions_have_numpy_docstrings() -> None:
"Rosenstein1993Lyapunov",
"Welch1967",
)
+# Method / software keys that shipped with the post-0.14 catalog.
+_V015_BIB_KEYS = (
+ "Colbrook2023mpEDMD",
+ "Klus2020gEDMD",
+ "HaseliCortes2023",
+ "Guo2025ModularEDMD",
+ "Macesic2018Nonautonomous",
+ "Zeng2022Sampling",
+ "Pan2021SparseSubspace",
+ "Ruiz2023Transferability",
+ "Zhang2022TubeMPC",
+ "Xu2025ResKoopNet",
+ "LibCity2021",
+ "BasicTS2024",
+ "kooplearn2026",
+ "PyKoopman2024",
+ "deeptime2021",
+ "PyDMD2018",
+ "TopoX2024",
+)
+_SOFTWARE_BIB_KEY = "koopmangraph2026"
+_STALE_SOFTWARE_VERSION = "0.11.0"
+_STALE_SOFTWARE_DOI_RECORD = "21763908"
+_ISOTYPIC_DOC_RELATIVE_PATHS = (
+ "docs/source/limitations.rst",
+ "CHANGELOG.md",
+ "docs/source/capabilities.rst",
+ "docs/source/architecture.rst",
+)
+_ISOTYPIC_UNSHIPPED_PRESENT_TENSE = (
+ re.compile(r"isotypic tying is\s+\*{0,2}not\*{0,2}\s+shipped", re.I),
+ re.compile(r"neighbor[- ]factor tying not shipped", re.I),
+)
+_STALE_CONTRIBUTING_VERSION = "0.6.0"
+_FROZEN_PACKAGE_VERSION_ASSIGNMENT = re.compile(r'__version__\s*=\s*"\d+\.\d+\.\d+"')
+_V015_ARCHITECTURE_HEADING = "v0.15.0 capability architecture"
+_UNSHIPPED_V015_PACKAGES: tuple[str, ...] = ()
+_UNSHIPPED_SPHINX_ROLE = re.compile(
+ r":(?:mod|class|func|data):`~?koopman_graph\.(?:%s)(?:\.[\w.]*)?`"
+ % ("|".join(_UNSHIPPED_V015_PACKAGES) or "NEVER_MATCH_UNSHIPPED")
+)
_REQUIRED_PAPER_MD_CITES = (
"Azencot2020",
"Bruder2021",
@@ -303,6 +344,298 @@ def test_v070_bibliography_entries_are_cited() -> None:
assert "proceedings.mlr.press/v202/h-zargarbashi23a.html" in bib_text
+def test_v015_bibliography_entries_are_cited() -> None:
+ """Post-0.14 method keys must exist in paper.bib and appear in src/ or docs."""
+ bib_text = (_PROJECT_ROOT / "paper.bib").read_text(encoding="utf-8")
+ corpus_parts: list[str] = []
+ for root_name in ("src", "docs/source"):
+ root = _PROJECT_ROOT / root_name
+ for path in root.rglob("*"):
+ if path.suffix in {".py", ".rst", ".md"} and path.is_file():
+ corpus_parts.append(path.read_text(encoding="utf-8"))
+ corpus = "\n".join(corpus_parts)
+
+ missing_bib = [
+ key
+ for key in _V015_BIB_KEYS
+ if not re.search(rf"@\w+\{{{re.escape(key)}\s*,", bib_text)
+ ]
+ missing_cites = [key for key in _V015_BIB_KEYS if key not in corpus]
+ assert not missing_bib, f"paper.bib missing v0.15 keys: {missing_bib}"
+ assert not missing_cites, f"uncited v0.15 bib keys: {missing_cites}"
+
+ for key, locator in (
+ ("Colbrook2023mpEDMD", "10.1137/22M1521407"),
+ ("Klus2020gEDMD", "10.1016/j.physd.2020.132416"),
+ ("HaseliCortes2023", "2311.13033"),
+ ("Guo2025ModularEDMD", "10.1016/j.physd.2025.134651"),
+ ("Macesic2018Nonautonomous", "10.1137/17M1133610"),
+ ("Zeng2022Sampling", "10.1109/CDC51059.2022.9992482"),
+ ("Pan2021SparseSubspace", "10.1017/jfm.2021.271"),
+ ("Ruiz2023Transferability", "10.1109/TSP.2023.3297848"),
+ ("Zhang2022TubeMPC", "10.1016/j.automatica.2021.110114"),
+ ("Xu2025ResKoopNet", "proceedings.mlr.press/v267/xu25y.html"),
+ ("LibCity2021", "10.1145/3474717.3483923"),
+ ("BasicTS2024", "10.1109/TKDE.2024.3484454"),
+ ("kooplearn2026", "10.21105/joss.10342"),
+ ("PyKoopman2024", "10.21105/joss.05881"),
+ ("deeptime2021", "10.1088/2632-2153/ac3de0"),
+ ("PyDMD2018", "10.21105/joss.00530"),
+ ("TopoX2024", "2402.02441"),
+ ):
+ assert locator in bib_text, f"{key} missing locator {locator}"
+
+
+def test_readme_highlights_identification_and_benchmarks() -> None:
+ """README Highlights must name identification and identity-bound benchmarks."""
+ readme = (_PROJECT_ROOT / "README.md").read_text(encoding="utf-8")
+ version = koopman_graph.__version__
+ assert "## Highlights" in readme
+ assert "Identification (opt-in)" in readme
+ assert "Identity-bound benchmarks" in readme
+ assert "does not train" in readme.lower()
+ assert "not a LibCity / BasicTS host" in readme
+ assert "not Ray Tune" in readme
+ assert f"new in {version}" in readme
+ assert f"version = {{{version}}}" in readme
+ assert "0.2.0" not in readme
+
+
+def test_release_metadata_versions_agree() -> None:
+ """Package, CHANGELOG, CITATION.cff, README, and paper.bib versions agree."""
+ version = koopman_graph.__version__
+ changelog = (_PROJECT_ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
+ readme = (_PROJECT_ROOT / "README.md").read_text(encoding="utf-8")
+ bib_text = (_PROJECT_ROOT / "paper.bib").read_text(encoding="utf-8")
+ cff_text = (_PROJECT_ROOT / "CITATION.cff").read_text(encoding="utf-8")
+ index = (_PROJECT_ROOT / "docs" / "source" / "index.rst").read_text(
+ encoding="utf-8"
+ )
+ assert "## [Unreleased]" in changelog
+ assert f"## [{version}]" in changelog
+ assert _cff_top_level_field(cff_text, "version") == version
+ assert _cff_top_level_field(cff_text, "doi") == "10.5281/zenodo.21926723"
+ assert f"version = {{{version}}}" in readme
+ assert f"Version **{version}**" in index
+ bib_version = _bib_field(_bib_entry(bib_text, _SOFTWARE_BIB_KEY), "version")
+ assert bib_version == version
+
+
+def _bib_entry(bib_text: str, key: str) -> str:
+ """Return the BibTeX body for ``key`` (braces exclusive of the entry type)."""
+ match = re.search(rf"@\w+\{{{re.escape(key)}\s*,", bib_text)
+ if match is None:
+ msg = f"paper.bib missing entry {key}"
+ raise AssertionError(msg)
+ start = bib_text.find("{", match.start())
+ depth = 0
+ for index in range(start, len(bib_text)):
+ char = bib_text[index]
+ if char == "{":
+ depth += 1
+ elif char == "}":
+ depth -= 1
+ if depth == 0:
+ return bib_text[start + 1 : index]
+ msg = f"paper.bib entry {key} is unclosed"
+ raise AssertionError(msg)
+
+
+def _bib_field(entry: str, field: str) -> str:
+ """Return a single braced BibTeX field value."""
+ match = re.search(
+ rf"(?im)^\s*{re.escape(field)}\s*=\s*\{{([^}}]*)\}}",
+ entry,
+ )
+ if match is None:
+ msg = f"paper.bib field {field} missing"
+ raise AssertionError(msg)
+ return match.group(1).strip()
+
+
+def _cff_top_level_field(cff_text: str, field: str) -> str:
+ """Return a top-level YAML scalar from CITATION.cff (no nested keys)."""
+ match = re.search(rf"(?m)^{re.escape(field)}:\s*(\S+)\s*$", cff_text)
+ if match is None:
+ msg = f"CITATION.cff missing top-level {field}"
+ raise AssertionError(msg)
+ return match.group(1).strip().strip("\"'")
+
+
+def test_software_citation_matches_citation_cff() -> None:
+ """Software bib entry version/DOI must match CITATION.cff (not stale 0.11.0)."""
+ bib_text = (_PROJECT_ROOT / "paper.bib").read_text(encoding="utf-8")
+ cff_text = (_PROJECT_ROOT / "CITATION.cff").read_text(encoding="utf-8")
+ entry = _bib_entry(bib_text, _SOFTWARE_BIB_KEY)
+ bib_version = _bib_field(entry, "version")
+ bib_doi = _bib_field(entry, "doi")
+ cff_version = _cff_top_level_field(cff_text, "version")
+ cff_doi = _cff_top_level_field(cff_text, "doi")
+ assert bib_version == cff_version, (
+ f"{_SOFTWARE_BIB_KEY} version {bib_version!r} != CITATION.cff {cff_version!r}"
+ )
+ assert bib_doi == cff_doi, (
+ f"{_SOFTWARE_BIB_KEY} doi {bib_doi!r} != CITATION.cff {cff_doi!r}"
+ )
+ assert _STALE_SOFTWARE_VERSION not in entry
+ assert _STALE_SOFTWARE_DOI_RECORD not in entry
+
+
+def test_isotypic_neighbor_tying_not_described_as_unshipped() -> None:
+ """Present-tense docs must not claim neighbor-factor isotypic tying is unshipped."""
+ hits: list[str] = []
+ texts: dict[str, str] = {}
+ for relative in _ISOTYPIC_DOC_RELATIVE_PATHS:
+ path = _PROJECT_ROOT / relative
+ text = path.read_text(encoding="utf-8")
+ texts[relative] = text
+ for pattern in _ISOTYPIC_UNSHIPPED_PRESENT_TENSE:
+ if pattern.search(text):
+ hits.append(f"{relative}: {pattern.pattern}")
+ assert not hits, (
+ "present-tense unshipped isotypic-tying wording remains in:\n" + "\n".join(hits)
+ )
+ assert "neighbor-factor ties" in texts["docs/source/capabilities.rst"]
+ assert (
+ "Neighbor-factor isotypic tying is shipped"
+ in texts["docs/source/limitations.rst"]
+ )
+ assert "was not included in 0.11.0" in texts["CHANGELOG.md"]
+ assert (
+ "neighbor-factor tying added in 0.14" in texts["docs/source/architecture.rst"]
+ )
+
+
+_V015_GUIDE_PAGES = (
+ "benchmarks",
+ "identification",
+ "spectral_diagnostics",
+ "graph_dynamics",
+ "matrix_free",
+ "criticality",
+ "time_conditioning",
+)
+
+
+def test_v015_guide_pages_exist_and_are_in_toctree() -> None:
+ """v0.15 guide pages must be tracked RST files listed in the Sphinx toctree."""
+ index = (_PROJECT_ROOT / "docs" / "source" / "index.rst").read_text(
+ encoding="utf-8"
+ )
+ for name in _V015_GUIDE_PAGES:
+ path = _PROJECT_ROOT / "docs" / "source" / f"{name}.rst"
+ assert path.is_file(), f"missing docs/source/{name}.rst"
+ assert f"\n {name}\n" in index or f"\n {name}\r\n" in index, (
+ f"{name} missing from docs/source/index.rst toctree"
+ )
+ text = path.read_text(encoding="utf-8")
+ assert "blueprint" not in text.lower()
+ assert "DESIGN-v" not in text
+ assert ":doc:`limitations`" in text or "limitations" in text
+
+
+def test_limitations_rehomes_v015_ceilings() -> None:
+ """Public limitations page must carry the post-0.14 honesty ceilings."""
+ text = (_PROJECT_ROOT / "docs" / "source" / "limitations.rst").read_text(
+ encoding="utf-8"
+ )
+ folded = _folded_rst(text)
+ assert "identification=None" in folded
+ assert "Adam" in folded
+ assert "MpEDMDBaseline" in folded
+ assert "SpectralDiagnostics" in folded
+ assert "fixed-union" in folded
+ assert "koopman_filter_degree=1" in folded
+ assert "Nyquist" in folded
+ assert "CochainKoopmanOperator" in folded
+ assert "validate_uniform_discrete_increments" in folded
+ assert "monitor_critical_transition" in folded
+ assert "0.6551" in folded
+ assert "hop order" in folded
+ assert "Neighbor-factor isotypic tying is shipped" in text
+ assert "Negative transfer" in folded
+ assert "MassConservingDecoder" in folded
+ assert "MAX_DENSE_LINEAR_OPERATOR_SIZE" in folded
+ assert "blueprint" not in text.lower()
+ assert "DESIGN-v" not in text
+
+
+def test_contributing_does_not_freeze_package_version() -> None:
+ """CONTRIBUTING must point at __init__.py, not freeze a package version string."""
+ text = (_PROJECT_ROOT / "CONTRIBUTING.md").read_text(encoding="utf-8")
+ assert "src/koopman_graph/__init__.py" in text
+ assert _STALE_CONTRIBUTING_VERSION not in text
+ match = _FROZEN_PACKAGE_VERSION_ASSIGNMENT.search(text)
+ assert match is None, (
+ f"frozen __version__ assignment in CONTRIBUTING: {match.group(0)!r}"
+ )
+
+
+def _architecture_section(heading: str) -> str:
+ """Return architecture.rst from ``heading`` through the next top-level section."""
+ text = (_PROJECT_ROOT / "docs/source/architecture.rst").read_text(encoding="utf-8")
+ start = text.index(heading)
+ related = text.index("Related documentation", start)
+ return text[start:related]
+
+
+def _folded_rst(text: str) -> str:
+ """Collapse RST wrapping so contract phrases can be matched."""
+ return re.sub(r"\s+", " ", text)
+
+
+def test_architecture_v015_scaffold_preserves_defaults_and_import_rules() -> None:
+ """v0.15 architecture scaffold must keep LTI defaults and planned import rules."""
+ section = _architecture_section(_V015_ARCHITECTURE_HEADING)
+ folded = _folded_rst(section)
+ assert "koopman=None" in folded
+ assert '"pernode"' in folded
+ assert "linear time-invariant per-node" in folded
+ assert "koopman_filter_degree" in folded
+ assert "default ``1``" in folded
+ assert "FORMAT_VERSION" in folded
+ assert "No other ``koopman_graph`` package imports ``benchmark``" in folded
+ assert "``training`` must not import ``identification`` at module load" in folded
+ assert "``identification`` must not import ``adaptation``" in folded
+ assert "EntityRemap" in folded
+ assert "estimate_graphon" in folded
+ assert "ConditioningContext" in folded
+ assert "diurnal_control_features" in folded
+ assert "ParametricKoopmanOperator" in folded
+ assert "DriftDiffusionKoopman" in folded
+ assert "JointCoverageSpec" in folded
+ assert "markov_closure_report" in folded
+ assert "FiniteMemoryKoopman" in folded
+ assert "CochainKoopmanOperator" in folded
+ assert "CochainState" in folded
+ assert "hodge_decompose_modes" in folded
+ assert "HodgeModeComponents" in folded
+ assert "order2_cochain_teaching" in folded
+ assert "MAX_CELL_COMPLEX_DEGREE" in folded
+ assert "EquivariantKoopmanOperator" in folded
+ assert "n_tensors" in folded
+ assert "MassConservingDecoder" in folded
+ assert "LinearConservingDecoder" in folded
+ assert "constraint_decoders" in folded
+ assert "LinearOperatorProtocol" in folded
+ assert "PolynomialGraphLinearOperator" in folded
+ assert "MAX_DENSE_LINEAR_OPERATOR_SIZE" in folded
+ assert "TubeKoopmanMPC" in folded
+ assert "TubeMPCReport" in folded
+ assert "SyntheticSCM" in folded
+ assert "SyntheticInterventionReport" in folded
+ assert "recover_synthetic_interventional_edges" in folded
+ assert "koopman_graph.identification" in folded
+ for name in _UNSHIPPED_V015_PACKAGES:
+ assert f"koopman_graph.{name}" in folded
+ assert "Planned" in folded
+ assert "koopman_graph.benchmark" in folded
+ role = _UNSHIPPED_SPHINX_ROLE.search(section)
+ assert role is None, (
+ f"unshipped package Sphinx role in v0.15 scaffold: {role.group(0)!r}"
+ )
+
+
def test_joss_paper_narrative_word_count_at_most_1000() -> None:
"""JOSS narrative body must stay within the ≤1000-word gate."""
paper_text = (_PROJECT_ROOT / "paper.md").read_text()
diff --git a/tests/package/test_example_honesty.py b/tests/package/test_example_honesty.py
new file mode 100644
index 0000000..f726c61
--- /dev/null
+++ b/tests/package/test_example_honesty.py
@@ -0,0 +1,136 @@
+"""Honesty locks for examples 22 / 37 / 38 (saved rankings, not SOTA)."""
+
+from __future__ import annotations
+
+import json
+import pathlib
+import re
+
+from tests.helpers import REPO_ROOT
+
+_PROJECT_ROOT = REPO_ROOT
+
+_EXAMPLE22_AGGREGATE_RMSE = ("0.6551", "0.7076", "1.0754", "0.9036")
+_TRAILS_GNNS = re.compile(r"trails\s+gnns", re.I)
+_TRANSFER_ADVANTAGE_FALSE = re.compile(
+ r"transfer_advantage[`\s=]*is[`\s]*`?False",
+ re.I,
+)
+_HOP_ORDER_NOT_CAUSE = re.compile(
+ r"(?:does\s+not\s+attribute|not\s+claimed\s+to\s+explain|"
+ r"neither\s+protocol\s+attributes).{0,80}hop\s+order"
+ r"|hop\s+order.{0,80}(?:does\s+not|not\s+claimed|"
+ r"neither\s+protocol\s+attributes)",
+ re.I | re.S,
+)
+_NOT_SOTA = re.compile(r"not.{0,80}sota", re.I | re.S)
+_HONESTY_SCAN_PATHS = (
+ "examples/22_gnn_forecaster_comparison.ipynb",
+ "examples/37_cross_topology_transfer.ipynb",
+ "examples/38_operator_factorization_ablation.ipynb",
+ "docs/source/limitations.rst",
+ "docs/source/tutorials.rst",
+ "docs/source/faq.rst",
+ "README.md",
+ "CHANGELOG.md",
+)
+_EXAMPLE22_PUBLIC_LOCKS = (
+ "examples/22_gnn_forecaster_comparison.ipynb",
+ "docs/source/limitations.rst",
+ "docs/source/tutorials.rst",
+ "docs/source/faq.rst",
+ "README.md",
+)
+
+
+def _cell_source(cell: dict) -> str:
+ source = cell.get("source", [])
+ if isinstance(source, list):
+ return "".join(source)
+ if isinstance(source, str):
+ return source
+ return ""
+
+
+def _notebook_markdown(path: pathlib.Path) -> str:
+ """Flatten Markdown cell sources (discussion, not code outputs)."""
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ chunks = [
+ _cell_source(cell)
+ for cell in payload.get("cells", [])
+ if cell.get("cell_type") == "markdown"
+ ]
+ return "\n".join(chunks)
+
+
+def _artifact_text(relative: str) -> str:
+ path = _PROJECT_ROOT / relative
+ if path.suffix == ".ipynb":
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ chunks = [_cell_source(cell) for cell in payload.get("cells", [])]
+ return "\n".join(chunks)
+ return path.read_text(encoding="utf-8")
+
+
+def test_examples_22_37_38_forbid_trails_gnns_slogan() -> None:
+ """Do not claim Koopman trails GNNs in the honesty-pass artifacts."""
+ hits: list[str] = []
+ for relative in _HONESTY_SCAN_PATHS:
+ text = _artifact_text(relative)
+ if _TRAILS_GNNS.search(text):
+ hits.append(relative)
+ assert not hits, f"'trails GNNs' remains in: {hits}"
+
+
+def test_example_22_markdown_cites_saved_aggregate_rmse() -> None:
+ """Example 22 discussion must cite the saved ranking and budget caveats."""
+ markdown = _notebook_markdown(
+ _PROJECT_ROOT / "examples/22_gnn_forecaster_comparison.ipynb"
+ )
+ for value in _EXAMPLE22_AGGREGATE_RMSE:
+ assert value in markdown, f"example 22 markdown missing RMSE {value}"
+ lower = markdown.lower()
+ assert "unequal" in lower and "budget" in lower
+ assert "teaching" in lower
+ assert _NOT_SOTA.search(markdown), "example 22 markdown must hedge SOTA"
+
+
+def test_example_22_public_docs_lock_aggregate_rmse() -> None:
+ """Limitations, gallery, and README must carry the same four RMSE figures."""
+ for relative in _EXAMPLE22_PUBLIC_LOCKS:
+ if relative.endswith(".ipynb"):
+ text = _notebook_markdown(_PROJECT_ROOT / relative)
+ else:
+ text = (_PROJECT_ROOT / relative).read_text(encoding="utf-8")
+ missing = [value for value in _EXAMPLE22_AGGREGATE_RMSE if value not in text]
+ assert not missing, f"{relative} missing RMSE {missing}"
+ lower = text.lower()
+ assert "unequal" in lower and "budget" in lower, (
+ f"{relative} missing budget caveat"
+ )
+ assert "teaching" in lower, f"{relative} missing teaching-baseline language"
+
+
+def test_example_37_markdown_keeps_negative_transfer_advantage() -> None:
+ """Example 37 must keep transfer_advantage False versus the pernode control."""
+ markdown = _notebook_markdown(
+ _PROJECT_ROOT / "examples/37_cross_topology_transfer.ipynb"
+ )
+ assert "transfer_advantage" in markdown
+ assert _TRANSFER_ADVANTAGE_FALSE.search(markdown), (
+ "example 37 markdown must state transfer_advantage is False"
+ )
+ assert "pernode" in markdown.lower()
+
+
+def test_example_38_markdown_keeps_joint_ls_gap_and_hop_disclaimer() -> None:
+ """Example 38 must keep the historical MSE gap and hop-order disclaimer."""
+ markdown = _notebook_markdown(
+ _PROJECT_ROOT / "examples/38_operator_factorization_ablation.ipynb"
+ )
+ assert "0.71" in markdown
+ assert "0.019" in markdown
+ assert "hop order" in markdown.lower()
+ assert _HOP_ORDER_NOT_CAUSE.search(markdown), (
+ "example 38 markdown must not attribute the joint-LS gap to hop order"
+ )
diff --git a/tests/package/test_notebook_47.py b/tests/package/test_notebook_47.py
new file mode 100644
index 0000000..88006e4
--- /dev/null
+++ b/tests/package/test_notebook_47.py
@@ -0,0 +1,110 @@
+"""Honesty locks for example 47 (identity-bound CLI, no METR-LA download)."""
+
+from __future__ import annotations
+
+import json
+import pathlib
+import re
+
+from tests.helpers import REPO_ROOT
+
+_LOCAL_PATH = re.compile(
+ r"(?:/Users/|/home/|/var/folders/|/private/var/|[A-Za-z]:\\Users\\)"
+)
+
+_PROJECT_ROOT = REPO_ROOT
+_NOTEBOOK = _PROJECT_ROOT / "examples" / "47_benchmark_manifest.ipynb"
+_REQUIRED_HEADINGS = (
+ "# Identity-bound benchmark manifests",
+ "## Setup",
+ "## Motivation and background",
+ "## Minimal example",
+ "## Progressive deep dive",
+ "## Results",
+ "## Interpretation / discussion",
+ "## Takeaways",
+ "## Further reading",
+)
+
+
+def _notebook_markdown(path: pathlib.Path) -> str:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ chunks: list[str] = []
+ for cell in payload.get("cells", []):
+ if cell.get("cell_type") != "markdown":
+ continue
+ source = cell.get("source", [])
+ if isinstance(source, list):
+ chunks.append("".join(source))
+ elif isinstance(source, str):
+ chunks.append(source)
+ return "\n".join(chunks)
+
+
+def test_example_47_notebook_exists() -> None:
+ """Example 47 must be a tracked Jupyter notebook."""
+ assert _NOTEBOOK.is_file()
+
+
+def test_example_47_markdown_has_scientific_arc() -> None:
+ """Example 47 must follow the nine-section scientific-notebook arc."""
+ markdown = _notebook_markdown(_NOTEBOOK)
+ missing = [heading for heading in _REQUIRED_HEADINGS if heading not in markdown]
+ assert not missing, f"example 47 missing headings: {missing}"
+
+
+def test_example_47_markdown_identity_bound_no_telemetry_download() -> None:
+ """Example 47 must stay on smoke fixtures and must not download METR-LA."""
+ markdown = _notebook_markdown(_NOTEBOOK)
+ plain = markdown.replace("**", "")
+ lower = plain.lower()
+ assert "executed=False" in plain
+ assert "smoke_telemetry" in plain
+ assert "verify" in lower
+ assert "[cli]" in plain
+ assert "does not download METR-LA" in plain
+ assert "wget" not in lower
+ assert "curl " not in lower
+ assert "metr-la.h5" not in lower
+
+
+def test_example_47_markdown_digest_semantics_and_citations() -> None:
+ """Example 47 must state verify semantics and cite named incumbents."""
+ markdown = _notebook_markdown(_NOTEBOOK)
+ plain = markdown.replace("**", "")
+ assert "ensure_ascii=False" in plain
+ assert "package_version" in plain
+ assert "manifest_sha256" in plain
+ assert "dataset.sha256" in plain
+ assert "does not enforce `executed=False`" in plain
+ assert "wrong payload" in plain.lower()
+ assert "benchmarks.html" in markdown
+ assert "api.html" in markdown
+ assert "doi:10.1145/3474717.3483923" in markdown
+ assert "doi:10.1109/TKDE.2024.3484454" in markdown
+ assert "openreview.net/forum?id=sjihxgwaz" in markdown.lower()
+
+
+def _notebook_output_text(path: pathlib.Path) -> str:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ chunks: list[str] = []
+ for cell in payload.get("cells", []):
+ if cell.get("cell_type") != "code":
+ continue
+ for item in cell.get("outputs", []):
+ text = item.get("text", "")
+ if isinstance(text, list):
+ chunks.append("".join(text))
+ elif isinstance(text, str):
+ chunks.append(text)
+ traceback = item.get("traceback", [])
+ if isinstance(traceback, list):
+ chunks.extend(str(line) for line in traceback)
+ return "\n".join(chunks)
+
+
+def test_example_47_outputs_omit_local_paths() -> None:
+ """Stored outputs must not leak checkout, temp, or home paths."""
+ text = _notebook_output_text(_NOTEBOOK)
+ hits = _LOCAL_PATH.findall(text)
+ assert not hits, f"example 47 outputs contain local paths: {hits}"
diff --git a/tests/package/test_notebook_50.py b/tests/package/test_notebook_50.py
new file mode 100644
index 0000000..42b7ac8
--- /dev/null
+++ b/tests/package/test_notebook_50.py
@@ -0,0 +1,68 @@
+"""Honesty locks for example 50 (wiring check vs hold-last, remap note)."""
+
+from __future__ import annotations
+
+import json
+import pathlib
+
+from tests.helpers import REPO_ROOT
+
+_PROJECT_ROOT = REPO_ROOT
+_NOTEBOOK = _PROJECT_ROOT / "examples" / "50_graph_state_closure.ipynb"
+_REQUIRED_HEADINGS = (
+ "# Graph-state closure versus hold-last",
+ "## Setup",
+ "## Motivation and background",
+ "## Minimal example",
+ "## Progressive deep dive",
+ "## Results",
+ "## Interpretation / discussion",
+ "## Takeaways",
+ "## Further reading",
+)
+
+
+def _notebook_markdown(path: pathlib.Path) -> str:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ chunks: list[str] = []
+ for cell in payload.get("cells", []):
+ if cell.get("cell_type") != "markdown":
+ continue
+ source = cell.get("source", [])
+ if isinstance(source, list):
+ chunks.append("".join(source))
+ elif isinstance(source, str):
+ chunks.append(source)
+ return "\n".join(chunks)
+
+
+def test_example_50_notebook_exists() -> None:
+ """Example 50 must be a tracked Jupyter notebook."""
+ assert _NOTEBOOK.is_file()
+
+
+def test_example_50_markdown_has_scientific_arc() -> None:
+ """Example 50 must follow the nine-section scientific-notebook arc."""
+ markdown = _notebook_markdown(_NOTEBOOK)
+ missing = [heading for heading in _REQUIRED_HEADINGS if heading not in markdown]
+ assert not missing, f"example 50 missing headings: {missing}"
+
+
+def test_example_50_markdown_wiring_check_and_remap_note() -> None:
+ """Example 50 must beat hold-last as a wiring check and note remapping."""
+ markdown = _notebook_markdown(_NOTEBOOK)
+ plain = markdown.replace("**", "")
+ lower = plain.lower()
+ assert "wiring check" in lower
+ assert "hold-last" in lower
+ assert "not a learned-forecast" in lower
+ assert "entityremap" in lower
+ assert "unbounded growth" in lower
+ assert "permute" in lower
+ assert "self_adaptive" in lower or "adaptiveadjacency" in lower.replace(" ", "")
+ assert "metr-la" in lower
+ assert "sota" in lower
+ assert "wu2019wavenet" in lower
+ assert "g(n,p)" in lower.replace(" ", "")
+ assert "graph_dynamics.html" in lower
+ assert "sigmoid" in lower
diff --git a/tests/package/test_notebooks_v015_guides.py b/tests/package/test_notebooks_v015_guides.py
new file mode 100644
index 0000000..8144aa7
--- /dev/null
+++ b/tests/package/test_notebooks_v015_guides.py
@@ -0,0 +1,142 @@
+"""Honesty locks for examples 48–49 and 51–54 (guides; no telemetry download)."""
+
+from __future__ import annotations
+
+import json
+import pathlib
+
+from tests.helpers import REPO_ROOT
+
+_PROJECT_ROOT = REPO_ROOT
+_ARC = (
+ "## Setup",
+ "## Motivation and background",
+ "## Minimal example",
+ "## Progressive deep dive",
+ "## Results",
+ "## Interpretation / discussion",
+ "## Takeaways",
+ "## Further reading",
+)
+_NOTEBOOKS = {
+ "48_identification_invariance.ipynb": (
+ "# Identification reports, residual-aware gating, and finite-sample invariance",
+ (
+ "identification=None",
+ "Haseli",
+ "select_resdmd_gated",
+ "polluted",
+ "include_invariance",
+ "does not download metr-la",
+ ),
+ ),
+ "49_multi_hop_factorization.ipynb": (
+ "# Multi-hop factorization: Kronecker spectrum versus dense",
+ (
+ "0.71",
+ "0.019",
+ "koopman_filter_degree",
+ "Guo",
+ "hop order",
+ "does not download metr-la",
+ ),
+ ),
+ "51_spectral_diagnostics.ipynb": (
+ "# Spectral diagnostics: non-normal shear and Nyquist toys",
+ (
+ "CONDITION_WARN",
+ "Nyquist",
+ "finite-horizon",
+ "Wilkinson",
+ "Trefethen",
+ "Zeng",
+ "does not download metr-la",
+ ),
+ ),
+ "52_cochain_hodge_modes.ipynb": (
+ "# Hodge split of cycle modes",
+ (
+ "harmonic",
+ 'koopman="hodge"',
+ "TopologicX",
+ "physical circulation",
+ "does not download metr-la",
+ "degree=1",
+ "relative L2",
+ "compute_spectrum",
+ ),
+ ),
+ "53_latent_rank_selection.ipynb": (
+ "# Latent-rank selection on a linear Gaussian oracle",
+ (
+ "select_latent_rank",
+ "Ray Tune",
+ "latent_dim",
+ "does not download metr-la",
+ ),
+ ),
+ "54_criticality_monitor.ipynb": (
+ "# Criticality monitor on a closing spectral gap",
+ (
+ "monitor_critical_transition",
+ "not a certificate",
+ "Ghosh",
+ "does not download metr-la",
+ ),
+ ),
+}
+
+
+def _notebook_markdown(path: pathlib.Path) -> str:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ chunks: list[str] = []
+ for cell in payload.get("cells", []):
+ if cell.get("cell_type") != "markdown":
+ continue
+ source = cell.get("source", [])
+ if isinstance(source, list):
+ chunks.append("".join(source))
+ elif isinstance(source, str):
+ chunks.append(source)
+ return "\n".join(chunks)
+
+
+def _notebook_text(path: pathlib.Path) -> str:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ chunks: list[str] = []
+ for cell in payload.get("cells", []):
+ source = cell.get("source", [])
+ if isinstance(source, list):
+ chunks.append("".join(source))
+ elif isinstance(source, str):
+ chunks.append(source)
+ return "\n".join(chunks)
+
+
+def test_v015_guide_notebooks_exist_with_scientific_arc() -> None:
+ """Examples 48–49 and 51–54 must exist and follow the scientific arc."""
+ for name, (title, _needles) in _NOTEBOOKS.items():
+ path = _PROJECT_ROOT / "examples" / name
+ assert path.is_file(), f"missing {name}"
+ markdown = _notebook_markdown(path)
+ assert title in markdown, f"{name} missing title {title!r}"
+ missing = [heading for heading in _ARC if heading not in markdown]
+ assert not missing, f"{name} missing headings: {missing}"
+
+
+def test_v015_guide_notebooks_honesty_and_no_telemetry_download() -> None:
+ """Guide notebooks must keep honesty needles and must not fetch METR-LA."""
+ for name, (_title, needles) in _NOTEBOOKS.items():
+ path = _PROJECT_ROOT / "examples" / name
+ markdown = _notebook_markdown(path)
+ text = _notebook_text(path)
+ plain = markdown.replace("**", "")
+ folded = " ".join(plain.lower().split())
+ lower = text.lower()
+ for needle in needles:
+ haystack = folded if needle.islower() else markdown
+ assert needle in haystack, f"{name} markdown missing {needle!r}"
+ assert "wget" not in lower
+ assert "curl http" not in lower
+ assert "metr-la.h5" not in lower
+ assert "download_metr" not in lower
diff --git a/tests/package/test_package.py b/tests/package/test_package.py
index 8d003d4..ff8bf02 100644
--- a/tests/package/test_package.py
+++ b/tests/package/test_package.py
@@ -304,6 +304,11 @@ def test_analysis_secondaries_demoted_from_root() -> None:
"spectral_residuals",
"SpectralResidualReport",
"spectrum_distance",
+ "markov_closure_report",
+ "MarkovClosureReport",
+ "FiniteMemoryKoopman",
+ "hodge_decompose_modes",
+ "HodgeModeComponents",
}
exported = set(koopman_graph.__all__)
assert not (demoted & exported)
@@ -312,6 +317,9 @@ def test_analysis_secondaries_demoted_from_root() -> None:
from koopman_graph.analysis import (
AnomalyDetectionResult,
+ FiniteMemoryKoopman,
+ HodgeModeComponents,
+ MarkovClosureReport,
RepresentationExplanation,
SpectralResidualReport,
calibrate_anomaly_threshold,
@@ -321,7 +329,9 @@ def test_analysis_secondaries_demoted_from_root() -> None:
discrete_spectrum_at_delta_t,
dynamical_similarity,
explain_representation,
+ hodge_decompose_modes,
koopman_std,
+ markov_closure_report,
plot_spectrum,
spectral_residuals,
spectrum_distance,
@@ -341,6 +351,11 @@ def test_analysis_secondaries_demoted_from_root() -> None:
assert callable(plot_spectrum)
assert callable(spectral_residuals)
assert callable(spectrum_distance)
+ assert MarkovClosureReport is not None
+ assert FiniteMemoryKoopman is not None
+ assert HodgeModeComponents is not None
+ assert callable(markov_closure_report)
+ assert callable(hodge_decompose_modes)
def test_statistics_module_off_root_all() -> None:
@@ -372,6 +387,8 @@ def test_data_adaptation_observables_secondaries_demoted_from_root() -> None:
"FilterResult",
"GraphKoopmanEnv",
"KoopmanObserver",
+ "JointStateTopologyObserver",
+ "JointObserverResult",
"RecursiveKoopmanAdapter",
"TemporalSplit",
"NeighborWindowSampler",
@@ -379,6 +396,59 @@ def test_data_adaptation_observables_secondaries_demoted_from_root() -> None:
"as_multi_trajectory",
"graph_laplacian_features",
"temporal_split",
+ "BatchedGraphTrajectory",
+ "collate_graph_trajectories",
+ "GraphDynamicsConfig",
+ "GraphStateSnapshot",
+ "graph_dynamics_from_mapping",
+ "graph_state_at",
+ "EntityRemap",
+ "remap_node_features",
+ "SeparableDictionaryDecoder",
+ "SeparableDictionaryEncoder",
+ "GraphonEstimate",
+ "estimate_graphon",
+ "ConditioningContext",
+ "conditioning_at",
+ "diurnal_control_features",
+ "diurnal_phase_index",
+ "ParametricKoopmanOperator",
+ "LeaveOneRegimeOutReport",
+ "leave_one_regime_out",
+ "DriftDiffusionKoopman",
+ "JointCoverageSpec",
+ "gaussian_crps",
+ "gaussian_nll",
+ "energy_score",
+ "markov_closure_report",
+ "MarkovClosureReport",
+ "FiniteMemoryKoopman",
+ "hodge_decompose_modes",
+ "HodgeModeComponents",
+ "CochainState",
+ "CochainKoopmanOperator",
+ "boundary_nilpotency",
+ "order2_cochain_teaching",
+ "Order2CochainTeaching",
+ "bind_cochain_operator",
+ "teaching_order2_triangle",
+ "EquivariantKoopmanOperator",
+ "MassConservingDecoder",
+ "PositivityDecoder",
+ "LinearConservingDecoder",
+ "LinearOperatorProtocol",
+ "PolynomialGraphLinearOperator",
+ "MatrixFreeGraphLinearOperator",
+ "MAX_DENSE_LINEAR_OPERATOR_SIZE",
+ "EigResult",
+ "MemoryEstimate",
+ "TubeKoopmanMPC",
+ "TubeMPCReport",
+ "ensemble_residual_radii",
+ "SyntheticSCM",
+ "SyntheticInterventionReport",
+ "recover_synthetic_interventional_edges",
+ "teaching_three_node_scm",
}
exported = set(koopman_graph.__all__)
assert not (demoted & exported)
@@ -388,30 +458,152 @@ def test_data_adaptation_observables_secondaries_demoted_from_root() -> None:
from koopman_graph.adaptation import (
AdaptationStepResult,
FilterResult,
+ JointObserverResult,
+ JointStateTopologyObserver,
KoopmanObserver,
RecursiveKoopmanAdapter,
)
+ from koopman_graph.analysis import (
+ FiniteMemoryKoopman,
+ HodgeModeComponents,
+ MarkovClosureReport,
+ SyntheticInterventionReport,
+ SyntheticSCM,
+ hodge_decompose_modes,
+ markov_closure_report,
+ recover_synthetic_interventional_edges,
+ teaching_three_node_scm,
+ )
from koopman_graph.data import (
+ BatchedGraphTrajectory,
+ ConditioningContext,
+ EntityRemap,
+ GraphDynamicsConfig,
+ GraphStateSnapshot,
NeighborWindowSampler,
TemporalSplit,
WindowSampler,
as_multi_trajectory,
+ collate_graph_trajectories,
+ conditioning_at,
+ diurnal_control_features,
+ diurnal_phase_index,
+ graph_dynamics_from_mapping,
+ graph_state_at,
+ remap_node_features,
temporal_split,
)
from koopman_graph.env import GraphKoopmanEnv
+ from koopman_graph.mpc import (
+ KoopmanMPC,
+ TubeKoopmanMPC,
+ TubeMPCReport,
+ ensemble_residual_radii,
+ )
+ from koopman_graph.nn import (
+ LinearConservingDecoder,
+ MassConservingDecoder,
+ Order2CochainTeaching,
+ PositivityDecoder,
+ SeparableDictionaryDecoder,
+ SeparableDictionaryEncoder,
+ bind_cochain_operator,
+ order2_cochain_teaching,
+ teaching_order2_triangle,
+ )
from koopman_graph.observables import graph_laplacian_features
+ from koopman_graph.operators import (
+ MAX_DENSE_LINEAR_OPERATOR_SIZE,
+ CochainKoopmanOperator,
+ CochainState,
+ DriftDiffusionKoopman,
+ EigResult,
+ EquivariantKoopmanOperator,
+ GraphonEstimate,
+ LeaveOneRegimeOutReport,
+ LinearOperatorProtocol,
+ MatrixFreeGraphLinearOperator,
+ MemoryEstimate,
+ ParametricKoopmanOperator,
+ PolynomialGraphLinearOperator,
+ boundary_nilpotency,
+ estimate_graphon,
+ leave_one_regime_out,
+ )
+ from koopman_graph.uq import (
+ JointCoverageSpec,
+ energy_score,
+ gaussian_crps,
+ gaussian_nll,
+ )
assert AdaptationStepResult is not None
assert FilterResult is not None
assert KoopmanObserver is not None
+ assert JointObserverResult is not None
+ assert JointStateTopologyObserver is not None
assert RecursiveKoopmanAdapter is not None
assert TemporalSplit is not None
assert NeighborWindowSampler is not None
assert WindowSampler is not None
assert callable(as_multi_trajectory)
assert callable(temporal_split)
+ assert BatchedGraphTrajectory is not None
+ assert callable(collate_graph_trajectories)
+ assert GraphDynamicsConfig is not None
+ assert GraphStateSnapshot is not None
+ assert callable(graph_dynamics_from_mapping)
+ assert callable(graph_state_at)
+ assert EntityRemap is not None
+ assert callable(remap_node_features)
+ assert ConditioningContext is not None
+ assert callable(conditioning_at)
+ assert callable(diurnal_control_features)
+ assert callable(diurnal_phase_index)
assert GraphKoopmanEnv is not None
assert callable(graph_laplacian_features)
+ assert SeparableDictionaryEncoder is not None
+ assert SeparableDictionaryDecoder is not None
+ assert GraphonEstimate is not None
+ assert callable(estimate_graphon)
+ assert ParametricKoopmanOperator is not None
+ assert LeaveOneRegimeOutReport is not None
+ assert callable(leave_one_regime_out)
+ assert DriftDiffusionKoopman is not None
+ assert JointCoverageSpec is not None
+ assert callable(gaussian_crps)
+ assert callable(gaussian_nll)
+ assert callable(energy_score)
+ assert MarkovClosureReport is not None
+ assert FiniteMemoryKoopman is not None
+ assert callable(markov_closure_report)
+ assert SyntheticSCM is not None
+ assert SyntheticInterventionReport is not None
+ assert callable(teaching_three_node_scm)
+ assert callable(recover_synthetic_interventional_edges)
+ assert HodgeModeComponents is not None
+ assert callable(hodge_decompose_modes)
+ assert CochainState is not None
+ assert CochainKoopmanOperator is not None
+ assert callable(boundary_nilpotency)
+ assert Order2CochainTeaching is not None
+ assert callable(order2_cochain_teaching)
+ assert callable(bind_cochain_operator)
+ assert callable(teaching_order2_triangle)
+ assert EquivariantKoopmanOperator is not None
+ assert MassConservingDecoder is not None
+ assert PositivityDecoder is not None
+ assert LinearConservingDecoder is not None
+ assert LinearOperatorProtocol is not None
+ assert PolynomialGraphLinearOperator is not None
+ assert MatrixFreeGraphLinearOperator is not None
+ assert MemoryEstimate is not None
+ assert EigResult is not None
+ assert MAX_DENSE_LINEAR_OPERATOR_SIZE == 4096
+ assert KoopmanMPC is not None
+ assert TubeKoopmanMPC is not None
+ assert TubeMPCReport is not None
+ assert callable(ensemble_residual_radii)
def _documented_keep_in_all_names() -> set[str]:
diff --git a/tests/package/test_protocols.py b/tests/package/test_protocols.py
index 969edaa..e5f11a5 100644
--- a/tests/package/test_protocols.py
+++ b/tests/package/test_protocols.py
@@ -9,7 +9,15 @@
from torch_geometric.data import Data
from koopman_graph import GraphKoopmanModel
-from koopman_graph.baselines import DMDBaseline, DMDcBaseline, EDMDBaseline
+from koopman_graph.baselines import (
+ DMDBaseline,
+ DMDcBaseline,
+ EDMDBaseline,
+ GEDMDBaseline,
+ HankelDMDBaseline,
+ HAVOKBaseline,
+ MpEDMDBaseline,
+)
from koopman_graph.data import GraphSnapshotSequence
from koopman_graph.losses import rollout_multi_start_loss, rollout_sequence_loss
from koopman_graph.nn import GNNDecoder, GNNEncoder
@@ -95,6 +103,10 @@ def test_baselines_satisfy_forecast_model_protocol() -> None:
DMDBaseline(time_step=0.1),
DMDcBaseline(time_step=0.1),
EDMDBaseline(time_step=0.1, polynomial_degree=1),
+ MpEDMDBaseline(time_step=0.1, polynomial_degree=1),
+ GEDMDBaseline(time_step=0.1, polynomial_degree=1),
+ HankelDMDBaseline(time_step=0.1, n_delays=2),
+ HAVOKBaseline(time_step=0.1, n_delays=4, havok_rank=3),
):
assert isinstance(baseline, ForecastModel)
assert isinstance(baseline, SpectrumProvider)
@@ -114,6 +126,10 @@ def test_uncontrolled_forecast_peers() -> None:
"""Verify uncontrolled Data-only peers vs controlled-only implementers."""
dmd = DMDBaseline(time_step=0.1)
edmd = EDMDBaseline(time_step=0.1, polynomial_degree=1)
+ mpedmd = MpEDMDBaseline(time_step=0.1, polynomial_degree=1)
+ gedmd = GEDMDBaseline(time_step=0.1, polynomial_degree=1)
+ hankel = HankelDMDBaseline(time_step=0.1, n_delays=2)
+ havok = HAVOKBaseline(time_step=0.1, n_delays=4, havok_rank=3)
dmdc = DMDcBaseline(time_step=0.1)
model = _small_graph_koopman_model()
controlled = GraphKoopmanModel(
@@ -134,7 +150,7 @@ def test_uncontrolled_forecast_peers() -> None:
control_dim=1,
)
- for peer in (dmd, edmd, model):
+ for peer in (dmd, edmd, mpedmd, gedmd, hankel, havok, model):
assert isinstance(peer, ForecastModel)
assert isinstance(peer, UncontrolledForecastModel)
assert accepts_uncontrolled_data_predict(peer)
@@ -153,6 +169,10 @@ def test_baselines_are_forecast_only_not_trainable() -> None:
DMDBaseline(time_step=0.1),
DMDcBaseline(time_step=0.1),
EDMDBaseline(time_step=0.1, polynomial_degree=1),
+ MpEDMDBaseline(time_step=0.1, polynomial_degree=1),
+ GEDMDBaseline(time_step=0.1, polynomial_degree=1),
+ HankelDMDBaseline(time_step=0.1, n_delays=2),
+ HAVOKBaseline(time_step=0.1, n_delays=4, havok_rank=3),
):
assert isinstance(baseline, ForecastModel)
assert not hasattr(baseline, "encode")
diff --git a/tests/package/test_v015_coverage_gaps.py b/tests/package/test_v015_coverage_gaps.py
new file mode 100644
index 0000000..e6cbfba
--- /dev/null
+++ b/tests/package/test_v015_coverage_gaps.py
@@ -0,0 +1,459 @@
+"""Release-gate coverage for 0.15 validation and error branches.
+
+These tests exercise public guards that the scientific suites leave
+unhit. They do not lower thresholds or invent forecast numbers.
+"""
+
+from __future__ import annotations
+
+import argparse
+import copy
+import hashlib
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+import torch
+from torch import nn
+
+import koopman_graph.adaptation as adaptation
+from koopman_graph.analysis.criticality import CriticalityReport
+from koopman_graph.analysis.dispersion import graph_dispersion
+from koopman_graph.baselines.hankel_dmd import delay_embed_rows
+from koopman_graph.baselines.mpedmd import _hermitian_sqrt_factors
+from koopman_graph.benchmark import (
+ SCHEMA_VERSION,
+ ComputeBudget,
+ DatasetRef,
+ ExperimentManifest,
+ ManifestError,
+ MethodSpec,
+ OODShiftSpec,
+ PreprocessingSpec,
+ SplitSpec,
+ UQSpec,
+ dump_manifest,
+ load_manifest,
+ manifest_from_mapping,
+)
+from koopman_graph.cli.benchmark import handle_benchmark_run
+from koopman_graph.identification import (
+ IdentificationConfig,
+ IdentificationReport,
+ LatentPairs,
+ OperatorSnapshot,
+ SpectralReliabilityBlock,
+)
+from koopman_graph.nn.receptive_field import (
+ check_encoder_operator_receptive_field,
+)
+from koopman_graph.nn.separable import (
+ SeparableDictionaryDecoder,
+ is_separable_dictionary,
+)
+from koopman_graph.operators import GraphKoopmanOperator
+from koopman_graph.operators.polynomial_graph import (
+ apply_monomial_powers,
+ dense_polynomial_kronecker,
+)
+from koopman_graph.uq import JointCoverageSpec, energy_score, gaussian_nll
+from koopman_graph.uq.scores import _reduce
+
+_DIGEST = hashlib.sha256(b"fixture-bytes").hexdigest()
+
+
+def _dataset() -> DatasetRef:
+ """Return a valid dataset reference."""
+ return DatasetRef(
+ name="toy-path",
+ version="1",
+ sha256=_DIGEST,
+ card="docs/data/toy.md",
+ )
+
+
+def _manifest(**overrides: object) -> ExperimentManifest:
+ """Build a valid telemetry manifest."""
+ payload: dict[str, object] = {
+ "manifest_id": "smoke-telemetry",
+ "schema_version": SCHEMA_VERSION,
+ "track": "telemetry",
+ "dataset": _dataset(),
+ "split": SplitSpec(0.7, 0.1, 0.2, history_len=12),
+ "preprocessing": PreprocessingSpec(zscore=True),
+ "methods": (MethodSpec(name="graph_koopman", role="koopman"),),
+ "seeds": (0, 1, 2),
+ "horizons": (1, 3, 12),
+ "metrics": ("mae", "rmse"),
+ "compute_budget": ComputeBudget(max_epochs=2),
+ "controls": ("pernode",),
+ }
+ payload.update(overrides)
+ return ExperimentManifest(**payload) # type: ignore[arg-type]
+
+
+def test_adaptation_lazy_exports_and_unknown_name() -> None:
+ """Joint-observer names resolve lazily; unknown names raise."""
+ assert "JointObserverResult" in dir(adaptation)
+ result = adaptation.JointObserverResult
+ observer = adaptation.JointStateTopologyObserver
+ assert result.__name__ == "JointObserverResult"
+ assert observer.__name__ == "JointStateTopologyObserver"
+ with pytest.raises(AttributeError, match="has no attribute"):
+ _ = adaptation.NotAnExport
+
+
+def test_benchmark_cli_run_reports_io_errors(tmp_path, capsys) -> None:
+ """``benchmark run`` returns 1 when the manifest is missing."""
+ args = argparse.Namespace(
+ manifest=str(tmp_path / "missing.yaml"),
+ data=str(tmp_path / "data.bin"),
+ out=str(tmp_path / "out"),
+ )
+ assert handle_benchmark_run(args) == 1
+ captured = capsys.readouterr()
+ assert "error:" in captured.err
+
+
+def test_mpedmd_empty_gram_and_zero_rank_raise() -> None:
+ """Truncated Gram square-root refuses empty or rank-zero factors."""
+ gram = torch.zeros(2, 2)
+ with pytest.raises(ValueError, match="empty"):
+ _hermitian_sqrt_factors(gram, rank=None)
+ identity = torch.eye(2)
+ with pytest.raises(ValueError, match="empty"):
+ _hermitian_sqrt_factors(identity, rank=0)
+ sqrt, inv_sqrt = _hermitian_sqrt_factors(identity, rank=1)
+ assert sqrt.shape == (2, 2)
+ assert inv_sqrt.shape == (2, 2)
+
+
+def test_identification_config_select_on_guards() -> None:
+ """``select_on`` must be a non-empty string sequence; gate is a bool."""
+ with pytest.raises(ValueError, match="sequence of strings"):
+ IdentificationConfig(select_on="rollout") # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="non-empty strings"):
+ IdentificationConfig(select_on=("rollout", ""))
+ IdentificationConfig(select_on=["rollout"])
+ with pytest.raises(ValueError, match="gate_resdmd must be a bool"):
+ IdentificationConfig(gate_resdmd=1) # type: ignore[arg-type]
+
+
+def test_latent_pairs_and_operator_snapshot_guards() -> None:
+ """Latent pair dtype/device and empty snapshots raise."""
+ left = torch.zeros(3, 2)
+ with pytest.raises(ValueError, match="dtype"):
+ LatentPairs(z_t=left, z_next=left.to(dtype=torch.float64))
+ if torch.cuda.is_available():
+ with pytest.raises(ValueError, match="device"):
+ LatentPairs(z_t=left, z_next=left.cuda())
+ with pytest.raises(TypeError, match="Tensor or None"):
+ OperatorSnapshot(matrix="dense") # type: ignore[arg-type]
+
+
+def test_identification_report_field_guards() -> None:
+ """Report blocks reject non-bool pollution and bad rejected names."""
+ with pytest.raises(ValueError, match="finite float"):
+ SpectralReliabilityBlock(residual_max="high") # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="polluted"):
+ SpectralReliabilityBlock(polluted="yes") # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="sequence of strings"):
+ IdentificationReport(rejected_alternatives="rank-2") # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="non-empty strings"):
+ IdentificationReport(rejected_alternatives=("rank-2", ""))
+ IdentificationReport(rejected_alternatives=["rank-2"])
+
+
+def test_joint_coverage_spec_rejects_unknown_target_and_block() -> None:
+ """Named coverage spec validates target, alpha, and block."""
+ with pytest.raises(ValueError, match="coverage target"):
+ JointCoverageSpec(target="joint_box") # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="coverage alpha"):
+ JointCoverageSpec(alpha=1.5)
+ with pytest.raises(ValueError, match="coverage block"):
+ JointCoverageSpec(block="spatial") # type: ignore[arg-type]
+
+
+def test_score_reductions_and_energy_shape_guards() -> None:
+ """Score reductions and energy-score ndim / pairwise expand."""
+ values = torch.tensor([1.0, 3.0])
+ assert torch.equal(_reduce(values, "none"), values)
+ assert float(_reduce(values, "sum")) == pytest.approx(4.0)
+ observation = torch.zeros(2, 3)
+ samples = torch.randn(4, 2, 3)
+ score = energy_score(observation, samples, reduction="none")
+ assert score.shape == (2,)
+ with pytest.raises(ValueError, match="\\(S, ..., d\\)"):
+ energy_score(torch.tensor(0.0), torch.tensor([1.0, 2.0]))
+ nll = gaussian_nll(torch.zeros(3), torch.zeros(3), torch.ones(3), reduction="sum")
+ assert nll.ndim == 0
+
+
+def test_manifest_io_rejects_non_mapping_and_bad_suffix(tmp_path) -> None:
+ """Manifest load/dump reject lists, unknown suffixes, and YAML-import gaps."""
+ json_list = tmp_path / "list.json"
+ json_list.write_text("[1, 2]\n", encoding="utf-8")
+ with pytest.raises(ManifestError, match="mapping"):
+ load_manifest(json_list)
+ with pytest.raises(ManifestError, match="Unsupported manifest suffix"):
+ dump_manifest(_manifest(), tmp_path / "manifest.txt")
+ monkey_yaml = pytest.MonkeyPatch()
+ try:
+ monkey_yaml.delitem(__import__("sys").modules, "yaml", raising=False)
+ import builtins
+
+ real_import = builtins.__import__
+
+ def _block_yaml(name, *args, **kwargs):
+ if name == "yaml":
+ raise ImportError("blocked")
+ return real_import(name, *args, **kwargs)
+
+ monkey_yaml.setattr(builtins, "__import__", _block_yaml)
+ with pytest.raises(ImportError, match="PyYAML"):
+ dump_manifest(_manifest(), tmp_path / "manifest.yaml")
+ finally:
+ monkey_yaml.undo()
+
+
+def test_schema_validation_error_paths() -> None:
+ """Manifest helpers reject malformed nested fields."""
+ with pytest.raises(ManifestError, match="must be a mapping"):
+ manifest_from_mapping([]) # type: ignore[arg-type]
+ with pytest.raises(ManifestError, match="keys must be strings"):
+ manifest_from_mapping({1: "x"}) # type: ignore[dict-item]
+ with pytest.raises(ManifestError, match="non-empty string"):
+ DatasetRef(name=" ", version="1", sha256=_DIGEST, card="card.md")
+ with pytest.raises(ManifestError, match="sequence of strings"):
+ PreprocessingSpec(notes="zscore") # type: ignore[arg-type]
+ with pytest.raises(ManifestError, match="non-empty strings"):
+ PreprocessingSpec(notes=("",))
+ with pytest.raises(ManifestError, match="sequence of ints"):
+ _manifest(seeds="012")
+ with pytest.raises(ManifestError, match="must contain ints"):
+ _manifest(seeds=(0, 1, 2.0))
+ with pytest.raises(ManifestError, match=">= 0"):
+ _manifest(seeds=(-1, 0, 1))
+ with pytest.raises(ManifestError, match="must be a float"):
+ SplitSpec("0.7", 0.1, 0.2) # type: ignore[arg-type]
+ with pytest.raises(ManifestError, match="0 < ratio"):
+ SplitSpec(0.0, 0.5, 0.5)
+ with pytest.raises(ManifestError, match="sum to 1"):
+ SplitSpec(0.5, 0.5, 0.5)
+ with pytest.raises(ManifestError, match="history_len"):
+ SplitSpec(0.7, 0.1, 0.2, history_len=0)
+ with pytest.raises(ManifestError, match="method.role"):
+ MethodSpec(name="graph_koopman", role="oracle") # type: ignore[arg-type]
+ with pytest.raises(ManifestError, match="ood.description"):
+ OODShiftSpec(name="night", kind="time", description=1) # type: ignore[arg-type]
+ with pytest.raises(ManifestError, match="uq.coverage"):
+ UQSpec(method="conformal", coverage="0.9") # type: ignore[arg-type]
+ with pytest.raises(ManifestError, match="max_epochs"):
+ ComputeBudget(max_epochs=0)
+ with pytest.raises(ManifestError, match="schema_version"):
+ _manifest(schema_version="benchmark_manifest_v0")
+ with pytest.raises(ManifestError, match="track must be"):
+ _manifest(track="libcity")
+ with pytest.raises(ManifestError, match="dataset must be"):
+ _manifest(dataset={"name": "toy"})
+ with pytest.raises(ManifestError, match="split must be"):
+ _manifest(split={"train_ratio": 0.7})
+ with pytest.raises(ManifestError, match="preprocessing must be"):
+ _manifest(preprocessing={"zscore": True})
+ with pytest.raises(ManifestError, match="compute_budget must be"):
+ _manifest(compute_budget={"max_epochs": 2})
+ with pytest.raises(ManifestError, match="uq must be"):
+ _manifest(uq={"method": "conformal"})
+ with pytest.raises(ManifestError, match="methods must be"):
+ _manifest(methods="graph_koopman")
+ with pytest.raises(ManifestError, match="methods must be a non-empty"):
+ _manifest(methods=())
+ with pytest.raises(ManifestError, match="MethodSpec"):
+ _manifest(methods=({"name": "graph_koopman", "role": "koopman"},))
+ with pytest.raises(ManifestError, match="horizons must be a non-empty"):
+ _manifest(horizons=())
+ with pytest.raises(ManifestError, match="metrics must be a non-empty"):
+ _manifest(metrics=())
+ with pytest.raises(ManifestError, match="subset"):
+ _manifest(metrics=("mae", "madeup"))
+ with pytest.raises(ManifestError, match="ood_shifts must be"):
+ _manifest(ood_shifts="night")
+
+ mapping = {
+ "manifest_id": "smoke",
+ "schema_version": SCHEMA_VERSION,
+ "track": "telemetry",
+ "dataset": {
+ "name": "toy",
+ "version": "1",
+ "sha256": _DIGEST,
+ "card": "card.md",
+ },
+ "split": {"train_ratio": 0.7, "val_ratio": 0.1, "test_ratio": 0.2},
+ "methods": [{"name": "graph_koopman", "role": "koopman"}],
+ "seeds": [0, 1, 2],
+ "horizons": [1, 3],
+ "metrics": ["mae"],
+ "compute_budget": {"max_epochs": 2},
+ "controls": ["pernode"],
+ }
+ incomplete = copy.deepcopy(mapping)
+ del incomplete["dataset"]["card"] # type: ignore[index]
+ with pytest.raises(ManifestError, match="dataset missing"):
+ manifest_from_mapping(incomplete)
+ split_missing = copy.deepcopy(mapping)
+ split_missing["split"] = {"train_ratio": 0.7, "val_ratio": 0.1}
+ with pytest.raises(ManifestError, match="split missing"):
+ manifest_from_mapping(split_missing)
+ method_missing = copy.deepcopy(mapping)
+ method_missing["methods"] = [{"name": "graph_koopman"}]
+ with pytest.raises(ManifestError, match="method missing"):
+ manifest_from_mapping(method_missing)
+ ood_missing = copy.deepcopy(mapping)
+ ood_missing["ood_shifts"] = [{"name": "night"}]
+ with pytest.raises(ManifestError, match="ood_shift missing"):
+ manifest_from_mapping(ood_missing)
+ uq_missing = copy.deepcopy(mapping)
+ uq_missing["uq"] = {"coverage": 0.9}
+ with pytest.raises(ManifestError, match="uq missing"):
+ manifest_from_mapping(uq_missing)
+ budget_missing = copy.deepcopy(mapping)
+ budget_missing["compute_budget"] = {"device": "cpu"}
+ with pytest.raises(ManifestError, match="compute_budget missing"):
+ manifest_from_mapping(budget_missing)
+ keys_missing = copy.deepcopy(mapping)
+ del keys_missing["seeds"]
+ with pytest.raises(ManifestError, match="manifest missing"):
+ manifest_from_mapping(keys_missing)
+ with pytest.raises(ManifestError, match="methods must be a sequence"):
+ manifest_from_mapping({**mapping, "methods": "graph_koopman"})
+ with pytest.raises(ManifestError, match="ood_shifts must be a sequence"):
+ manifest_from_mapping({**mapping, "ood_shifts": "night"})
+
+
+def test_polynomial_graph_shape_and_min_power_guards() -> None:
+ """Polynomial assembly and matvec helpers reject mismatched hops."""
+ adjacency = torch.eye(2)
+ hops = (torch.eye(2), torch.ones(2, 3))
+ with pytest.raises(ValueError, match="hop_matrices\\[1\\]"):
+ dense_polynomial_kronecker(adjacency, hops)
+ states = torch.zeros(3, 2)
+ with pytest.raises(ValueError, match="min_power"):
+ apply_monomial_powers(states, (torch.eye(2),), lambda z: z, min_power=0)
+ empty = apply_monomial_powers(states, (), lambda z: z, min_power=1)
+ assert torch.equal(empty, states)
+ with pytest.raises(ValueError, match="hop_matrices\\[0\\]"):
+ apply_monomial_powers(states, (torch.ones(3, 3),), lambda z: z)
+
+
+def test_receptive_field_unwraps_and_skips_bad_metadata() -> None:
+ """Hop inference ignores bools, negatives, and raising getters."""
+
+ class RaisingEncoder(nn.Module):
+ def receptive_field_hops(self) -> int:
+ raise TypeError("broken hops")
+
+ class Diffusive(nn.Module):
+ diffusion_steps = 2
+ num_layers = 3
+
+ class Wrapped(nn.Module):
+ def __init__(self) -> None:
+ super().__init__()
+ self.base_encoder = Diffusive()
+
+ class NegativeHops(nn.Module):
+ def receptive_field_hops(self) -> int:
+ return -1
+
+ class RaisingOperator(nn.Module):
+ def receptive_field_hops(self) -> int:
+ raise ValueError("broken operator hops")
+
+ silent = check_encoder_operator_receptive_field(RaisingEncoder(), nn.Linear(2, 2))
+ assert silent.encoder_hops is None
+ hops = check_encoder_operator_receptive_field(Wrapped(), RaisingOperator())
+ assert hops.encoder_hops == 6
+ assert hops.operator_hops is None
+ skipped = check_encoder_operator_receptive_field(NegativeHops(), nn.Identity())
+ assert skipped.encoder_hops is None
+
+
+def test_separable_kind_flag_and_decoder_hops() -> None:
+ """A duck-typed separable wrapper and decoder hops are recognized."""
+ wrapper = SimpleNamespace(encoder_kind="separable", base_encoder=None)
+ assert is_separable_dictionary(wrapper)
+ decoder = SeparableDictionaryDecoder(3, 4, 2, num_layers=3, activation="sigmoid")
+ assert decoder.receptive_field_hops() == 0
+
+
+def test_criticality_report_and_dispersion_none_path() -> None:
+ """Criticality validation and a mocked ineligible dispersion raise."""
+ gap = torch.tensor([0.2, 0.1])
+ with pytest.raises(ValueError, match="shape \\(T,\\)"):
+ CriticalityReport(
+ spectral_gap=gap,
+ gap_closure_rate=torch.tensor([0.1]),
+ max_gap_closure_rate=0.1,
+ near_defective=torch.tensor([True]),
+ )
+ with pytest.raises(ValueError, match="boolean tensor"):
+ CriticalityReport(
+ spectral_gap=gap,
+ gap_closure_rate=torch.tensor([0.1]),
+ max_gap_closure_rate=0.1,
+ near_defective=torch.tensor([1, 0]),
+ )
+ with pytest.raises(ValueError, match="length <= T"):
+ CriticalityReport(
+ spectral_gap=gap,
+ gap_closure_rate=torch.tensor([0.1, 0.2, 0.3]),
+ max_gap_closure_rate=0.3,
+ near_defective=torch.zeros(2, dtype=torch.bool),
+ )
+ with pytest.raises(ValueError, match="non-negative"):
+ CriticalityReport(
+ spectral_gap=torch.tensor([-0.1, 0.2]),
+ gap_closure_rate=torch.tensor([0.1]),
+ max_gap_closure_rate=0.1,
+ near_defective=torch.zeros(2, dtype=torch.bool),
+ )
+ with pytest.raises(ValueError, match="must not be NaN"):
+ CriticalityReport(
+ spectral_gap=gap,
+ gap_closure_rate=torch.tensor([0.1]),
+ max_gap_closure_rate=float("nan"),
+ near_defective=torch.zeros(2, dtype=torch.bool),
+ )
+
+ import koopman_graph.analysis.criticality as crit_mod
+
+ with pytest.raises(ValueError, match="at least two"):
+ crit_mod._pairwise_spectral_gap(torch.tensor([1.0]))
+ with pytest.raises(ValueError, match="must be finite"):
+ crit_mod._pairwise_spectral_gap(torch.tensor([1.0, float("nan")]))
+
+ import koopman_graph.analysis.dispersion as disp_mod
+
+ operator = GraphKoopmanOperator(2, init_mode="identity")
+ edges = torch.tensor([[0, 1], [1, 0]], dtype=torch.long)
+ with (
+ pytest.raises(ValueError, match="not eligible"),
+ pytest.MonkeyPatch.context() as monkey,
+ ):
+ monkey.setattr(disp_mod, "spectrum_k_eff_kronecker_sum", lambda **_: None)
+ graph_dispersion(operator, edges, 2)
+
+ spectrum = MagicMock()
+ spectrum.eigenvalues = torch.ones(2)
+ spectrum.growth_rates.real = torch.ones(8)
+ with pytest.MonkeyPatch.context() as monkey:
+ monkey.setattr(disp_mod, "spectrum_k_eff_kronecker_sum", lambda **_: spectrum)
+ relation = graph_dispersion(operator, edges, 2)
+ assert relation.growth_rates.numel() == 2
+
+
+def test_hankel_delay_matrix_rejects_non_matrix_states() -> None:
+ """Hankel assembly requires a 2-D state matrix."""
+ with pytest.raises(ValueError, match="2-D"):
+ delay_embed_rows(torch.zeros(3), n_delays=2)
diff --git a/tests/package/test_v015_coverage_gaps_extra.py b/tests/package/test_v015_coverage_gaps_extra.py
new file mode 100644
index 0000000..b3e84c3
--- /dev/null
+++ b/tests/package/test_v015_coverage_gaps_extra.py
@@ -0,0 +1,877 @@
+"""Extra release-gate coverage for 0.15 modules still under 90%.
+
+Leftover lines after this file plus the existing scientific suites are
+defensive or unreachable through public APIs:
+
+* ``mpc/tube.py`` ``564-565`` — ``y_min`` / ``y_max`` are required kwargs.
+* ``nn/predicted_topology.py`` ``152``, ``158-159``, ``559``, ``565-566``,
+ ``641`` — empty candidate / bucket paths cannot occur for ``N >= 2``
+ and ``k >= 1``; ``916-917`` is guarded by ``resolve_topology_policy``.
+* ``analysis/causal_intervention.py`` ``241-242`` — public samplers always
+ pass ``intervene_value`` when a source is set.
+* ``nn/constraint_decoders.py`` ``61`` — empty ``channels`` raise in
+ ``_ConstraintHead`` before ``_as_channel_index``.
+* ``adaptation/joint_observer.py`` ``136-140`` — non-dense graph
+ parameterization is refused at construction.
+* ``baselines/gedmd.py`` ``213-217``, ``282-283`` — stacked ``dx_dt``
+ always matches ``x``, and ``GraphSnapshotSequence`` refuses ``T = 0``.
+* ``baselines/hankel_dmd.py`` ``285`` — ``_require_operator`` raises
+ before the ``state_dim is None`` check on an unfitted baseline.
+"""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import numpy as np
+import pytest
+import torch
+from tests.adaptation.test_joint_observer import _sequence
+from tests.mpc.test_mpc import _identity_plant_model, _origin
+from torch import nn
+from torch_geometric.data import Data
+
+from koopman_graph import GraphKoopmanModel
+from koopman_graph.adaptation import JointStateTopologyObserver
+from koopman_graph.analysis.causal_intervention import (
+ SyntheticInterventionReport,
+ SyntheticSCM,
+ recover_synthetic_interventional_edges,
+ sample_synthetic_intervention,
+ sample_synthetic_observational,
+ teaching_three_node_scm,
+)
+from koopman_graph.analysis.hodge_modes import (
+ HodgeModeComponents,
+ hodge_decompose_modes,
+)
+from koopman_graph.analysis.memory import (
+ LAG_UNIT,
+ FiniteMemoryKoopman,
+ MarkovClosureReport,
+ markov_closure_report,
+)
+from koopman_graph.baselines import GEDMDBaseline, HankelDMDBaseline
+from koopman_graph.baselines.gedmd import polynomial_observable_derivatives
+from koopman_graph.baselines.hankel_dmd import assemble_delay_state, delay_embed_rows
+from koopman_graph.data import (
+ EntityRemap,
+ GraphDynamicsConfig,
+ GraphSnapshotSequence,
+ remap_node_features,
+)
+from koopman_graph.mpc import TubeKoopmanMPC, TubeMPCReport, ensemble_residual_radii
+from koopman_graph.nn import (
+ LinearConservingDecoder,
+ MassConservingDecoder,
+ PositivityDecoder,
+ PredictedTopologyHead,
+ PresenceHead,
+ SparseCandidateTopologyHead,
+ build_candidate_index,
+ build_supervision_index,
+)
+from koopman_graph.nn.constraint_decoders import project_linear_conservation
+from koopman_graph.nn.predicted_topology import (
+ candidate_edge_labels,
+ decode_weighted_topology,
+ resolve_rollout_topology_at,
+ resolve_topology_policy,
+)
+from koopman_graph.nn.separable import (
+ SeparableDictionaryDecoder,
+ SeparableDictionaryEncoder,
+)
+from koopman_graph.spectrum_types import KoopmanSpectrum
+from koopman_graph.uq import JointCoverageSpec
+from koopman_graph.uq.common import PredictionInterval, snapshot_with_features
+
+
+def _coverage() -> JointCoverageSpec:
+ """Return the shipped residual-tube coverage spec."""
+ return JointCoverageSpec()
+
+
+def _two_node_edges() -> torch.Tensor:
+ """Return a bidirectional two-node edge index."""
+ return torch.tensor([[0, 1], [1, 0]], dtype=torch.long)
+
+
+def _path_edges(num_nodes: int) -> torch.Tensor:
+ """Return an oriented path on ``num_nodes`` vertices."""
+ tails = torch.arange(num_nodes - 1, dtype=torch.long)
+ return torch.stack((tails, tails + 1), dim=0)
+
+
+def _interval_template() -> Data:
+ """Return a two-node template snapshot."""
+ return Data(x=torch.zeros(2, 2), edge_index=_two_node_edges())
+
+
+def _real_spectrum(columns: torch.Tensor) -> KoopmanSpectrum:
+ """Build a spectrum whose eigenvectors stay real."""
+ modes = int(columns.shape[1])
+ eigenvalues = torch.ones(modes)
+ return KoopmanSpectrum(
+ eigenvalues=eigenvalues,
+ eigenvectors=columns,
+ magnitudes=eigenvalues.abs(),
+ growth_rates=torch.zeros(modes),
+ frequencies=torch.zeros(modes),
+ time_step=1.0,
+ )
+
+
+def _gedmd_sequence(
+ states: list[torch.Tensor],
+ edge_index: torch.Tensor,
+ *,
+ derivatives: list[torch.Tensor] | None = None,
+) -> GraphSnapshotSequence:
+ """Build a two-node scalar-feature sequence for gEDMD guards."""
+ snapshots = []
+ for index, state in enumerate(states):
+ payload: dict[str, torch.Tensor] = {
+ "x": state.reshape(2, 1),
+ "edge_index": edge_index,
+ }
+ if derivatives is not None:
+ payload["dx_dt"] = derivatives[index].reshape(2, 1)
+ snapshots.append(Data(**payload))
+ return GraphSnapshotSequence(snapshots)
+
+
+class _ChannelCopyDecoder(nn.Module):
+ """Copy the first two latent columns as decoded features."""
+
+ def forward(
+ self,
+ z: torch.Tensor,
+ edge_index: torch.Tensor,
+ edge_weight: torch.Tensor | None = None,
+ ) -> torch.Tensor:
+ del edge_index, edge_weight
+ return z[:, :2]
+
+
+# ---------------------------------------------------------------------------
+# mpc/tube.py
+# ---------------------------------------------------------------------------
+
+
+def test_ensemble_residual_radii_reject_empty_and_mismatched_bands() -> None:
+ """Empty or length-mismatched prediction bands raise."""
+ empty = PredictionInterval(
+ mean=(),
+ lower=(),
+ upper=(),
+ level=0.9,
+ n_members=1,
+ )
+ with pytest.raises(ValueError, match="at least one forecast step"):
+ ensemble_residual_radii(empty)
+ template = _interval_template()
+ band = snapshot_with_features(template, torch.zeros(2, 2))
+ mismatched = PredictionInterval(
+ mean=(band,),
+ lower=(band,),
+ upper=(band, band),
+ level=0.9,
+ n_members=2,
+ )
+ with pytest.raises(ValueError, match="lengths differ"):
+ ensemble_residual_radii(mismatched)
+
+
+def test_ensemble_residual_radii_reject_missing_features_and_negative_width() -> None:
+ """Homogeneous ``x`` is required; half-widths must be non-negative."""
+ template = _interval_template()
+ bare = Data(edge_index=template.edge_index)
+ featured = snapshot_with_features(template, torch.zeros(2, 2))
+ missing = PredictionInterval(
+ mean=(featured,),
+ lower=(bare,),
+ upper=(featured,),
+ level=0.9,
+ n_members=1,
+ )
+ with pytest.raises(TypeError, match="homogeneous node features"):
+ ensemble_residual_radii(missing)
+ lower = snapshot_with_features(template, torch.ones(2, 2))
+ upper = snapshot_with_features(template, torch.zeros(2, 2))
+ inverted = PredictionInterval(
+ mean=(featured,),
+ lower=(lower,),
+ upper=(upper,),
+ level=0.9,
+ n_members=1,
+ )
+ with pytest.raises(ValueError, match="non-negative"):
+ ensemble_residual_radii(inverted)
+
+
+def test_tube_residual_source_shape_and_type_guards() -> None:
+ """Scalar, short, rank-2, rank-3, negative, and typed residuals are checked."""
+ pytest.importorskip("osqp")
+ model = _identity_plant_model()
+ y_min = torch.tensor([-2.0, -2.0])
+ y_max = torch.tensor([2.0, 2.0])
+ kwargs = {
+ "horizon": 2,
+ "Q": torch.eye(2),
+ "R": torch.eye(1),
+ "y_min": y_min,
+ "y_max": y_max,
+ }
+ scalar = TubeKoopmanMPC(model, residual_source=torch.tensor(0.05), **kwargs)
+ assert scalar.horizon == 2
+ assert scalar.model is model
+ rank2 = TubeKoopmanMPC(
+ model,
+ residual_source=torch.tensor([[0.05, 0.02], [0.04, 0.01]]),
+ **kwargs,
+ )
+ assert rank2.horizon == 2
+ with pytest.raises(ValueError, match="must cover horizon"):
+ TubeKoopmanMPC(model, residual_source=torch.tensor([0.1]), **kwargs)
+ with pytest.raises(ValueError, match="must cover horizon"):
+ TubeKoopmanMPC(
+ model,
+ residual_source=torch.ones(1, 2),
+ **kwargs,
+ )
+ with pytest.raises(ValueError, match="scalar, shape \\(H,\\)"):
+ TubeKoopmanMPC(model, residual_source=torch.ones(2, 2, 2), **kwargs)
+ with pytest.raises(ValueError, match="non-negative"):
+ TubeKoopmanMPC(model, residual_source=torch.tensor([-0.1, 0.1]), **kwargs)
+ with pytest.raises(TypeError, match="residual_source must be"):
+ TubeKoopmanMPC(model, residual_source="radii", **kwargs)
+
+
+def test_tube_report_validates_counts_and_rates() -> None:
+ """``TubeMPCReport`` refuses inconsistent counts and rates."""
+ coverage = _coverage()
+ with pytest.raises(ValueError, match="n_steps must be >= 1"):
+ TubeMPCReport(
+ violation_rate=0.0,
+ feasibility_rate=1.0,
+ cost=0.0,
+ n_steps=0,
+ n_feasible=0,
+ n_violations=0,
+ coverage=coverage,
+ )
+ with pytest.raises(ValueError, match="n_feasible must lie"):
+ TubeMPCReport(
+ violation_rate=0.0,
+ feasibility_rate=2.0,
+ cost=0.0,
+ n_steps=2,
+ n_feasible=3,
+ n_violations=0,
+ coverage=coverage,
+ )
+ with pytest.raises(ValueError, match="n_violations must lie"):
+ TubeMPCReport(
+ violation_rate=1.0,
+ feasibility_rate=1.0,
+ cost=0.0,
+ n_steps=2,
+ n_feasible=2,
+ n_violations=-1,
+ coverage=coverage,
+ )
+ with pytest.raises(ValueError, match="feasibility_rate must equal"):
+ TubeMPCReport(
+ violation_rate=0.0,
+ feasibility_rate=0.5,
+ cost=0.0,
+ n_steps=2,
+ n_feasible=2,
+ n_violations=0,
+ coverage=coverage,
+ )
+ with pytest.raises(ValueError, match="violation_rate must equal"):
+ TubeMPCReport(
+ violation_rate=0.5,
+ feasibility_rate=1.0,
+ cost=0.0,
+ n_steps=2,
+ n_feasible=2,
+ n_violations=0,
+ coverage=coverage,
+ )
+
+
+def test_tube_rollout_evaluate_and_infeasible_fallback() -> None:
+ """Rollout, ``steps < 1``, and clipped-zero fallback on an infeasible QP."""
+ pytest.importorskip("osqp")
+ model = _identity_plant_model()
+ y_min = torch.tensor([-2.0, -2.0])
+ y_max = torch.tensor([2.0, 2.0])
+ feasible = TubeKoopmanMPC(
+ model,
+ horizon=2,
+ Q=torch.eye(2),
+ R=0.05 * torch.eye(1),
+ residual_source=np.array(0.0),
+ y_min=y_min,
+ y_max=y_max,
+ u_min=torch.tensor([-1.0]),
+ u_max=torch.tensor([1.0]),
+ )
+ snapshots = feasible.rollout(_origin(), torch.tensor([0.0, 0.0]), steps=2)
+ assert len(snapshots) == 2
+ with pytest.raises(ValueError, match="steps must be >= 1"):
+ feasible.evaluate(_origin(), torch.tensor([0.0, 0.0]), steps=0)
+ with pytest.raises(ValueError, match="steps must be >= 1"):
+ feasible.rollout(_origin(), torch.tensor([0.0, 0.0]), steps=0)
+ infeasible = TubeKoopmanMPC(
+ model,
+ horizon=2,
+ Q=torch.eye(2),
+ R=torch.eye(1),
+ residual_source=torch.zeros(2),
+ y_min=torch.tensor([5.0, 5.0]),
+ y_max=torch.tensor([6.0, 6.0]),
+ u_min=torch.tensor([0.1]),
+ u_max=torch.tensor([0.2]),
+ )
+ report = infeasible.evaluate(_origin(), torch.tensor([0.0, 0.0]), steps=2)
+ assert report.feasibility_rate == pytest.approx(0.0)
+ assert report.n_feasible == 0
+
+
+# ---------------------------------------------------------------------------
+# nn/predicted_topology.py
+# ---------------------------------------------------------------------------
+
+
+def test_topology_heads_cover_self_loops_empty_keep_and_presence() -> None:
+ """Self-loops, empty keep, presence guards, and dense thresholding."""
+ loops = torch.tensor([[0, 0, 1], [0, 1, 1]], dtype=torch.long)
+ index = build_candidate_index(
+ 3, 2, loops, generator=torch.Generator().manual_seed(0)
+ )
+ assert int((index[0] == index[1]).sum()) == 0
+ head = PredictedTopologyHead(3, hidden_dim=8)
+ z = torch.randn(4, 3)
+ kept = head.edge_index(z, threshold=-1e9)
+ assert kept.shape[0] == 2
+ assert kept.shape[1] > 0
+ sparse = SparseCandidateTopologyHead(3, hidden_dim=8, candidate_k=2)
+ candidates = torch.tensor([[0, 1], [1, 0]], dtype=torch.long)
+ with pytest.raises(ValueError, match="shape \\(2, E\\)"):
+ sparse.pair_logits(z[:3], torch.arange(4))
+ empty_keep = sparse.edge_index(z[:3], candidates, threshold=1e9)
+ assert empty_keep.shape == (2, 3)
+ with pytest.raises(ValueError, match="latent_dim"):
+ PresenceHead(0)
+ presence = PresenceHead(3)
+ assert presence(z).shape == (4,)
+ with pytest.raises(ValueError, match="shape \\(N, d\\)"):
+ presence(torch.randn(3))
+ with pytest.raises(ValueError, match="latent width"):
+ presence(torch.randn(4, 2))
+
+
+def test_supervision_labels_decode_and_rollout_policy_guards() -> None:
+ """Supervision COO, labels, decode, and rollout-policy error paths."""
+ with pytest.raises(ValueError, match="num_nodes"):
+ build_supervision_index(1, 2, None, _path_edges(2))
+ with pytest.raises(ValueError, match="candidate_k"):
+ build_supervision_index(3, 0, None, _path_edges(3))
+ with pytest.raises(ValueError, match="edge_index must have shape"):
+ build_supervision_index(3, 2, torch.arange(3), _path_edges(3))
+ next_index = torch.tensor([[0, 1, 2], [0, 2, 1]], dtype=torch.long)
+ current = torch.tensor([[0, 1], [2, 2]], dtype=torch.long)
+ overlap = build_supervision_index(
+ 3,
+ 2,
+ current,
+ next_index,
+ device="cpu",
+ generator=torch.Generator().manual_seed(2),
+ )
+ assert overlap.shape[0] == 2
+ supervised = build_supervision_index(
+ 3,
+ 2,
+ None,
+ next_index,
+ generator=torch.Generator().manual_seed(1),
+ )
+ assert supervised.shape[0] == 2
+ labels = candidate_edge_labels(supervised, next_index, 3)
+ assert labels.shape[0] == int(supervised.shape[1])
+ with pytest.raises(ValueError, match="candidate_index must have shape"):
+ candidate_edge_labels(torch.arange(3), next_index, 3)
+ with pytest.raises(ValueError, match="true_next_index must have shape"):
+ candidate_edge_labels(supervised, torch.arange(3), 3)
+ empty = torch.empty(2, 0, dtype=torch.long)
+ assert candidate_edge_labels(empty, next_index, 3).shape == (0,)
+ zeros = candidate_edge_labels(supervised, empty, 3)
+ assert torch.equal(zeros, torch.zeros(int(supervised.shape[1])))
+ sparse = SparseCandidateTopologyHead(2, hidden_dim=4, candidate_k=2)
+ dense = PredictedTopologyHead(2, hidden_dim=4)
+ z = torch.randn(3, 2)
+ edges, weights = decode_weighted_topology(sparse, z, _path_edges(3))
+ assert edges.shape[0] == 2
+ assert weights.ndim == 1
+ dense_edges, dense_weights = decode_weighted_topology(dense, z, _path_edges(3))
+ assert dense_edges.shape[1] == 3 * 2
+ assert dense_weights.shape == (6,)
+ with pytest.raises(TypeError, match="PredictedTopologyHead or"):
+ decode_weighted_topology(nn.Linear(2, 1), z, _path_edges(3))
+ dummy = SimpleNamespace(predicted_topology=None, graph_dynamics=None)
+ with pytest.raises(ValueError, match="topology_policy must be one of"):
+ resolve_topology_policy(dummy, "oracle")
+ assert resolve_topology_policy(dummy, "hold_last") == "hold_last"
+ with pytest.raises(ValueError, match="requires a predicted topology head"):
+ resolve_topology_policy(dummy, "recursive")
+ origin = _path_edges(3)
+ hold = resolve_rollout_topology_at(dummy, origin, None, topology_policy="hold_last")
+ held = hold(0)
+ assert held[0].shape[0] == 2
+ future = [Data(x=torch.zeros(3, 1), edge_index=_path_edges(3))]
+ oracle = resolve_rollout_topology_at(dummy, origin, None, future_topologies=future)
+ assert callable(oracle)
+ with pytest.raises(ValueError, match="requires a predicted topology head"):
+ resolve_rollout_topology_at(dummy, origin, None, topology_policy="recursive")
+ wrong = SimpleNamespace(predicted_topology=nn.Identity(), graph_dynamics=None)
+ with pytest.raises(TypeError, match="predicted_topology must be"):
+ resolve_rollout_topology_at(wrong, origin, None, topology_policy="recursive")
+ configured = SimpleNamespace(
+ predicted_topology=sparse,
+ graph_dynamics=GraphDynamicsConfig(recursive_training=True),
+ )
+ recursive = resolve_rollout_topology_at(
+ configured,
+ origin,
+ None,
+ topology_policy="auto",
+ )
+ predicted = recursive(0, z)
+ assert predicted[0].shape[0] == 2
+ hold_auto = SimpleNamespace(
+ predicted_topology=sparse,
+ graph_dynamics=GraphDynamicsConfig(recursive_training=False),
+ )
+ assert resolve_topology_policy(hold_auto, "auto") == "hold_last"
+
+
+# ---------------------------------------------------------------------------
+# analysis/causal_intervention.py
+# ---------------------------------------------------------------------------
+
+
+def test_synthetic_scm_and_report_reject_invalid_weights_and_flags() -> None:
+ """Square-weight, noise, flag, and report guards raise."""
+ with pytest.raises(ValueError, match="shape \\(N, N\\)"):
+ SyntheticSCM(weights=torch.zeros(3), noise_scale=0.1, seed=0)
+ with pytest.raises(ValueError, match="N >= 2"):
+ SyntheticSCM(weights=torch.zeros(1, 1), noise_scale=0.1, seed=0)
+ nan_weights = torch.zeros(2, 2)
+ nan_weights[0, 1] = float("nan")
+ with pytest.raises(ValueError, match="finite"):
+ SyntheticSCM(weights=nan_weights, noise_scale=0.1, seed=0)
+ diag = torch.eye(2)
+ with pytest.raises(ValueError, match="zero diagonal"):
+ SyntheticSCM(weights=diag, noise_scale=0.1, seed=0)
+ ok = torch.zeros(2, 2)
+ ok[0, 1] = 0.5
+ with pytest.raises(ValueError, match="noise_scale"):
+ SyntheticSCM(weights=ok, noise_scale=0.0, seed=0)
+ with pytest.raises(ValueError, match="labeled_synthetic must be True"):
+ SyntheticSCM(weights=ok, noise_scale=0.1, seed=0, labeled_synthetic=False)
+ scores = torch.zeros(2, 3)
+ with pytest.raises(ValueError, match="scores must have shape"):
+ SyntheticInterventionReport(
+ scores=scores,
+ recovered_edges=(),
+ true_edges=(),
+ threshold=0.2,
+ )
+ with pytest.raises(ValueError, match="threshold must be > 0"):
+ SyntheticInterventionReport(
+ scores=torch.zeros(2, 2),
+ recovered_edges=(),
+ true_edges=(),
+ threshold=0.0,
+ )
+ with pytest.raises(ValueError, match="labeled_synthetic must be True"):
+ SyntheticInterventionReport(
+ scores=torch.zeros(2, 2),
+ recovered_edges=(),
+ true_edges=(),
+ threshold=0.2,
+ labeled_synthetic=False,
+ )
+
+
+def test_synthetic_sampling_and_recovery_guards() -> None:
+ """Sample-count, intervention index, teaching weight, and threshold guards."""
+ with pytest.raises(ValueError, match="edge_weight must be nonzero"):
+ teaching_three_node_scm(edge_weight=0.0)
+ scm = teaching_three_node_scm(seed=2)
+ observed = sample_synthetic_observational(scm, 8)
+ assert observed.shape == (8, 3)
+ intervened = sample_synthetic_intervention(scm, source=0, value=0.5, n_samples=8)
+ assert intervened.shape == (8, 3)
+ with pytest.raises(ValueError, match="n_samples must be >= 1"):
+ sample_synthetic_observational(scm, 0)
+ with pytest.raises(ValueError, match="outside"):
+ sample_synthetic_intervention(scm, source=5, value=1.0, n_samples=4)
+ with pytest.raises(ValueError, match="threshold must be > 0"):
+ recover_synthetic_interventional_edges(scm, threshold=0.0)
+
+
+# ---------------------------------------------------------------------------
+# nn/constraint_decoders.py
+# ---------------------------------------------------------------------------
+
+
+def test_constraint_decoder_channel_and_table_guards() -> None:
+ """Channel indices and decoded-table validation raise."""
+ inner = _ChannelCopyDecoder()
+ with pytest.raises(ValueError, match="non-empty sequence"):
+ MassConservingDecoder(inner, channels=(), mass=1.0)
+ with pytest.raises(TypeError, match="nn.Module"):
+ MassConservingDecoder("decoder", channels=(0,), mass=1.0) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="must be ints"):
+ MassConservingDecoder(inner, channels=(True,), mass=1.0).project( # type: ignore[arg-type]
+ torch.randn(4, 2)
+ )
+ with pytest.raises(ValueError, match="unique"):
+ MassConservingDecoder(inner, channels=(0, 0), mass=1.0).project(
+ torch.randn(4, 2)
+ )
+ headed = MassConservingDecoder(inner, channels=(0,), mass=1.0)
+ with pytest.raises(TypeError, match="must be a Tensor"):
+ headed.project([1.0, 2.0]) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="must be real"):
+ headed.project(torch.ones(3, 2, dtype=torch.complex64))
+ with pytest.raises(ValueError, match="floating-point"):
+ headed.project(torch.ones(3, 2, dtype=torch.int64))
+ with pytest.raises(ValueError, match="both axes >= 1"):
+ headed.project(torch.ones(3))
+ bad = torch.ones(3, 2)
+ bad[0, 0] = float("nan")
+ with pytest.raises(ValueError, match="must be finite"):
+ headed.project(bad)
+ with pytest.raises(ValueError, match="finite float"):
+ MassConservingDecoder(inner, channels=(0,), mass="1.0") # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="mass must be finite"):
+ MassConservingDecoder(inner, channels=(0,), mass=float("inf"))
+
+
+def test_project_linear_conservation_and_positivity_forward() -> None:
+ """Linear conservation shape guards and positivity ``softplus`` / forward."""
+ values = torch.ones(4)
+ with pytest.raises(ValueError, match="shape \\(n_eq, n_nodes\\)"):
+ project_linear_conservation(values, torch.ones(4), torch.ones(1))
+ with pytest.raises(ValueError, match="shape \\(n_eq,\\)"):
+ project_linear_conservation(values, torch.ones(1, 4), torch.ones(2))
+ with pytest.raises(ValueError, match="at least one equation"):
+ project_linear_conservation(values, torch.ones(0, 4), torch.ones(0))
+ nan_c = torch.ones(1, 4)
+ nan_c[0, 0] = float("nan")
+ with pytest.raises(ValueError, match="constraint and target must be finite"):
+ project_linear_conservation(values, nan_c, torch.ones(1))
+ inner = _ChannelCopyDecoder()
+ positive = PositivityDecoder(inner, channels=(0,), method="softplus")
+ table = torch.tensor([[-1.0, 0.5], [0.0, -2.0], [2.0, 1.0], [-0.5, 0.0]])
+ projected = positive.project(table)
+ assert bool((projected[:, 0] >= 0).all().item())
+ decoded = positive(torch.randn(4, 3), _path_edges(4))
+ assert decoded.shape == (4, 2)
+ with pytest.raises(TypeError, match="must be tensors"):
+ LinearConservingDecoder(
+ inner,
+ channels=(0,),
+ constraint="C", # type: ignore[arg-type]
+ target=torch.ones(1),
+ )
+ with pytest.raises(ValueError, match="n_eq >= 1"):
+ LinearConservingDecoder(
+ inner,
+ channels=(0,),
+ constraint=torch.ones(3),
+ target=torch.ones(1),
+ )
+ with pytest.raises(ValueError, match="shape \\(n_eq,\\)"):
+ LinearConservingDecoder(
+ inner,
+ channels=(0,),
+ constraint=torch.ones(1, 4),
+ target=torch.ones(2),
+ )
+ inf_c = torch.ones(1, 4)
+ inf_c[0, 0] = float("inf")
+ with pytest.raises(ValueError, match="constraint and target must be finite"):
+ LinearConservingDecoder(
+ inner,
+ channels=(0,),
+ constraint=inf_c,
+ target=torch.ones(1),
+ )
+
+
+# ---------------------------------------------------------------------------
+# data/remap.py
+# ---------------------------------------------------------------------------
+
+
+def test_entity_remap_construction_and_snapshot_edge_cases() -> None:
+ """Empty union, bad index, missing ``x``, empty edges, and empty trajectories."""
+ with pytest.raises(ValueError, match="non-empty"):
+ EntityRemap(entity_ids=(), index=torch.tensor([0]))
+ with pytest.raises(ValueError, match="1-D tensor"):
+ EntityRemap(entity_ids=("a", "b"), index=torch.tensor([[0, 1]]))
+ with pytest.raises(ValueError, match="at least one source row"):
+ EntityRemap(entity_ids=("a",), index=torch.tensor([], dtype=torch.long))
+ remap = EntityRemap(entity_ids=("a", "b", "c"), index=torch.tensor([0, 2]))
+ with pytest.raises(ValueError, match="requires Data.x"):
+ remap.apply_snapshot(Data(edge_index=_path_edges(2)))
+ missing_edges = remap.apply_snapshot(Data(x=torch.ones(2, 1)))
+ assert missing_edges.edge_index.shape == (2, 0)
+ empty_edges = remap.apply_snapshot(
+ Data(x=torch.ones(2, 1), edge_index=torch.empty(2, 0, dtype=torch.long))
+ )
+ assert empty_edges.edge_index.shape == (2, 0)
+ with pytest.raises(ValueError, match="shape \\(2, E\\)"):
+ remap.apply_snapshot(Data(x=torch.ones(2, 1), edge_index=torch.arange(3)))
+ weighted = Data(
+ x=torch.ones(2, 1),
+ edge_index=torch.tensor([[0], [1]], dtype=torch.long),
+ edge_weight=torch.tensor([0.5]),
+ )
+ union = remap.apply_snapshot(weighted)
+ assert union.edge_weight is not None
+ with pytest.raises(ValueError, match="at least one snapshot"):
+ remap.apply_snapshots([])
+ features = torch.ones(2, 3)
+ with pytest.raises(ValueError, match="shape \\(N, F\\)"):
+ remap_node_features(
+ torch.ones(3),
+ old_index=torch.tensor([0, 1]),
+ new_capacity=3,
+ )
+ with pytest.raises(ValueError, match="1-D with length"):
+ remap_node_features(features, old_index=torch.tensor([0]), new_capacity=3)
+ with pytest.raises(ValueError, match="at least N_old"):
+ remap_node_features(features, old_index=torch.tensor([0, 1]), new_capacity=1)
+ with pytest.raises(ValueError, match="outside"):
+ remap_node_features(features, old_index=torch.tensor([0, 5]), new_capacity=3)
+
+
+# ---------------------------------------------------------------------------
+# analysis/memory.py
+# ---------------------------------------------------------------------------
+
+
+def test_markov_report_and_operator_type_guards() -> None:
+ """Report field ranges and typed constructor / series arguments raise."""
+ base = {
+ "autocorrelation": torch.zeros(2),
+ "ljung_box_statistic": 1.0,
+ "ljung_box_pvalue": 0.5,
+ "max_abs_autocorrelation": 0.1,
+ "max_lag": 2,
+ "n_timesteps": 20,
+ "n_fit_parameters": 0,
+ "alpha": 0.05,
+ "white": True,
+ "lag_unit": LAG_UNIT,
+ }
+ with pytest.raises(ValueError, match="shape \\(max_lag,\\)"):
+ MarkovClosureReport(**{**base, "autocorrelation": torch.zeros(3)})
+ with pytest.raises(ValueError, match="max_lag must be >= 1"):
+ MarkovClosureReport(**{**base, "autocorrelation": torch.zeros(0), "max_lag": 0})
+ with pytest.raises(ValueError, match="n_timesteps must exceed"):
+ MarkovClosureReport(**{**base, "n_timesteps": 2})
+ with pytest.raises(ValueError, match="n_fit_parameters must be >= 0"):
+ MarkovClosureReport(**{**base, "n_fit_parameters": -1})
+ with pytest.raises(ValueError, match="max_lag - n_fit_parameters"):
+ MarkovClosureReport(**{**base, "n_fit_parameters": 2})
+ with pytest.raises(ValueError, match="alpha must lie"):
+ MarkovClosureReport(**{**base, "alpha": 1.0})
+ with pytest.raises(ValueError, match="ljung_box_statistic must be finite"):
+ MarkovClosureReport(**{**base, "ljung_box_statistic": float("nan")})
+ with pytest.raises(ValueError, match="ljung_box_pvalue must lie"):
+ MarkovClosureReport(**{**base, "ljung_box_pvalue": 1.5})
+ with pytest.raises(ValueError, match="max_abs_autocorrelation must be finite"):
+ MarkovClosureReport(**{**base, "max_abs_autocorrelation": float("inf")})
+ series = torch.randn(40)
+ with pytest.raises(TypeError, match="must be a Tensor"):
+ markov_closure_report([1.0, 2.0, 3.0]) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="floating-point"):
+ markov_closure_report(torch.arange(20))
+ with pytest.raises(ValueError, match="positive int"):
+ markov_closure_report(series, max_lag=True) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="non-negative int"):
+ markov_closure_report(series, max_lag=4, n_fit_parameters=True) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="n_fit_parameters must be >= 0"):
+ markov_closure_report(series, max_lag=4, n_fit_parameters=-1)
+ with pytest.raises(ValueError, match="latent_dim must be a positive int"):
+ FiniteMemoryKoopman(latent_dim=True) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="memory_order must be a positive int"):
+ FiniteMemoryKoopman(latent_dim=2, memory_order=True) # type: ignore[arg-type]
+
+
+# ---------------------------------------------------------------------------
+# analysis/hodge_modes.py
+# ---------------------------------------------------------------------------
+
+
+def test_hodge_components_empty_basis_real_modes_and_degree_types() -> None:
+ """Isolated-node curl is empty; real columns skip the complex reassemble."""
+ with pytest.raises(ValueError, match="n_cells, n_modes"):
+ HodgeModeComponents(
+ gradient=torch.zeros(3, 0),
+ curl=torch.zeros(3, 0),
+ harmonic=torch.zeros(3, 0),
+ )
+ isolated = torch.empty(2, 0, dtype=torch.long)
+ real = _real_spectrum(torch.ones(3, 1, dtype=torch.float64))
+ parts = hodge_decompose_modes(real, isolated, num_nodes=3, degree=0)
+ assert parts.gradient.dtype == torch.float64
+ assert torch.equal(parts.curl, torch.zeros_like(parts.curl))
+ with pytest.raises(ValueError, match="degree must be 0 or 1"):
+ hodge_decompose_modes(real, isolated, num_nodes=3, degree=True)
+ with pytest.raises(ValueError, match="num_nodes must be >= 1"):
+ hodge_decompose_modes(real, isolated, num_nodes=0)
+ flat = KoopmanSpectrum(
+ eigenvalues=torch.ones(1),
+ eigenvectors=torch.ones(4),
+ magnitudes=torch.ones(1),
+ growth_rates=torch.zeros(1),
+ frequencies=torch.zeros(1),
+ time_step=1.0,
+ )
+ with pytest.raises(ValueError, match="n_cells, n_modes"):
+ hodge_decompose_modes(flat, isolated, num_nodes=3)
+
+
+# ---------------------------------------------------------------------------
+# adaptation/joint_observer.py
+# ---------------------------------------------------------------------------
+
+
+def test_joint_observer_rejects_non_dense_and_unsupported_operators() -> None:
+ """Schur graph factors and Hodge operators are refused."""
+ schur = GraphKoopmanModel(
+ SeparableDictionaryEncoder(2, 8, 3, num_layers=1),
+ SeparableDictionaryDecoder(3, 8, 2, num_layers=1),
+ latent_dim=3,
+ time_step=0.1,
+ koopman="graph",
+ koopman_parameterization="schur",
+ )
+ with pytest.raises(ValueError, match="parameterization='dense'"):
+ JointStateTopologyObserver(schur, claim_homomorphism=False)
+ continuous_graph = GraphKoopmanModel(
+ SeparableDictionaryEncoder(2, 8, 3, num_layers=1),
+ SeparableDictionaryDecoder(3, 8, 2, num_layers=1),
+ latent_dim=3,
+ time_step=0.1,
+ koopman="continuous_graph",
+ dynamics_mode="continuous",
+ )
+ with pytest.raises(TypeError, match="GraphKoopmanOperator"):
+ JointStateTopologyObserver(continuous_graph, claim_homomorphism=False)
+
+
+def test_joint_observer_continuous_path_runs_rls() -> None:
+ """Continuous per-node ``L`` uses RLS write-back with ``delta_t``."""
+ model = GraphKoopmanModel(
+ SeparableDictionaryEncoder(2, 8, 3, num_layers=1),
+ SeparableDictionaryDecoder(3, 8, 2, num_layers=1),
+ latent_dim=3,
+ time_step=0.1,
+ dynamics_mode="continuous",
+ )
+ observer = JointStateTopologyObserver(model, claim_homomorphism=False)
+ result = observer.filter_and_adapt(_sequence())
+ assert result.sparse_factors is None
+ assert len(result.rls_steps) == 3
+
+
+# ---------------------------------------------------------------------------
+# baselines/gedmd.py and hankel_dmd.py
+# ---------------------------------------------------------------------------
+
+
+def test_gedmd_dictionary_derivative_and_fit_guards() -> None:
+ """Polynomial derivative helpers and stacked ``dx/dt`` shapes raise."""
+ states = torch.randn(6, 2)
+ derivs = torch.randn(6, 2)
+ with pytest.raises(ValueError, match="must share shape"):
+ polynomial_observable_derivatives(states, derivs[:, :1], polynomial_degree=1)
+ degree1 = polynomial_observable_derivatives(states, derivs, polynomial_degree=1)
+ assert torch.equal(degree1, derivs)
+ degree2 = polynomial_observable_derivatives(states, derivs, polynomial_degree=2)
+ assert degree2.shape == (6, 4)
+ with pytest.raises(ValueError, match="polynomial_degree must be 1 or 2"):
+ polynomial_observable_derivatives(states, derivs, polynomial_degree=3)
+ edges = torch.tensor([[0, 1], [1, 0]], dtype=torch.long)
+ sequence = _gedmd_sequence(
+ [torch.randn(2) for _ in range(4)],
+ edges,
+ derivatives=[torch.randn(2) for _ in range(4)],
+ )
+ fitted = GEDMDBaseline(polynomial_degree=2).fit(sequence)
+ assert fitted.K is not None
+ stacked = torch.stack([snapshot.dx_dt.reshape(-1) for snapshot in sequence])
+ via_kwarg = GEDMDBaseline(polynomial_degree=1).fit(sequence, derivatives=stacked)
+ assert via_kwarg.K is not None
+ with pytest.raises(ValueError, match="steps must be >= 1"):
+ fitted.predict(sequence[0], steps=0)
+ with pytest.raises(ValueError, match="\\(T, state_dim\\)"):
+ GEDMDBaseline(polynomial_degree=1).fit(sequence, derivatives=torch.ones(3, 2))
+ with pytest.raises(ValueError, match="\\(T, N, d\\)"):
+ GEDMDBaseline(polynomial_degree=1).fit(
+ sequence,
+ derivatives=torch.ones(4, 3, 1),
+ )
+ with pytest.raises(ValueError, match="state_dim\\) or \\(T, N, d\\)"):
+ GEDMDBaseline(polynomial_degree=1).fit(sequence, derivatives=torch.ones(4))
+ mismatch = _gedmd_sequence(
+ [torch.randn(2) for _ in range(3)],
+ edges,
+ derivatives=[torch.randn(2) for _ in range(3)],
+ )
+ mismatch[0].dx_dt = torch.randn(2, 2)
+ with pytest.raises(ValueError, match="does not match x shape"):
+ GEDMDBaseline(polynomial_degree=1).fit(mismatch)
+
+
+def test_hankel_dmd_unfitted_short_history_and_spectrum() -> None:
+ """Unfitted predict, ``steps < 1``, zero-pad history, and spectrum."""
+ with pytest.raises(ValueError, match="2-D"):
+ delay_embed_rows(torch.zeros(3, 2, 1), n_delays=2)
+ unfitted = HankelDMDBaseline(n_delays=2)
+ loop = torch.tensor([[0], [0]], dtype=torch.long)
+ graph = Data(x=torch.ones(1, 1), edge_index=loop)
+ with pytest.raises(RuntimeError, match="must be fit"):
+ unfitted.predict(graph, steps=1)
+ states = [torch.tensor([float(index)]) for index in range(6)]
+ sequence = GraphSnapshotSequence(
+ [Data(x=state.reshape(1, 1), edge_index=loop) for state in states]
+ )
+ fitted = HankelDMDBaseline(n_delays=3).fit(sequence)
+ with pytest.raises(ValueError, match="steps must be >= 1"):
+ fitted.predict(sequence[-1], steps=0)
+ padded = assemble_delay_state(
+ sequence[-1],
+ history=list(sequence[:1]),
+ n_delays=3,
+ num_nodes=1,
+ in_channels=1,
+ state_dim=1,
+ )
+ assert padded.shape == (3,)
+ forecasts = fitted.predict(sequence[-1], steps=2)
+ assert len(forecasts) == 2
+ spectrum = fitted.spectrum()
+ assert spectrum.eigenvalues.ndim == 1
diff --git a/tests/serialization/test_serialization.py b/tests/serialization/test_serialization.py
index 073f186..7b0e933 100644
--- a/tests/serialization/test_serialization.py
+++ b/tests/serialization/test_serialization.py
@@ -290,6 +290,7 @@ def test_save_checkpoint_uses_current_format_version(
"adjacency",
"learn_topology",
"topology_embedding_dim",
+ "graph_dynamics",
"symmetry",
"local_window",
"local_rank",
@@ -979,7 +980,9 @@ def test_build_model_config_includes_placeholder_keys(
config = build_model_config(graph_koopman_model)
assert config["sparsity"] == "dense"
assert config["adjacency"] is None
+ assert config["filter_degree"] is None
assert config["learn_topology"] is None
+ assert config["graph_dynamics"] is None
assert config["symmetry"] is None
assert config["local_window"] is None
assert config["local_rank"] is None
diff --git a/tests/serialization/test_switched_mixture_checkpoint.py b/tests/serialization/test_switched_mixture_checkpoint.py
index 6f373b5..f65340d 100644
--- a/tests/serialization/test_switched_mixture_checkpoint.py
+++ b/tests/serialization/test_switched_mixture_checkpoint.py
@@ -5,6 +5,7 @@
from pathlib import Path
from koopman_graph import GNNDecoder, GNNEncoder, GraphKoopmanModel
+from koopman_graph.serialization import FORMAT_VERSION, build_checkpoint
def _tiny_model(*, koopman: str = "pernode", parameterization: str = "dense", **kwargs):
@@ -40,6 +41,20 @@ def test_switched_mixture_checkpoint_roundtrip(tmp_path: Path) -> None:
loaded_h = GraphKoopmanModel.load(path_h)
assert loaded_h.koopman_kind == "hodge"
+ parametric = _tiny_model(koopman="parametric", koopman_parameter_dim=2)
+ path_p = tmp_path / "parametric.pt"
+ parametric.save(path_p)
+ loaded_p = GraphKoopmanModel.load(path_p)
+ assert loaded_p.koopman_kind == "parametric"
+ assert loaded_p.koopman.parameter_dim == 2
+ assert loaded_p.koopman.weight_kind == "rbf"
+ checkpoint = build_checkpoint(parametric)
+ assert checkpoint["format_version"] == FORMAT_VERSION == 1
+ assert checkpoint["config"]["koopman_kind"] == "parametric"
+ assert checkpoint["config"]["koopman_parameter_dim"] == 2
+ assert checkpoint["config"]["koopman_weight_kind"] == "rbf"
+ assert checkpoint["config"]["koopman_num_modes"] == 2
+
stochastic = _tiny_model(parameterization="row_stochastic")
path2 = tmp_path / "row_stoch.pt"
stochastic.save(path2)
diff --git a/tests/training/test_batched_graphs.py b/tests/training/test_batched_graphs.py
new file mode 100644
index 0000000..025e07a
--- /dev/null
+++ b/tests/training/test_batched_graphs.py
@@ -0,0 +1,318 @@
+"""Tests for opt-in vectorized multi-graph training."""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+import pytest
+import torch
+from torch_geometric.data import Data, HeteroData
+
+from koopman_graph import GNNDecoder, GNNEncoder, GraphKoopmanModel
+from koopman_graph.data import (
+ GraphSnapshotSequence,
+ HeteroGraphSnapshotSequence,
+ MultiTrajectory,
+)
+from koopman_graph.training import (
+ LossWeights,
+ compute_batched_training_loss,
+ compute_training_loss,
+ mean_training_loss_breakdown,
+)
+
+# Float32 encode / K matvec accumulation on modest graphs (N≤8, T≤4, two
+# sequences). Tighter than typical eigendecomposition checks.
+_BATCHED_LOSS_RTOL = 1e-5
+_BATCHED_LOSS_ATOL = 1e-6
+
+
+def _path_edge_index(num_nodes: int) -> torch.Tensor:
+ """Return a bidirectional path graph.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count (at least 2).
+
+ Returns
+ -------
+ Tensor
+ COO index with shape ``(2, 2 * (num_nodes - 1))``.
+ """
+ src = list(range(num_nodes - 1)) + list(range(1, num_nodes))
+ dst = list(range(1, num_nodes)) + list(range(num_nodes - 1))
+ return torch.tensor([src, dst], dtype=torch.long)
+
+
+def _sequence(
+ *,
+ num_nodes: int,
+ num_timesteps: int = 4,
+ in_channels: int = 3,
+ seed: int = 0,
+ scale: float = 1.0,
+) -> GraphSnapshotSequence:
+ """Build a static-topology homogeneous sequence.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Nodes per snapshot.
+ num_timesteps : int, optional
+ Snapshot count. Default is ``4``.
+ in_channels : int, optional
+ Feature width. Default is ``3``.
+ seed : int, optional
+ RNG seed. Default is ``0``.
+ scale : float, optional
+ Feature scale so unequal-``N`` graphs have distinct MSE. Default is
+ ``1.0``.
+
+ Returns
+ -------
+ GraphSnapshotSequence
+ Random snapshots on a path graph.
+ """
+ torch.manual_seed(seed)
+ edge_index = _path_edge_index(num_nodes)
+ snapshots = [
+ Data(
+ x=scale * torch.randn(num_nodes, in_channels),
+ edge_index=edge_index,
+ )
+ for _ in range(num_timesteps)
+ ]
+ return GraphSnapshotSequence(snapshots)
+
+
+def _make_model(*, koopman: str = "pernode", seed: int = 0) -> GraphKoopmanModel:
+ """Construct a small hop-matched model for batching tests.
+
+ Parameters
+ ----------
+ koopman : str, optional
+ Factory kind. Default is ``"pernode"``.
+ seed : int, optional
+ RNG seed. Default is ``0``.
+
+ Returns
+ -------
+ GraphKoopmanModel
+ One-layer GCN stack (no receptive-field warning vs graph ``P=1``).
+ """
+ torch.manual_seed(seed)
+ encoder = GNNEncoder(
+ in_channels=3,
+ hidden_channels=8,
+ latent_dim=4,
+ num_layers=1,
+ )
+ decoder = GNNDecoder(
+ latent_dim=4,
+ hidden_channels=8,
+ out_channels=3,
+ num_layers=1,
+ )
+ return GraphKoopmanModel(
+ encoder=encoder,
+ decoder=decoder,
+ latent_dim=4,
+ time_step=0.1,
+ koopman=koopman, # type: ignore[arg-type]
+ )
+
+
+def _assert_close(actual: torch.Tensor, expected: torch.Tensor) -> None:
+ """Require batched vs per-sequence losses to match documented tols.
+
+ Parameters
+ ----------
+ actual : Tensor
+ Batched scalar.
+ expected : Tensor
+ Mean of per-sequence scalars.
+ """
+ assert torch.allclose(
+ actual,
+ expected,
+ rtol=_BATCHED_LOSS_RTOL,
+ atol=_BATCHED_LOSS_ATOL,
+ )
+
+
+@pytest.mark.parametrize("koopman", ["pernode", "graph"])
+def test_batched_reconstruction_matches_per_sequence_mean(koopman: str) -> None:
+ """Batched recon equals the trajectory-equal mean on unequal ``N``."""
+ model = _make_model(koopman=koopman, seed=7)
+ model.eval()
+ seq_a = _sequence(num_nodes=3, seed=11, scale=0.2)
+ seq_b = _sequence(num_nodes=5, seed=12, scale=4.0)
+ weights = LossWeights()
+ per_sequence = mean_training_loss_breakdown(
+ [
+ compute_training_loss(model, seq_a, weights),
+ compute_training_loss(model, seq_b, weights),
+ ]
+ )
+ batched = compute_batched_training_loss(model, (seq_a, seq_b), weights)
+ _assert_close(batched.reconstruction, per_sequence.reconstruction)
+ _assert_close(batched.total, per_sequence.total)
+
+
+def test_batched_forward_matches_per_sequence_mean() -> None:
+ """Forward consistency uses the same per-graph-then-mean reduction."""
+ model = _make_model(koopman="graph", seed=8)
+ model.eval()
+ seq_a = _sequence(num_nodes=3, seed=21)
+ seq_b = _sequence(num_nodes=5, seed=22)
+ weights = LossWeights(reconstruction=1.0, forward=1.0)
+ per_sequence = mean_training_loss_breakdown(
+ [
+ compute_training_loss(model, seq_a, weights),
+ compute_training_loss(model, seq_b, weights),
+ ]
+ )
+ batched = compute_batched_training_loss(model, (seq_a, seq_b), weights)
+ _assert_close(batched.reconstruction, per_sequence.reconstruction)
+ _assert_close(batched.forward, per_sequence.forward)
+ _assert_close(batched.total, per_sequence.total)
+
+
+def test_batched_eigenvalue_term_stays_per_sequence() -> None:
+ """Eigenvalue hinge is the mean of per-graph spectra, not the union."""
+ model = _make_model(koopman="graph", seed=9)
+ model.eval()
+ seq_a = _sequence(num_nodes=3, seed=31)
+ seq_b = _sequence(num_nodes=5, seed=32)
+ weights = LossWeights(reconstruction=1.0, eigenvalue=1.0)
+ per_sequence = mean_training_loss_breakdown(
+ [
+ compute_training_loss(model, seq_a, weights),
+ compute_training_loss(model, seq_b, weights),
+ ]
+ )
+ batched = compute_batched_training_loss(model, (seq_a, seq_b), weights)
+ _assert_close(batched.eigenvalue, per_sequence.eigenvalue)
+ _assert_close(batched.total, per_sequence.total)
+
+
+def test_default_fit_does_not_call_batched_loss() -> None:
+ """Default ``MultiTrajectory`` fit keeps the per-sequence Python loop."""
+ model = _make_model(seed=10)
+ seq_a = _sequence(num_nodes=3, num_timesteps=3, seed=41)
+ seq_b = _sequence(num_nodes=3, num_timesteps=3, seed=42)
+ with patch(
+ "koopman_graph.training.epochs.compute_batched_training_loss"
+ ) as batched:
+ model.fit(MultiTrajectory((seq_a, seq_b)), epochs=1, lr=1e-2)
+ batched.assert_not_called()
+
+
+def test_fit_batch_graphs_trains_and_allows_single_trajectory() -> None:
+ """``batch_graphs=True`` trains a multi-graph batch and a trivial batch."""
+ model = _make_model(koopman="graph", seed=13)
+ seq_a = _sequence(num_nodes=3, num_timesteps=3, seed=51)
+ seq_b = _sequence(num_nodes=5, num_timesteps=3, seed=52)
+ history = model.fit(
+ MultiTrajectory((seq_a, seq_b)),
+ epochs=2,
+ lr=1e-2,
+ batch_graphs=True,
+ )
+ assert history.epochs == 2
+ assert len(history.loss) == 2
+
+ single = _make_model(seed=14)
+ solo = _sequence(num_nodes=4, num_timesteps=3, seed=53)
+ solo_history = single.fit(solo, epochs=1, lr=1e-2, batch_graphs=True)
+ assert solo_history.epochs == 1
+
+
+def test_batch_graphs_rejects_windowed_and_ddp() -> None:
+ """Windowed sampling and DDP are mutually exclusive with graph batching."""
+ model = _make_model(seed=15)
+ sequence = _sequence(num_nodes=3, num_timesteps=4, seed=61)
+ with pytest.raises(ValueError, match="window_length"):
+ model.fit(
+ sequence,
+ epochs=1,
+ batch_graphs=True,
+ window_length=3,
+ )
+ with pytest.raises(ValueError, match="ddp"):
+ model.fit(
+ sequence,
+ epochs=1,
+ batch_graphs=True,
+ strategy="ddp",
+ )
+
+
+def test_batch_graphs_rejects_hetero_delay_orbits_and_topology() -> None:
+ """Refused families raise a clear ``ValueError`` / ``TypeError``."""
+ model = _make_model(seed=16)
+ hetero = HeteroData()
+ hetero["node"].x = torch.randn(4, 3)
+ hetero["node", "r1", "node"].edge_index = torch.tensor(
+ [[0, 1], [1, 2]], dtype=torch.long
+ )
+ hetero_seq = HeteroGraphSnapshotSequence([hetero, hetero.clone()])
+ with pytest.raises(TypeError, match="GraphSnapshotSequence"):
+ compute_batched_training_loss(model, (hetero_seq,), LossWeights())
+
+ delay_encoder = GNNEncoder(
+ in_channels=6,
+ hidden_channels=8,
+ latent_dim=4,
+ num_layers=1,
+ )
+ delay_decoder = GNNDecoder(
+ latent_dim=4,
+ hidden_channels=8,
+ out_channels=3,
+ num_layers=1,
+ )
+ delayed = GraphKoopmanModel(
+ encoder=delay_encoder,
+ decoder=delay_decoder,
+ latent_dim=4,
+ time_step=0.1,
+ n_delays=2,
+ )
+ sequence = _sequence(num_nodes=3, seed=71)
+ with pytest.raises(ValueError, match="n_delays"):
+ compute_batched_training_loss(delayed, (sequence,), LossWeights())
+
+ orbit = GraphKoopmanModel(
+ encoder=GNNEncoder(3, 8, 4, num_layers=1),
+ decoder=GNNDecoder(4, 8, 3, num_layers=1),
+ latent_dim=4,
+ time_step=0.1,
+ koopman="graph",
+ koopman_orbit_partition=((0, 1), (2,)),
+ )
+ with pytest.raises(ValueError, match="orbit"):
+ compute_batched_training_loss(orbit, (sequence,), LossWeights())
+
+ learned = GraphKoopmanModel(
+ encoder=GNNEncoder(3, 8, 4, num_layers=1),
+ decoder=GNNDecoder(4, 8, 3, num_layers=1),
+ latent_dim=4,
+ time_step=0.1,
+ koopman="graph",
+ learn_topology="self_adaptive",
+ )
+ with pytest.raises(ValueError, match="topology"):
+ compute_batched_training_loss(learned, (sequence,), LossWeights())
+
+ continuous = GraphKoopmanModel(
+ encoder=GNNEncoder(3, 8, 4, num_layers=1),
+ decoder=GNNDecoder(4, 8, 3, num_layers=1),
+ latent_dim=4,
+ time_step=0.1,
+ koopman="continuous_graph",
+ dynamics_mode="continuous",
+ )
+ with pytest.raises(ValueError, match="discrete per-node or graph"):
+ compute_batched_training_loss(continuous, (sequence,), LossWeights())
diff --git a/tests/training/test_coverage_gaps.py b/tests/training/test_coverage_gaps.py
new file mode 100644
index 0000000..a666f16
--- /dev/null
+++ b/tests/training/test_coverage_gaps.py
@@ -0,0 +1,688 @@
+"""Coverage for batched-objective guards and fit-loop error paths."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import pytest
+import torch
+from torch import nn
+from torch.optim.lr_scheduler import StepLR
+from torch_geometric.data import Data, HeteroData
+
+from koopman_graph import GNNDecoder, GNNEncoder, GraphKoopmanModel
+from koopman_graph.data import (
+ GraphDynamicsConfig,
+ GraphSnapshotSequence,
+ HeteroGraphSnapshotSequence,
+ MultiTrajectory,
+ WindowSampler,
+)
+from koopman_graph.identification import IdentificationConfig
+from koopman_graph.nn import DelayEmbeddingEncoder, HypergraphEncoder, RelGraphEncoder
+from koopman_graph.operators import KoopmanOperator
+from koopman_graph.training import (
+ LossWeights,
+ NoOpFitCallback,
+ compute_batched_training_loss,
+ run_fit_loop,
+ validate_graph_batching_request,
+)
+from koopman_graph.training.loop import (
+ _collect_identification_pairs,
+ _encode_sequence_latents,
+ _validate_identification_fit_request,
+ bind_pending_orbit_ties,
+)
+
+
+def _path_edge_index(num_nodes: int) -> torch.Tensor:
+ """Return a bidirectional path graph.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count (at least 2).
+
+ Returns
+ -------
+ Tensor
+ COO index with shape ``(2, 2 * (num_nodes - 1))``.
+ """
+ src = list(range(num_nodes - 1)) + list(range(1, num_nodes))
+ dst = list(range(1, num_nodes)) + list(range(num_nodes - 1))
+ return torch.tensor([src, dst], dtype=torch.long)
+
+
+def _sequence(
+ *,
+ num_nodes: int = 3,
+ num_timesteps: int = 4,
+ in_channels: int = 3,
+ seed: int = 0,
+ control_inputs: torch.Tensor | None = None,
+ timestamps: torch.Tensor | None = None,
+ observation_masks: torch.Tensor | None = None,
+ presence_masks: torch.Tensor | None = None,
+ allow_node_churn: bool = False,
+ hyperedges: bool = False,
+) -> GraphSnapshotSequence:
+ """Build a static-topology homogeneous sequence.
+
+ Parameters
+ ----------
+ num_nodes, num_timesteps, in_channels, seed
+ Snapshot geometry and RNG.
+ control_inputs, timestamps, observation_masks, presence_masks
+ Optional sequence metadata.
+ allow_node_churn : bool, optional
+ Fixed-union churn flag. Default is ``False``.
+ hyperedges : bool, optional
+ Attach a static bipartite incidence. Default is ``False``.
+
+ Returns
+ -------
+ GraphSnapshotSequence
+ Random snapshots on a path graph.
+ """
+ torch.manual_seed(seed)
+ edge_index = _path_edge_index(num_nodes)
+ hyperedge_index = (
+ torch.tensor([[0, 1, 2], [0, 0, 0]], dtype=torch.long) if hyperedges else None
+ )
+ snapshots = []
+ for _ in range(num_timesteps):
+ kwargs: dict[str, torch.Tensor] = {
+ "x": torch.randn(num_nodes, in_channels),
+ "edge_index": edge_index,
+ }
+ if hyperedge_index is not None:
+ kwargs["hyperedge_index"] = hyperedge_index
+ snapshots.append(Data(**kwargs))
+ return GraphSnapshotSequence(
+ snapshots,
+ control_inputs=control_inputs,
+ timestamps=timestamps,
+ observation_masks=observation_masks,
+ presence_masks=presence_masks,
+ allow_node_churn=allow_node_churn,
+ )
+
+
+def _make_model(*, seed: int = 0, **kwargs: object) -> GraphKoopmanModel:
+ """Construct a small hop-matched per-node model.
+
+ Parameters
+ ----------
+ seed : int, optional
+ RNG seed. Default is ``0``.
+ **kwargs
+ Forwarded to :class:`~koopman_graph.model.GraphKoopmanModel`.
+
+ Returns
+ -------
+ GraphKoopmanModel
+ One-layer GCN stack.
+ """
+ torch.manual_seed(seed)
+ encoder = GNNEncoder(in_channels=3, hidden_channels=8, latent_dim=4, num_layers=1)
+ decoder = GNNDecoder(latent_dim=4, hidden_channels=8, out_channels=3, num_layers=1)
+ return GraphKoopmanModel(
+ encoder=encoder,
+ decoder=decoder,
+ latent_dim=4,
+ time_step=0.1,
+ **kwargs, # type: ignore[arg-type]
+ )
+
+
+def _ok_koopman(**overrides: object) -> SimpleNamespace:
+ """Return a discrete-operator stub that batching accepts.
+
+ Parameters
+ ----------
+ **overrides
+ Attribute replacements.
+
+ Returns
+ -------
+ SimpleNamespace
+ Orbit / family flags.
+ """
+ payload: dict[str, object] = {
+ "auto_orbits": False,
+ "isotypic_symmetry": False,
+ "orbit_partition": None,
+ "uses_orbit_selves": False,
+ }
+ payload.update(overrides)
+ return SimpleNamespace(**payload)
+
+
+def _batching_stub(
+ *,
+ encoder: nn.Module | None = None,
+ **flags: object,
+) -> SimpleNamespace:
+ """Return a model stub for :func:`validate_graph_batching_request`.
+
+ Parameters
+ ----------
+ encoder : nn.Module or None, optional
+ Encoder to inspect. Default is a one-layer GCN.
+ **flags
+ Model attributes (``n_delays``, ``control_dim``, topology flags).
+
+ Returns
+ -------
+ SimpleNamespace
+ Minimal trainable-model stand-in.
+ """
+ defaults: dict[str, object] = {
+ "encoder": encoder
+ if encoder is not None
+ else GNNEncoder(3, 8, 4, num_layers=1),
+ "n_delays": 1,
+ "control_dim": 0,
+ "learns_pairwise_topology": False,
+ "adaptive_topology": None,
+ "predicted_topology": None,
+ "uses_hypergraph_koopman": False,
+ "graph_dynamics": None,
+ "koopman": _ok_koopman(),
+ }
+ defaults.update(flags)
+ return SimpleNamespace(**defaults)
+
+
+def test_batch_graphs_rejects_empty_and_short_sequences() -> None:
+ """Empty batches and single-snapshot trajectories raise ``ValueError``."""
+ model = _make_model()
+ with pytest.raises(ValueError, match="at least one GraphSnapshotSequence"):
+ validate_graph_batching_request(model, [])
+ short = _sequence(num_timesteps=1, seed=3)
+ with pytest.raises(ValueError, match="at least 2 snapshots"):
+ compute_batched_training_loss(model, (short,), LossWeights())
+
+
+def test_batch_graphs_rejects_controls_hyperedges_and_timestamps() -> None:
+ """Sequence-level control, incidence, and timestamp records are refused."""
+ model = _make_model()
+ controlled = _sequence(control_inputs=torch.randn(4, 1), seed=4)
+ with pytest.raises(ValueError, match="control_inputs"):
+ validate_graph_batching_request(model, (controlled,))
+ hyper = _sequence(hyperedges=True, seed=5)
+ with pytest.raises(ValueError, match="hyperedge"):
+ validate_graph_batching_request(model, (hyper,))
+ stamped = _sequence(timestamps=torch.arange(4, dtype=torch.float32), seed=6)
+ with pytest.raises(ValueError, match="timestamps"):
+ validate_graph_batching_request(model, (stamped,))
+
+
+def test_batch_graphs_rejects_control_dim_adaptive_and_hypergraph_flags() -> None:
+ """Model-level control, adaptive topology, and hypergraph flags raise."""
+ sequence = _sequence(seed=7)
+ with pytest.raises(ValueError, match="control_dim"):
+ validate_graph_batching_request(
+ _batching_stub(control_dim=1),
+ (sequence,),
+ )
+ with pytest.raises(ValueError, match="adaptive topology"):
+ validate_graph_batching_request(
+ _batching_stub(adaptive_topology=object()),
+ (sequence,),
+ )
+ with pytest.raises(ValueError, match="hypergraph Koopman"):
+ validate_graph_batching_request(
+ _batching_stub(uses_hypergraph_koopman=True),
+ (sequence,),
+ )
+
+
+def test_batch_graphs_unwraps_delay_then_rejects_relgraph_encoder() -> None:
+ """Delay wrappers unwrap before RelGraph / hypergraph encoder guards."""
+ sequence = _sequence(seed=8)
+ delayed = DelayEmbeddingEncoder(
+ RelGraphEncoder(3, 8, 4, 2, num_layers=1),
+ n_delays=1,
+ )
+ with pytest.raises(ValueError, match="RelGraph"):
+ validate_graph_batching_request(_batching_stub(encoder=delayed), (sequence,))
+ with pytest.raises(ValueError, match="hypergraph"):
+ validate_graph_batching_request(
+ _batching_stub(encoder=HypergraphEncoder(3, 8, 4, num_layers=1)),
+ (sequence,),
+ )
+
+
+def test_batch_graphs_rejects_orbit_and_isotypic_operator_flags() -> None:
+ """Orbit-tied and isotypic operator flags are refused."""
+ sequence = _sequence(seed=9)
+ with pytest.raises(ValueError, match="orbit-tied or isotypic"):
+ validate_graph_batching_request(
+ _batching_stub(koopman=_ok_koopman(auto_orbits=True)),
+ (sequence,),
+ )
+ with pytest.raises(ValueError, match="orbit-tied or isotypic"):
+ validate_graph_batching_request(
+ _batching_stub(koopman=_ok_koopman(isotypic_symmetry=True)),
+ (sequence,),
+ )
+ with pytest.raises(ValueError, match="orbit-tied"):
+ validate_graph_batching_request(
+ _batching_stub(koopman=_ok_koopman(uses_orbit_selves=True)),
+ (sequence,),
+ )
+
+
+def test_batch_graphs_allows_nonrecursive_predicted_topology() -> None:
+ """A topology head without recursive training is not a batching refusal."""
+ model = _make_model(
+ seed=26,
+ graph_dynamics=GraphDynamicsConfig(
+ topology_head="sparse_candidate",
+ recursive_training=False,
+ topology_loss_weight=0.0,
+ presence_loss_weight=0.0,
+ ),
+ )
+ sequence = _sequence(seed=27)
+ validate_graph_batching_request(model, (sequence,))
+
+
+def test_batched_loss_skips_inactive_recon_or_forward_terms() -> None:
+ """Zero reconstruction / forward weights skip the matching vectorized loop."""
+ model = _make_model(seed=28)
+ model.eval()
+ sequence = _sequence(seed=29)
+ forward_only = compute_batched_training_loss(
+ model,
+ (sequence,),
+ LossWeights(reconstruction=0.0, forward=1.0),
+ )
+ assert torch.isfinite(forward_only.forward)
+ recon_only = compute_batched_training_loss(
+ model,
+ (sequence,),
+ LossWeights(reconstruction=1.0, forward=0.0),
+ )
+ assert torch.isfinite(recon_only.reconstruction)
+ inactive = compute_batched_training_loss(
+ model,
+ (sequence,),
+ LossWeights(reconstruction=0.0, forward=0.0),
+ )
+ assert float(inactive.total) == pytest.approx(0.0, abs=1e-8)
+
+
+def test_batched_loss_uses_observation_masks_and_graph_state_weights() -> None:
+ """Masked reconstruction and leftover graph-state weights stay finite."""
+ model = _make_model(
+ seed=10,
+ graph_dynamics=GraphDynamicsConfig(
+ topology_head="none",
+ recursive_training=False,
+ topology_loss_weight=1.0,
+ presence_loss_weight=1.0,
+ ),
+ )
+ model.eval()
+ masks = torch.ones(4, 3, dtype=torch.bool)
+ masks[1, 0] = False
+ sequence = _sequence(observation_masks=masks, seed=11)
+ breakdown = compute_batched_training_loss(model, (sequence,), LossWeights())
+ assert torch.isfinite(breakdown.reconstruction)
+ assert torch.isfinite(breakdown.total)
+
+
+def test_identification_fit_request_rejects_unsupported_layouts() -> None:
+ """Identification refuses non-config objects and unsupported families."""
+ model = _make_model()
+ sequence = _sequence(seed=12)
+ config = IdentificationConfig()
+ with pytest.raises(TypeError, match="IdentificationConfig"):
+ _validate_identification_fit_request(
+ model,
+ (sequence,),
+ "ridge",
+ window_sampler=None,
+ batch_graphs=False,
+ )
+ with pytest.raises(ValueError, match="batch_graphs"):
+ _validate_identification_fit_request(
+ model,
+ (sequence,),
+ config,
+ window_sampler=None,
+ batch_graphs=True,
+ )
+ hetero = HeteroData()
+ hetero["node"].x = torch.randn(3, 3)
+ hetero["node", "r", "node"].edge_index = torch.tensor(
+ [[0, 1], [1, 2]], dtype=torch.long
+ )
+ hetero_seq = HeteroGraphSnapshotSequence([hetero, hetero.clone()])
+ with pytest.raises(ValueError, match="HeteroGraphSnapshotSequence"):
+ _validate_identification_fit_request(
+ model,
+ (hetero_seq,),
+ config,
+ window_sampler=None,
+ batch_graphs=False,
+ )
+ presence = torch.ones(4, 3, dtype=torch.bool)
+ churn = _sequence(presence_masks=presence, allow_node_churn=True, seed=13)
+ with pytest.raises(ValueError, match="allow_node_churn"):
+ _validate_identification_fit_request(
+ model,
+ (churn,),
+ config,
+ window_sampler=None,
+ batch_graphs=False,
+ )
+
+
+def test_identification_fit_request_rejects_operator_and_control_flags() -> None:
+ """Continuous, delay, controlled, adaptive, and non-dense maps raise."""
+ sequence = _sequence(seed=14)
+ config = IdentificationConfig()
+ continuous = SimpleNamespace(
+ koopman_kind="pernode",
+ dynamics_mode="continuous",
+ n_delays=1,
+ control_dim=0,
+ adaptive_topology=None,
+ koopman=KoopmanOperator(4),
+ )
+ with pytest.raises(ValueError, match="dynamics_mode"):
+ _validate_identification_fit_request(
+ continuous,
+ (sequence,),
+ config,
+ window_sampler=None,
+ batch_graphs=False,
+ )
+ delayed = SimpleNamespace(
+ koopman_kind="pernode",
+ dynamics_mode="discrete",
+ n_delays=2,
+ control_dim=0,
+ adaptive_topology=None,
+ koopman=KoopmanOperator(4),
+ )
+ with pytest.raises(ValueError, match="n_delays"):
+ _validate_identification_fit_request(
+ delayed,
+ (sequence,),
+ config,
+ window_sampler=None,
+ batch_graphs=False,
+ )
+ controlled = SimpleNamespace(
+ koopman_kind="pernode",
+ dynamics_mode="discrete",
+ n_delays=1,
+ control_dim=1,
+ adaptive_topology=None,
+ koopman=KoopmanOperator(4),
+ )
+ with pytest.raises(ValueError, match="control_dim"):
+ _validate_identification_fit_request(
+ controlled,
+ (sequence,),
+ config,
+ window_sampler=None,
+ batch_graphs=False,
+ )
+ adaptive = SimpleNamespace(
+ koopman_kind="pernode",
+ dynamics_mode="discrete",
+ n_delays=1,
+ control_dim=0,
+ adaptive_topology=object(),
+ koopman=KoopmanOperator(4),
+ )
+ with pytest.raises(ValueError, match="pairwise topology"):
+ _validate_identification_fit_request(
+ adaptive,
+ (sequence,),
+ config,
+ window_sampler=None,
+ batch_graphs=False,
+ )
+ injected = SimpleNamespace(
+ koopman_kind="pernode",
+ dynamics_mode="discrete",
+ n_delays=1,
+ control_dim=0,
+ adaptive_topology=None,
+ koopman=object(),
+ )
+ with pytest.raises(ValueError, match="KoopmanOperator only"):
+ _validate_identification_fit_request(
+ injected,
+ (sequence,),
+ config,
+ window_sampler=None,
+ batch_graphs=False,
+ )
+
+
+def test_identification_pairs_require_two_snapshots_and_encode_fallback() -> None:
+ """Pair collection rejects short series; encode is used without ``encode_at``."""
+ model = _make_model(seed=15)
+ short = _sequence(num_timesteps=1, seed=16)
+ with pytest.raises(ValueError, match="at least two snapshots"):
+ _collect_identification_pairs(model, (short,))
+
+ sequence = _sequence(num_timesteps=3, seed=17)
+
+ class _EncodeOnly:
+ """Minimal encode surface without ``encode_at``."""
+
+ encode_at = None
+
+ @staticmethod
+ def encode(snapshot: Data) -> torch.Tensor:
+ """Return node features.
+
+ Parameters
+ ----------
+ snapshot : Data
+ Graph snapshot.
+
+ Returns
+ -------
+ Tensor
+ Node feature matrix.
+ """
+ return snapshot.x
+
+ stacked = _encode_sequence_latents(_EncodeOnly(), sequence) # type: ignore[arg-type]
+ assert stacked.shape == (3, 3, 3)
+
+
+def test_bind_pending_orbit_ties_empty_and_heterodata_on_homogeneous() -> None:
+ """Empty trains are a no-op; HeteroData on a homogeneous path raises."""
+ model = _make_model()
+ bind_pending_orbit_ties(model, [])
+
+ hetero = HeteroData()
+ hetero["node"].x = torch.zeros(2, 2)
+ hetero["node", "r", "node"].edge_index = torch.tensor(
+ [[0, 1], [1, 0]], dtype=torch.long
+ )
+
+ class _HomogeneousLooking:
+ """Sequence that is not a hetero container but yields HeteroData."""
+
+ def __getitem__(self, _index: int) -> HeteroData:
+ return hetero
+
+ koopman = SimpleNamespace(
+ auto_orbits=True,
+ isotypic_symmetry=False,
+ orbit_partition=None,
+ ensure_orbit_binding=lambda *_args, **_kwargs: None,
+ )
+ with pytest.raises(ValueError, match="homogeneous Data"):
+ bind_pending_orbit_ties(
+ SimpleNamespace(koopman=koopman),
+ [_HomogeneousLooking()], # type: ignore[list-item]
+ )
+
+
+def test_run_fit_loop_rejects_val_monitor_and_dual_window_args() -> None:
+ """Loop-level guards fire when ``fit`` resolution is bypassed."""
+ model = _make_model(seed=18)
+ sequence = _sequence(num_timesteps=4, seed=19)
+ with pytest.raises(ValueError, match="val_sequences"):
+ run_fit_loop(
+ model,
+ (sequence,),
+ epochs=1,
+ early_stopping_monitor="val",
+ )
+ sampler = WindowSampler((sequence,), window_length=3, batch_size=1, seed=0)
+ with pytest.raises(ValueError, match="sampler or window_length"):
+ run_fit_loop(
+ model,
+ (sequence,),
+ epochs=1,
+ sampler=sampler,
+ window_length=3,
+ )
+
+
+def test_fit_batch_graphs_validates_val_sequences() -> None:
+ """``batch_graphs=True`` also validates the held-out trajectories."""
+ model = _make_model(koopman="graph", seed=20)
+ train = _sequence(num_nodes=3, num_timesteps=3, seed=21)
+ val = _sequence(num_nodes=4, num_timesteps=3, seed=22)
+ history = model.fit(
+ train,
+ validation_sequence=val,
+ epochs=1,
+ lr=1e-2,
+ batch_graphs=True,
+ )
+ assert history.epochs == 1
+ assert history.val_loss is not None
+
+
+def test_identification_fit_trains_encoder_or_rejects_scheduler() -> None:
+ """Identification builds an encoder Adam step, or refuses a scheduler."""
+ sequence = _sequence(num_timesteps=5, seed=23)
+ trained = _make_model(seed=24)
+ history = trained.fit(
+ sequence,
+ epochs=1,
+ lr=1e-2,
+ identification=IdentificationConfig(solver="ridge", ridge=1e-2),
+ )
+ assert history.epochs == 1
+ assert trained.identification_report is not None
+
+ class _IdentityCodec(nn.Module):
+ """Parameter-free pass-through codec."""
+
+ def __init__(self, dim: int) -> None:
+ super().__init__()
+ self.in_channels = dim
+ self.latent_dim = dim
+ self.out_channels = dim
+ self.num_layers = 1
+
+ def forward(
+ self,
+ x_or_data: torch.Tensor | Data,
+ edge_index: torch.Tensor | None = None,
+ edge_weight: torch.Tensor | None = None,
+ ) -> torch.Tensor:
+ """Return node features unchanged."""
+ del edge_index, edge_weight
+ if isinstance(x_or_data, Data):
+ return x_or_data.x
+ return x_or_data
+
+ frozen = GraphKoopmanModel(
+ encoder=_IdentityCodec(3),
+ decoder=_IdentityCodec(3),
+ latent_dim=3,
+ time_step=0.1,
+ koopman_init_mode="identity",
+ koopman_init_scale=0.0,
+ )
+ dummy_opt = torch.optim.Adam(trained.parameters(), lr=1e-2)
+ with pytest.raises(ValueError, match="lr_scheduler"):
+ frozen.fit(
+ sequence,
+ epochs=1,
+ identification=IdentificationConfig(solver="ridge"),
+ lr_scheduler=StepLR(dummy_opt, step_size=1),
+ )
+
+
+def test_windowed_identification_without_trainable_params_raises() -> None:
+ """Windowed identification with no encoder parameters is refused."""
+
+ class _IdentityCodec(nn.Module):
+ """Parameter-free pass-through codec."""
+
+ def __init__(self, dim: int) -> None:
+ super().__init__()
+ self.in_channels = dim
+ self.latent_dim = dim
+ self.out_channels = dim
+ self.num_layers = 1
+
+ def forward(
+ self,
+ x_or_data: torch.Tensor | Data,
+ edge_index: torch.Tensor | None = None,
+ edge_weight: torch.Tensor | None = None,
+ ) -> torch.Tensor:
+ """Return node features unchanged."""
+ del edge_index, edge_weight
+ if isinstance(x_or_data, Data):
+ return x_or_data.x
+ return x_or_data
+
+ sequence = _sequence(num_timesteps=5, seed=25)
+ frozen = GraphKoopmanModel(
+ encoder=_IdentityCodec(3),
+ decoder=_IdentityCodec(3),
+ latent_dim=3,
+ time_step=0.1,
+ koopman_init_mode="identity",
+ koopman_init_scale=0.0,
+ )
+ with (
+ patch("koopman_graph.training.loop._validate_identification_fit_request"),
+ pytest.raises(ValueError, match="windowed fit requires trainable"),
+ ):
+ frozen.fit(
+ MultiTrajectory((sequence,)),
+ epochs=1,
+ window_length=3,
+ identification=IdentificationConfig(solver="ridge"),
+ )
+
+
+def test_fit_callbacks_observe_encodings_and_on_fit_end() -> None:
+ """Callbacks receive a frozen latent stack and a final history hook."""
+ model = _make_model(seed=30)
+ sequence = _sequence(num_timesteps=3, seed=31)
+ seen: list[tuple[int, ...]] = []
+
+ class _Observer(NoOpFitCallback):
+ """Record encoding shapes and fit-end epochs."""
+
+ def observe_encodings(self, encodings: torch.Tensor) -> None:
+ """Store the time-major latent shape."""
+ seen.append(tuple(encodings.shape))
+
+ history = model.fit(sequence, epochs=1, lr=1e-2, callbacks=[_Observer()])
+ assert history.epochs == 1
+ assert seen and seen[0][0] == sequence.num_timesteps
diff --git a/tests/training/test_device_movers.py b/tests/training/test_device_movers.py
index 49cda53..27ae620 100644
--- a/tests/training/test_device_movers.py
+++ b/tests/training/test_device_movers.py
@@ -134,6 +134,17 @@ def test_sequence_to_device_preserves_homogeneous_observation_masks() -> None:
torch.testing.assert_close(moved[0].edge_weight, snapshots[0].edge_weight)
+def test_sequence_to_device_preserves_homogeneous_parameter_trajectory() -> None:
+ """Homogeneous parameter_trajectory survives device transfer."""
+ snapshots = [_homo_snapshot(seed=i) for i in range(3)]
+ mu = torch.tensor([[0.0, 1.0], [0.5, 1.5], [1.0, 2.0]])
+ sequence = GraphSnapshotSequence(snapshots, parameter_trajectory=mu)
+ moved = sequence_to_device(sequence, _CPU)
+ assert moved.parameter_trajectory is not None
+ torch.testing.assert_close(moved.parameter_trajectory, mu)
+ assert moved.parameter_trajectory.device == _CPU
+
+
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available")
def test_hetero_sequence_cuda_cpu_round_trip() -> None:
"""Hetero sequence round-trips CPU → CUDA → CPU with matching stores."""
diff --git a/tests/training/test_graph_dynamics_losses.py b/tests/training/test_graph_dynamics_losses.py
new file mode 100644
index 0000000..709900f
--- /dev/null
+++ b/tests/training/test_graph_dynamics_losses.py
@@ -0,0 +1,183 @@
+"""Structural and presence losses for graph-state closure."""
+
+from __future__ import annotations
+
+import torch
+from torch_geometric.data import Data
+
+from koopman_graph import GNNDecoder, GNNEncoder, GraphKoopmanModel
+from koopman_graph.data import GraphDynamicsConfig, GraphSnapshotSequence
+from koopman_graph.training import LossWeights, compute_training_loss
+from koopman_graph.training.latent_cache import encode_sequence_latents
+from koopman_graph.training.objectives import compute_graph_dynamics_losses
+
+
+def _path_edge_index(num_nodes: int) -> torch.Tensor:
+ """Return a bidirectional path graph.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count (at least 2).
+
+ Returns
+ -------
+ Tensor
+ COO index.
+ """
+ src = list(range(num_nodes - 1)) + list(range(1, num_nodes))
+ dst = list(range(1, num_nodes)) + list(range(num_nodes - 1))
+ return torch.tensor([src, dst], dtype=torch.long)
+
+
+def _cycle_edge_index(num_nodes: int) -> torch.Tensor:
+ """Return a bidirectional cycle.
+
+ Parameters
+ ----------
+ num_nodes : int
+ Node count.
+
+ Returns
+ -------
+ Tensor
+ COO index.
+ """
+ forward_src = list(range(num_nodes))
+ forward_dst = list(range(1, num_nodes)) + [0]
+ src = forward_src + forward_dst
+ dst = forward_dst + forward_src
+ return torch.tensor([src, dst], dtype=torch.long)
+
+
+def _model(config: GraphDynamicsConfig | None) -> GraphKoopmanModel:
+ """Return a tiny homogeneous model.
+
+ Parameters
+ ----------
+ config : GraphDynamicsConfig or None
+ Graph-state config.
+
+ Returns
+ -------
+ GraphKoopmanModel
+ Untrained model.
+ """
+ return GraphKoopmanModel(
+ GNNEncoder(2, 8, 3, num_layers=1),
+ GNNDecoder(3, 8, 2, num_layers=1),
+ latent_dim=3,
+ time_step=0.1,
+ graph_dynamics=config,
+ )
+
+
+def _sequence(
+ *,
+ dynamic: bool = True,
+ presence: torch.Tensor | None = None,
+) -> GraphSnapshotSequence:
+ """Build a short homogeneous sequence.
+
+ Parameters
+ ----------
+ dynamic : bool, optional
+ Alternate path / cycle topologies when ``True``.
+ presence : Tensor or None, optional
+ Optional ``(T, N)`` presence masks.
+
+ Returns
+ -------
+ GraphSnapshotSequence
+ Three-step sequence on 5 nodes.
+ """
+ torch.manual_seed(0)
+ snapshots = []
+ for timestep in range(3):
+ edges = _path_edge_index(5) if timestep % 2 == 0 else _cycle_edge_index(5)
+ if not dynamic:
+ edges = _path_edge_index(5)
+ snapshots.append(Data(x=torch.randn(5, 2), edge_index=edges))
+ return GraphSnapshotSequence(
+ snapshots,
+ allow_dynamic_topology=dynamic,
+ presence_masks=presence,
+ allow_node_churn=presence is not None,
+ )
+
+
+def test_graph_dynamics_none_keeps_zero_topology_terms() -> None:
+ """Default models record zero topology / presence breakdown terms."""
+ model = _model(None)
+ sequence = _sequence(dynamic=False)
+ breakdown = compute_training_loss(model, sequence, LossWeights())
+ assert float(breakdown.topology) == 0.0
+ assert float(breakdown.presence) == 0.0
+
+
+def test_structural_bce_is_positive_on_dynamic_graphs() -> None:
+ """An untrained sparse head yields a positive structural term."""
+ model = _model(GraphDynamicsConfig(candidate_k=4))
+ sequence = _sequence(dynamic=True)
+ breakdown = compute_training_loss(model, sequence, LossWeights())
+ assert float(breakdown.topology) > 0.0
+ assert float(breakdown.presence) == 0.0
+ assert float(breakdown.total) > float(breakdown.reconstruction)
+
+
+def test_presence_bce_runs_when_masks_exist() -> None:
+ """Presence BCE is computed only when masks are attached."""
+ presence = torch.tensor(
+ [
+ [True, True, True, True, False],
+ [True, True, True, False, True],
+ [True, True, False, True, True],
+ ]
+ )
+ sequence = _sequence(dynamic=False, presence=presence)
+ model = _model(GraphDynamicsConfig(topology_head="none"))
+ cache = encode_sequence_latents(model, sequence)
+ topology, presence_loss = compute_graph_dynamics_losses(
+ model,
+ sequence,
+ cache=cache,
+ device=next(model.parameters()).device,
+ )
+ assert float(topology) == 0.0
+ assert float(presence_loss) > 0.0
+
+
+def test_teacher_force_does_not_change_reconstruction_topology() -> None:
+ """``recursive_training=False`` keeps teacher-forced one-step reconstruction."""
+ torch.manual_seed(1)
+ sequence = _sequence(dynamic=True)
+ teacher = _model(
+ GraphDynamicsConfig(recursive_training=False, topology_loss_weight=0.0)
+ )
+ recursive = _model(
+ GraphDynamicsConfig(recursive_training=True, topology_loss_weight=0.0)
+ )
+ recursive.encoder.load_state_dict(teacher.encoder.state_dict())
+ recursive.decoder.load_state_dict(teacher.decoder.state_dict())
+ recursive.koopman.load_state_dict(teacher.koopman.state_dict())
+ recursive.predicted_topology.load_state_dict(
+ teacher.predicted_topology.state_dict()
+ )
+ weights = LossWeights(reconstruction=1.0)
+ teacher_break = compute_training_loss(teacher, sequence, weights)
+ recursive_break = compute_training_loss(recursive, sequence, weights)
+ assert not torch.allclose(
+ teacher_break.reconstruction,
+ recursive_break.reconstruction,
+ atol=1e-6,
+ )
+
+
+def test_fit_history_records_topology_term() -> None:
+ """Fit history stores the unweighted topology series."""
+ model = _model(GraphDynamicsConfig(candidate_k=3))
+ sequence = _sequence(dynamic=True)
+ history = model.fit(sequence, epochs=2, lr=1e-2)
+ assert len(history.topology_loss) == 2
+ assert history.topology_loss[0] > 0.0
+ assert len(history.presence_loss) == 2
diff --git a/tests/training/test_resdmd_callback.py b/tests/training/test_resdmd_callback.py
index 9d9d31b..c1d6413 100644
--- a/tests/training/test_resdmd_callback.py
+++ b/tests/training/test_resdmd_callback.py
@@ -6,6 +6,10 @@
import pytest
import torch
+from tests.identification.test_fit_identification import (
+ _identity_model,
+ _linear_sequence,
+)
from koopman_graph.distributed.ray_train import multinode_recipe_enabled
from koopman_graph.operators import (
@@ -19,8 +23,11 @@ def test_resdmd_callback_hooks() -> None:
"""Observe-only ResDMD hooks reset, ignore epoch events, and no-op on end."""
callback = ResDMDFitCallback()
callback.record(0.5)
+ assert callback.polluted is True
+ assert callback.rejected is False
callback.on_fit_start(model=None, fit_kwargs={})
assert callback.last_mean_residual is None
+ assert callback.last_residual_max is None
callback.on_epoch_end(
epoch=0,
train_breakdown=TrainingLossBreakdown.zeros(torch.device("cpu")),
@@ -38,3 +45,57 @@ def test_resdmd_callback_and_sparse_eigs() -> None:
vals = sparse_leading_eigenvalues(torch.eye(4), num_modes=2)
assert vals.numel() == 2
assert multinode_recipe_enabled() is False
+
+
+def test_resdmd_callback_rejects_invalid_mode() -> None:
+ """Unknown ``mode`` values raise at construction."""
+ with pytest.raises(ValueError, match="mode must be"):
+ ResDMDFitCallback(mode="abort") # type: ignore[arg-type]
+
+
+def test_resdmd_gate_mode_raises_at_fit_end() -> None:
+ """Gate mode records pollution and raises from ``on_fit_end``."""
+ callback = ResDMDFitCallback(mode="gate")
+ callback.record(0.5)
+ assert callback.rejected is True
+ with pytest.raises(ValueError, match="ResDMD gate rejected"):
+ callback.on_fit_end(history=SimpleNamespace())
+
+
+def test_resdmd_observe_encodings_records_residual() -> None:
+ """Identity-dictionary stacks with ``T >= 3`` populate residuals."""
+ callback = ResDMDFitCallback()
+ true_k = torch.diag(torch.tensor([0.8, 0.5], dtype=torch.float64))
+ state = torch.tensor([0.4, -0.2], dtype=torch.float64)
+ rows = []
+ for _ in range(12):
+ rows.append(state.clone())
+ state = state @ true_k.T
+ encodings = torch.stack(rows, dim=0)
+ callback.observe_encodings(encodings)
+ assert callback.last_mean_residual is not None
+ assert callback.last_residual_max is not None
+ assert callback.polluted is False
+ stacked = ResDMDFitCallback()
+ stacked.observe_encodings(encodings.unsqueeze(1))
+ assert stacked.last_mean_residual is not None
+ short = ResDMDFitCallback()
+ short.observe_encodings(encodings[:2])
+ assert short.last_mean_residual is None
+ with pytest.raises(ValueError, match="shape"):
+ callback.observe_encodings(torch.zeros(4, dtype=torch.float64))
+
+
+def test_resdmd_callback_observe_during_identity_fit() -> None:
+ """Fit-loop wiring calls ``observe_encodings`` on the first sequence."""
+ true_k = torch.diag(torch.tensor([0.8, 0.5], dtype=torch.float64))
+ sequence, _ = _linear_sequence(true_k, n_nodes=1, n_times=16)
+ model = _identity_model(2, dtype=torch.float64)
+ callback = ResDMDFitCallback()
+ model.fit(sequence, epochs=1, callbacks=[callback], lr=0.0)
+ assert callback.last_mean_residual is not None
+ assert callback.last_residual_max is not None
+ assert callback.polluted is False
+ gated = ResDMDFitCallback(mode="gate")
+ model.fit(sequence, epochs=1, callbacks=[gated], lr=0.0)
+ assert gated.rejected is False
diff --git a/tests/uq/test_coverage.py b/tests/uq/test_coverage.py
new file mode 100644
index 0000000..f4d3f35
--- /dev/null
+++ b/tests/uq/test_coverage.py
@@ -0,0 +1,94 @@
+"""Tests for named coverage specs and conformal target naming."""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+import pytest
+
+from koopman_graph.model import GraphKoopmanModel
+from koopman_graph.nn import GNNDecoder, GNNEncoder
+from koopman_graph.uq import ConformalKoopmanUQ, JointCoverageSpec
+from koopman_graph.uq.coverage import require_shipped_coverage
+
+
+def test_joint_coverage_spec_defaults_to_per_node_marginal() -> None:
+ """Default estimand is named per-node marginal coverage."""
+ spec = JointCoverageSpec()
+ assert spec.target == "per_node_marginal"
+ assert spec.alpha == 0.1
+ assert spec.block == "none"
+ require_shipped_coverage(spec)
+
+
+def test_joint_coverage_spec_refuses_unshipped_estimands() -> None:
+ """Simultaneous / event / block targets are named but not implemented."""
+ with pytest.raises(ValueError, match="alpha"):
+ JointCoverageSpec(alpha=0.0)
+ with pytest.raises(ValueError, match="per_node_marginal"):
+ require_shipped_coverage(
+ JointCoverageSpec(target="simultaneous_node_feature_horizon")
+ )
+ with pytest.raises(ValueError, match="per_node_marginal"):
+ require_shipped_coverage(JointCoverageSpec(target="event"))
+ with pytest.raises(ValueError, match="none"):
+ require_shipped_coverage(JointCoverageSpec(block="temporal"))
+
+
+def test_conformal_coverage_is_named_not_implied() -> None:
+ """Conformal wrappers always expose a named per-node-marginal spec."""
+ model = GraphKoopmanModel(
+ GNNEncoder(2, 4, 2, num_layers=1),
+ GNNDecoder(2, 4, 2, num_layers=1),
+ latent_dim=2,
+ time_step=0.1,
+ )
+ wrapper = ConformalKoopmanUQ(model)
+ named = wrapper.coverage
+ assert named.target == "per_node_marginal"
+ assert named.block == "none"
+ assert named.alpha == 0.1
+ explicit = JointCoverageSpec(target="per_node_marginal", alpha=0.2)
+ named_wrapper = ConformalKoopmanUQ(model, coverage=explicit)
+ assert named_wrapper.coverage.alpha == 0.2
+ with pytest.raises(ValueError, match="not implemented"):
+ ConformalKoopmanUQ(
+ model,
+ coverage=JointCoverageSpec(target="simultaneous_node_feature_horizon"),
+ )
+
+
+def test_conformal_calibrate_alpha_must_match_named_spec() -> None:
+ """calibrate alpha cannot silently disagree with the named spec."""
+ model = GraphKoopmanModel(
+ GNNEncoder(2, 4, 2, num_layers=1),
+ GNNDecoder(2, 4, 2, num_layers=1),
+ latent_dim=2,
+ time_step=0.1,
+ )
+ wrapper = ConformalKoopmanUQ(
+ model,
+ coverage=JointCoverageSpec(target="per_node_marginal", alpha=0.2),
+ )
+ with pytest.raises(ValueError, match="coverage.alpha"):
+ wrapper.calibrate([], steps=1, alpha=0.1)
+
+
+def test_coverage_module_does_not_import_model_or_operators() -> None:
+ """Coverage records stay below model / operator layers."""
+ source = Path(__file__).resolve().parents[2] / "src/koopman_graph/uq/coverage.py"
+ tree = ast.parse(source.read_text(encoding="utf-8"))
+ imported: list[str] = []
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ imported.extend(alias.name for alias in node.names)
+ elif isinstance(node, ast.ImportFrom) and node.module is not None:
+ imported.append(node.module)
+ forbidden = ("koopman_graph.model", "koopman_graph.operators")
+ offenders = [
+ name
+ for name in imported
+ if any(name == prefix or name.startswith(f"{prefix}.") for prefix in forbidden)
+ ]
+ assert not offenders
diff --git a/tests/uq/test_scores.py b/tests/uq/test_scores.py
new file mode 100644
index 0000000..9b5c252
--- /dev/null
+++ b/tests/uq/test_scores.py
@@ -0,0 +1,51 @@
+"""Tests for Gaussian and ensemble proper scores."""
+
+from __future__ import annotations
+
+import math
+
+import pytest
+import torch
+
+from koopman_graph.uq import energy_score, gaussian_crps, gaussian_nll
+
+
+def test_gaussian_nll_and_crps_prefer_the_true_oracle() -> None:
+ """The matching Gaussian scores lower than a shifted mean."""
+ observation = torch.zeros(8)
+ mean = torch.zeros(8)
+ variance = torch.ones(8)
+ true_nll = gaussian_nll(observation, mean, variance)
+ wrong_nll = gaussian_nll(observation, mean + 1.5, variance)
+ assert float(true_nll) < float(wrong_nll)
+ true_crps = gaussian_crps(observation, mean, variance)
+ wrong_crps = gaussian_crps(observation, mean + 1.5, variance)
+ assert float(true_crps) < float(wrong_crps)
+ expected_nll = 0.5 * math.log(2.0 * math.pi)
+ assert float(true_nll) == pytest.approx(expected_nll, rel=0.0, abs=1e-6)
+
+
+def test_energy_score_prefers_centered_samples() -> None:
+ """An ensemble around the observation scores better than a shift."""
+ generator = torch.Generator().manual_seed(0)
+ observation = torch.zeros(2)
+ samples = torch.randn(16, 2, generator=generator)
+ shifted = samples + 2.0
+ true_score = energy_score(observation, samples)
+ wrong_score = energy_score(observation, shifted)
+ assert float(true_score) < float(wrong_score)
+
+
+def test_score_guards() -> None:
+ """Variance, reduction, and ensemble shape errors raise."""
+ y = torch.zeros(2)
+ with pytest.raises(ValueError, match="positive"):
+ gaussian_nll(y, y, torch.zeros(2))
+ with pytest.raises(ValueError, match="finite"):
+ gaussian_crps(y, y, torch.tensor([1.0, float("nan")]))
+ with pytest.raises(ValueError, match="reduction"):
+ gaussian_nll(y, y, torch.ones(2), reduction="median") # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="at least 2"):
+ energy_score(y, torch.zeros(1, 2))
+ with pytest.raises(ValueError, match="samples\\[1:\\]"):
+ energy_score(torch.zeros(3), torch.zeros(4, 2))
diff --git a/uv.lock b/uv.lock
index e3f992e..58e6959 100644
--- a/uv.lock
+++ b/uv.lock
@@ -42,7 +42,7 @@ wheels = [
[[package]]
name = "aiohttp"
-version = "3.14.2"
+version = "3.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -55,126 +55,126 @@ dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "yarl" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/1d/cc/58f26f118d8099f84e009ce560b9148a3f803e63fa8473b57feb67241875/aiohttp-3.14.2.tar.gz", hash = "sha256:f96821eb2ae2f12b0dfa799eafbf221f5621a9220b457b4744a269a63a5f3a6c", size = 7969860, upload-time = "2026-07-20T19:53:26.881Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/86/37/6a24e23d559bc2d042287cee15b4a6e61b46f844d2b3c96832483f854a4a/aiohttp-3.14.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ceb77c159b2b4c1a179b96a26af36bcaa68eb79c393ec4f569386a69d013cbe9", size = 765793, upload-time = "2026-07-20T19:49:37.707Z" },
- { url = "https://files.pythonhosted.org/packages/dc/14/23aa28408805efe6adc0e97048f5cd6028a2a3036d237038f45d10dc05a6/aiohttp-3.14.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3f3381f81bc1c6cbe160b2a3708d39d05014329118e6b648b95edc841eeeebd4", size = 517440, upload-time = "2026-07-20T19:49:40.182Z" },
- { url = "https://files.pythonhosted.org/packages/24/2d/7fc2d359d6702ce5299cd865ec2c70dad2fd8c1b3901bc79f83e3c61c395/aiohttp-3.14.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:483b6f964bbbdaa99a0cd7def631208c44e39d243b95cff23ebc812db8a80e03", size = 515306, upload-time = "2026-07-20T19:49:41.818Z" },
- { url = "https://files.pythonhosted.org/packages/dc/f0/fd9c7019bdfba5529c9a697621a757f0cfd92787595a04a9252923b097fc/aiohttp-3.14.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc056948b7a8a40484b4bbc69923fa25cddd80cbc5f236a3a22ad2f836baeed2", size = 1708602, upload-time = "2026-07-20T19:49:43.351Z" },
- { url = "https://files.pythonhosted.org/packages/3d/6d/8de300d792a3b91a94533a626a835c32cda69189209ae539bebaadba88ad/aiohttp-3.14.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:135570f5b470c72c4988a58986f1f847ad336721f77fcc18fda8472bd3bbe3db", size = 1673881, upload-time = "2026-07-20T19:49:44.753Z" },
- { url = "https://files.pythonhosted.org/packages/65/27/37559302e4f03e343f549ff1628295c20fb0298d603e40193db2f25f5153/aiohttp-3.14.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ee5bdd7933c653e43ef8d720704a4e228e4927121f2f5f598b7efe6a4c18633a", size = 1766985, upload-time = "2026-07-20T19:49:46.464Z" },
- { url = "https://files.pythonhosted.org/packages/df/55/29cc3691fa0bb1334352bcb61ba5f256396fd6a0603cf321b8c29bb7e1fe/aiohttp-3.14.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0baed2a2367a28456b612f4c3fd28bb86b00fadfb6454e706d8f65c21636bfd7", size = 1858788, upload-time = "2026-07-20T19:49:48.015Z" },
- { url = "https://files.pythonhosted.org/packages/71/63/36a34c0139d0b1a830e8ee130a75312e9eddccb5b234a7f56e9de5e6edd2/aiohttp-3.14.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecdd6b8cab5b7c0ff2988378c11ba7192f076a1864e64dc3ff72f7ba05c71796", size = 1714003, upload-time = "2026-07-20T19:49:49.714Z" },
- { url = "https://files.pythonhosted.org/packages/ac/d8/c3a37079d503967a4072f8fc187c9d46c62b1eb72b25ae57dc0f2d0ddb11/aiohttp-3.14.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a3177e51e26e0158fb3376aebac97e0546c6f175c510f331f585e514a00a302b", size = 1588046, upload-time = "2026-07-20T19:49:51.209Z" },
- { url = "https://files.pythonhosted.org/packages/ba/0c/c42b45bb275983cde529a4bc0e94f23ca6cdfe2a3c97285e73a4a6b81c23/aiohttp-3.14.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:86861a430657bc71e0f89b195de5f8fa495c0b9b5864cf2f89bd5ec1dbb6b77a", size = 1677219, upload-time = "2026-07-20T19:49:52.89Z" },
- { url = "https://files.pythonhosted.org/packages/75/a1/bdcd55b54d6335bc33e5f1b9f8613501c7f35cd99f44bf462d540d6b30d8/aiohttp-3.14.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:aac1b05fc5e2ef188b6d74cf151e977db75ab281238f30c3163bbd6f797788e3", size = 1691747, upload-time = "2026-07-20T19:49:54.495Z" },
- { url = "https://files.pythonhosted.org/packages/f1/98/a0d751d3787f2ca457beb16de5774a24e53fab840a8c7cb727cf900ed301/aiohttp-3.14.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:89120e926c68c4e60c78514d76e16fc15689d8df35843b2a6bf6c4cc0d64b11a", size = 1735039, upload-time = "2026-07-20T19:49:56.269Z" },
- { url = "https://files.pythonhosted.org/packages/ea/7a/33b8a5d348abc7335de33f4332ec0dcedfc7d45db28fd2162802d306345d/aiohttp-3.14.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:82d14d66d6147441b6571833405c828980efc17bda98075a248104ffdd330c30", size = 1577209, upload-time = "2026-07-20T19:49:57.926Z" },
- { url = "https://files.pythonhosted.org/packages/a9/03/09d41c3866e2102e6679e49fd78cd928926326fd304fa4e6f1d155f8ba04/aiohttp-3.14.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6cde463b9dd9ce4343785c5a39127b40fce059ae6fbd320f5a045a38c3d25cd0", size = 1751303, upload-time = "2026-07-20T19:49:59.472Z" },
- { url = "https://files.pythonhosted.org/packages/eb/53/72115db0f3e4490b6c0cb063709d7553a1661ec03532b1b2f891e841af1b/aiohttp-3.14.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2280d165ab38355144d9984cdce77ce506cee019a07390bab7fd13682248ce91", size = 1699526, upload-time = "2026-07-20T19:50:01.328Z" },
- { url = "https://files.pythonhosted.org/packages/0c/c6/9b8964be6d8d67dcf4bf32ff2380c203380322b1e23b5c1aa122c9b5a740/aiohttp-3.14.2-cp310-cp310-win32.whl", hash = "sha256:5e94a8c4445bfdaa30773c81f2be7f129673e0f528945e542b8bd024b2979134", size = 456626, upload-time = "2026-07-20T19:50:02.79Z" },
- { url = "https://files.pythonhosted.org/packages/bf/10/8dcdbbd9d83103db95ff74b255105113c1f6983865446f4facef81f0a1eb/aiohttp-3.14.2-cp310-cp310-win_amd64.whl", hash = "sha256:65cd3bb118f42fceceb9e8a615c735a01453d019c673f35c57b420601cc1a83a", size = 480367, upload-time = "2026-07-20T19:50:04.284Z" },
- { url = "https://files.pythonhosted.org/packages/14/08/b6eb0532f5ec2b202980c2d9658dd8ee3a1e21cdd0cb9d01debc0ba40045/aiohttp-3.14.2-cp310-cp310-win_arm64.whl", hash = "sha256:2a382aa6bb85347515ead043257445baeec0885d42bfedb962093b134c3b4816", size = 453374, upload-time = "2026-07-20T19:50:05.953Z" },
- { url = "https://files.pythonhosted.org/packages/f6/4a/a6c1426d2fb3cddd901575436803733caaaeb7ac0a49aa72d1930d2e3ab6/aiohttp-3.14.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:56432ee8f7abe47c97717cfbf5c32430463ea8a7138e12a87b7891fa6084c8ff", size = 764168, upload-time = "2026-07-20T19:50:07.389Z" },
- { url = "https://files.pythonhosted.org/packages/0c/62/88ae65afc3590719788cbff3b2e0f56e6850f53ca34114cbb371e677c543/aiohttp-3.14.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6c244f7a65cbec04c830a301aae443c529d4dbca5fddfd4b19e5a179d896adfd", size = 516255, upload-time = "2026-07-20T19:50:08.84Z" },
- { url = "https://files.pythonhosted.org/packages/8f/bc/ad9b767785b014f2f57497a2ccf67e3d4316d153f7ed1c7715fbbd7573fe/aiohttp-3.14.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1c05afdd28ecacce5a1f63275a2e3dce09efddd3a63d143ee9799fda83989c8d", size = 514670, upload-time = "2026-07-20T19:50:10.325Z" },
- { url = "https://files.pythonhosted.org/packages/48/94/697aef63f15ef354f64723eaac6ccfb289d59b99699b3b7cb1ff663b2045/aiohttp-3.14.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a57f39d6ec155932853b6b0f130cbbafab3208240fa807f29a2c96ea52b77ae1", size = 1780650, upload-time = "2026-07-20T19:50:11.676Z" },
- { url = "https://files.pythonhosted.org/packages/99/f9/6a1994c1bf138403cf10171cb618571a330ae5683a5803b1ef29b5723d7f/aiohttp-3.14.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fc31339824ec922cb7424d624b5b6c11d8942d077b2585e5bd602ca1a1e27ed", size = 1737710, upload-time = "2026-07-20T19:50:13.401Z" },
- { url = "https://files.pythonhosted.org/packages/8b/ba/abe8d5015148b2cd7189473789461058dcf88ae202d4cf1cbecaf291a7ef/aiohttp-3.14.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d93854e215dcc7c88e4f530827193c1a594e2662931d8dbe7cca3abf52a7082d", size = 1845568, upload-time = "2026-07-20T19:50:15.168Z" },
- { url = "https://files.pythonhosted.org/packages/c2/89/98a2688810f469f19f2d02a298426a37b6e1cc420230a8cc7d6c85af7a01/aiohttp-3.14.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87c9b03be0c18c3b3587be979149830381e37ac4a6ca8557dbe72e44fcad66c3", size = 1928485, upload-time = "2026-07-20T19:50:16.922Z" },
- { url = "https://files.pythonhosted.org/packages/5a/fd/c980f64f5e5bff22f07968bcc6af1b64167dfd21dbbffda86f2d153fc802/aiohttp-3.14.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc1a0793dce8fa9bb6906411e57fb18a2f1c31357b04172541b92b30337362a7", size = 1786216, upload-time = "2026-07-20T19:50:18.598Z" },
- { url = "https://files.pythonhosted.org/packages/da/80/c710ce9c7f22fcbb83366aa64a5ccf03dced35ab09a6e37545656a6df01e/aiohttp-3.14.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2f1b9540d2d0f2f95590528a1effd0ba5370f6ec189ac925e70b5eecae02dc77", size = 1636921, upload-time = "2026-07-20T19:50:20.201Z" },
- { url = "https://files.pythonhosted.org/packages/e1/b2/56ce1b0c535d188ef94c05bccbf3f5b1aaea1231fcb073318977bdd917e9/aiohttp-3.14.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c0a968b04fecf7c94e502015860ad1e2e112c6b761e97b6fdf65fbb374e22b73", size = 1753040, upload-time = "2026-07-20T19:50:21.857Z" },
- { url = "https://files.pythonhosted.org/packages/86/61/4318a947139a21d4e1a40e7f5a92799d0fe28feffaca6640a4a165e62b0e/aiohttp-3.14.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2d2eedae227cd5cbd0bccc5e759f71e1af2cd77b7f74ce413bb9a2b87f94a272", size = 1760811, upload-time = "2026-07-20T19:50:23.623Z" },
- { url = "https://files.pythonhosted.org/packages/81/b0/2012165377d029ceb925c9e5f42b0b30a39d89cf7e494c4100f3ab8d27c6/aiohttp-3.14.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9d3f4c68b2c2cd282b65e558cebf4b27c8b440ab511f2b938a643d3598df2ddb", size = 1819760, upload-time = "2026-07-20T19:50:25.34Z" },
- { url = "https://files.pythonhosted.org/packages/16/e5/1de93ba3bc18edb87f71594326bedafdc38fe725da8118b7d36fd56610f8/aiohttp-3.14.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:d32a70b8bf8836fd80d4169d9e34eb032cd2a7cbccb0b9cf00eac1f40732467c", size = 1628145, upload-time = "2026-07-20T19:50:26.992Z" },
- { url = "https://files.pythonhosted.org/packages/66/8f/adc9e8592449ee08f72fcce94b7f3f1a9bbf0591a46026e4d322e5a70d7a/aiohttp-3.14.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:386ce4e709b4cc40f9ef9a132ad8e672d2d164a65451305672df656e7794c68e", size = 1832577, upload-time = "2026-07-20T19:50:28.546Z" },
- { url = "https://files.pythonhosted.org/packages/9b/7f/700500700513b1b63967a24c4e189aad377e18f9d67b7387b3418d6213cb/aiohttp-3.14.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc0ed30b942c3bd755583d74bb00b90248c067d20b1f8301e4489a53a33aa65f", size = 1774814, upload-time = "2026-07-20T19:50:30.144Z" },
- { url = "https://files.pythonhosted.org/packages/a5/d6/580f9b2ab4c78d9fbf9d56c85fb7f7d4d22d667b27df8032ab9bf7140e37/aiohttp-3.14.2-cp311-cp311-win32.whl", hash = "sha256:b5ed2c7dacebf4950d6b4a1b22548e4d709bb15e0287e064a7cdb32ada65893a", size = 455968, upload-time = "2026-07-20T19:50:31.722Z" },
- { url = "https://files.pythonhosted.org/packages/96/76/33665ae48e24e01c6dfa15ed14d9d9eec1349577cef497f3b26210621f0a/aiohttp-3.14.2-cp311-cp311-win_amd64.whl", hash = "sha256:bf7951959a8e89f2d4a1e719e60d3ea4e8fc26f011ee3aed09598ad786b112f7", size = 480978, upload-time = "2026-07-20T19:50:33.299Z" },
- { url = "https://files.pythonhosted.org/packages/5e/19/00963031da42bd23716032f57b2a8414c5355eb1abc41805b2b41173040c/aiohttp-3.14.2-cp311-cp311-win_arm64.whl", hash = "sha256:c167127a3b6089ef78ac2e33582c38040d51688ee28474b5053acf55f192187b", size = 452900, upload-time = "2026-07-20T19:50:35.049Z" },
- { url = "https://files.pythonhosted.org/packages/e0/99/8737b10b6fa447371541a3b2cdbf9703c31ecf3afff4998f2f0633646a57/aiohttp-3.14.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:30e41662123806e4590a0440585122ac33c89a2465a8be81cc1b50656ca0e432", size = 754562, upload-time = "2026-07-20T19:50:36.672Z" },
- { url = "https://files.pythonhosted.org/packages/7c/e5/fd624b8b46d99dfd8f7f75111569b5ee21850539e914eed04ce39e4455ea/aiohttp-3.14.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbc45e2773c66d14fbd337754e9bf23932beef539bd539716a721f5b5f372034", size = 509386, upload-time = "2026-07-20T19:50:38.268Z" },
- { url = "https://files.pythonhosted.org/packages/6d/a8/473db85d08b862724c506b51af2b9c6327e9e95cbd297c4c6f33968be1a8/aiohttp-3.14.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:476cf7fac10619ad6d08e1df0225d07b5a8d57c04963a171ad845d5a349d47ef", size = 511905, upload-time = "2026-07-20T19:50:39.862Z" },
- { url = "https://files.pythonhosted.org/packages/c1/e8/aa5ebff13ac5e39ffae8add143757e936d1c2ceb726b82786e5f5cb9912c/aiohttp-3.14.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40bedff39ea83185f3f98a41155dd9da28b365c432e5bd90e7be140bcef0b7f3", size = 1764968, upload-time = "2026-07-20T19:50:41.559Z" },
- { url = "https://files.pythonhosted.org/packages/f4/22/23ec6d3d3f24f5961c7be73d5127481c992d6c92ca213f336ed8f0ff1453/aiohttp-3.14.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a26f14006883fc7662e21041b4311eac1acbc977a5c43aacb27ff17f8a4c28b2", size = 1740635, upload-time = "2026-07-20T19:50:43.363Z" },
- { url = "https://files.pythonhosted.org/packages/a0/7b/f8966d2d08f0582525ef02d5af007d2d2467cb733abf1d1f7849a02a6b98/aiohttp-3.14.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:673217cbc9370ebf8cd048b0889d7cbe922b7bb48f4e4c02d31cfefa140bd946", size = 1810447, upload-time = "2026-07-20T19:50:45.258Z" },
- { url = "https://files.pythonhosted.org/packages/39/f5/239f2a828b033ac244237074db7b560ad3279bb1e934bb462dbde19f3877/aiohttp-3.14.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b39dbdbe30a44958d63f3f8baa2af68f24ec8a631dcd18a33dd76dfa2a0eb917", size = 1905896, upload-time = "2026-07-20T19:50:47.022Z" },
- { url = "https://files.pythonhosted.org/packages/6c/6e/fb84b8dafc521026355aadbeed484fc1ec44a05aae927de309f204c1f0af/aiohttp-3.14.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d15f618255fcbe5f54689403aa4c2a90b6f2e6ebc96b295b1cb0e868c1c12384", size = 1792052, upload-time = "2026-07-20T19:50:48.683Z" },
- { url = "https://files.pythonhosted.org/packages/ba/c4/25b199009f164ee02599b9b0962d22e6533d212418e49df9f4fdb37fe2eb/aiohttp-3.14.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ae767b7dffd316cc2d0abf3e1f90132b4c1a2819a32d8bcb1ba749800ea6273", size = 1590939, upload-time = "2026-07-20T19:50:50.702Z" },
- { url = "https://files.pythonhosted.org/packages/ad/f5/96d7540a52620433db3822267008a1697fc816979c109a4f535855ac893c/aiohttp-3.14.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3ec4b6501a076b2f73844256da17d6b7acb15bb74ee0e908a67feb9412371166", size = 1725039, upload-time = "2026-07-20T19:50:52.506Z" },
- { url = "https://files.pythonhosted.org/packages/f6/50/1a06abbeec14a2bcb46124ca6e281ed4ccb3e9006542ded6bc89a0ce5224/aiohttp-3.14.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7e328d02fb46b9a8dbfa070d98967e8b7eaa1d9ee10ae03fb664bdf30d58ccf0", size = 1764748, upload-time = "2026-07-20T19:50:54.336Z" },
- { url = "https://files.pythonhosted.org/packages/70/5d/b8821b362306627ea8ca11fe49ec47cba18e7c75d975fa5f6710ad93845c/aiohttp-3.14.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c0c7f2e5fe10910d5ab76438f269cc41bb7e499fd48ded978e926360ab1790c8", size = 1777119, upload-time = "2026-07-20T19:50:56.306Z" },
- { url = "https://files.pythonhosted.org/packages/81/e5/805d9d49e66c6358574ad70dd731031585d18fe95ae65b857bc378bccc9a/aiohttp-3.14.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:66de80888db2176655f8df0b705b817f5ae3834e6566cc2caa89360871d90195", size = 1580047, upload-time = "2026-07-20T19:50:58.316Z" },
- { url = "https://files.pythonhosted.org/packages/c6/fa/1536f272d5ad93bf8b75043b02b94c24bf4e6b55a849d3287553ad6da97e/aiohttp-3.14.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f2f9950b2dd0fc896ab520ea2366b7df6484d3d164a65d5e9f28f7b0e5742d8a", size = 1796861, upload-time = "2026-07-20T19:51:00.373Z" },
- { url = "https://files.pythonhosted.org/packages/e6/f3/86dfd6152bb706351cf5c6b45f0f6a1818a4e559d1e88d899c4c673c23a8/aiohttp-3.14.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cc4435b16dc246c5dfa7f2f8ee71b10a30765018a090ee36e99f356b1e9b75cc", size = 1768687, upload-time = "2026-07-20T19:51:02.214Z" },
- { url = "https://files.pythonhosted.org/packages/57/af/14b98e07fcd1a2b37ebb2f950309821eaa56e0eac3d32fc23dc55227e49b/aiohttp-3.14.2-cp312-cp312-win32.whl", hash = "sha256:4ca802547f1128008addfc21b24959f5cbf30a8952d365e7daa078a0d884b242", size = 451437, upload-time = "2026-07-20T19:51:03.954Z" },
- { url = "https://files.pythonhosted.org/packages/38/20/8478c05702a369b6d0800e40e0c5f9a289ff482d0921faec1eb17727339d/aiohttp-3.14.2-cp312-cp312-win_amd64.whl", hash = "sha256:e5efff8bfd27c44ce1bfdf92ce838362d9316ed8b2ed2f89f581dbe0bbe05acf", size = 476776, upload-time = "2026-07-20T19:51:05.705Z" },
- { url = "https://files.pythonhosted.org/packages/d0/d0/e550bb1cb4fc82de3bdbe2add061cc0cedb34f2e7f01ada78fba94493ca1/aiohttp-3.14.2-cp312-cp312-win_arm64.whl", hash = "sha256:0eb1c9fd51f231ac8dc9d5824d5c2efc45337d429db0123fa9d4c20f570fdfc3", size = 447928, upload-time = "2026-07-20T19:51:07.523Z" },
- { url = "https://files.pythonhosted.org/packages/96/3a/8b8779f8e813d3a33cf16bc836a7ea7bf3c27b6588d42efa36b0c32fcb58/aiohttp-3.14.2-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:17eecd6ee9bfc8e31b6003137d74f349f0ac3797111a2df87e23acb4a7a912ea", size = 506205, upload-time = "2026-07-20T19:51:09.215Z" },
- { url = "https://files.pythonhosted.org/packages/d1/73/2f45204b37ecc4ce2c54b8b67254d70a8ea3b629c4083d58f3e2d27991cb/aiohttp-3.14.2-cp313-cp313-android_21_x86_64.whl", hash = "sha256:ce8dfb58f012f76258f29951d38935ac928b32ae24a480f30761f2ed5036fa78", size = 515116, upload-time = "2026-07-20T19:51:10.914Z" },
- { url = "https://files.pythonhosted.org/packages/f0/22/8f4424de1f50df4fbb74ea32006f9cff798ae0d346ffa6be7b6f0caca719/aiohttp-3.14.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:4181d72e0e6d1735c1fae56381193c6ae211d584d06413980c00775b9b2a176a", size = 486244, upload-time = "2026-07-20T19:51:12.878Z" },
- { url = "https://files.pythonhosted.org/packages/c2/53/60de022260f5d2d31976bc5f50db708e10671f0ac5da515402adcc6b4d4d/aiohttp-3.14.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:0e56babe35076f69ec9327833b71439eeccd10f51fe56c1a533da8f24923f014", size = 492260, upload-time = "2026-07-20T19:51:14.555Z" },
- { url = "https://files.pythonhosted.org/packages/b2/6f/d3ccc31c15cd33888670b8f1bf1dc06f3239b4edd156fc818078e87ae628/aiohttp-3.14.2-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6b63709e259e3b3d7922b235606564e91ed4c224e777cc0ca4cae04f5f559206", size = 502174, upload-time = "2026-07-20T19:51:16.3Z" },
- { url = "https://files.pythonhosted.org/packages/40/20/52d21e485652f4708015ce27dd3029e63140e0bdb0dcc7bf72dc0bc3f4ad/aiohttp-3.14.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f7c10c4d0b33888a68c192d883d1390d4596c116a59bf689e6d352c6739b7940", size = 750878, upload-time = "2026-07-20T19:51:18.013Z" },
- { url = "https://files.pythonhosted.org/packages/2e/49/1220bdc73d568431cc3856667405d78c2e95eb57fed2b9ffa5e825932ab2/aiohttp-3.14.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f7b19e27b78a3a927b1932af93af7645806153e8f541cee8fe856426142503f", size = 508459, upload-time = "2026-07-20T19:51:19.876Z" },
- { url = "https://files.pythonhosted.org/packages/ed/f1/39177406ce0ae5cb2782b35e848c0edf6e09d6e082df636eeacdbdfa70bd/aiohttp-3.14.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:18fcc3a5cc7dde1d8f7903e309055294c28894c9434588645817e374f3b83d03", size = 509179, upload-time = "2026-07-20T19:51:21.719Z" },
- { url = "https://files.pythonhosted.org/packages/87/f7/6d081c2ee838458492c7ae1062399bdd68f149811d257d4502e79a25b306/aiohttp-3.14.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09d1b0deec698d1198eb0b8f910dd9432d856985abbfea3f06be8b296a6619b4", size = 1761346, upload-time = "2026-07-20T19:51:23.437Z" },
- { url = "https://files.pythonhosted.org/packages/1d/ed/0befea4ae533f09c3a60f0d10b923cd4a90027a1bf49dfafaa24eeee08b8/aiohttp-3.14.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cabaaecb4c6888bd9abafac151051377534dad4c3859a386b6325f39d3732f99", size = 1735057, upload-time = "2026-07-20T19:51:25.512Z" },
- { url = "https://files.pythonhosted.org/packages/de/03/c1d89fcd94907f93a08d4aadbb4e1d9bd3d7d6c9f94bc9c17689de38107b/aiohttp-3.14.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:114299c08cce8ad4ebb21fafe766378864109e88ad8cf63cf6acb384ff844a57", size = 1800389, upload-time = "2026-07-20T19:51:27.585Z" },
- { url = "https://files.pythonhosted.org/packages/3a/8b/edbac4d1a1cde3571588a1e5b40637df5acee43158efdf3ae9a161fde8ab/aiohttp-3.14.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6bea8451e26cd67645d9b2ee18232e438ddfc36cea35feecb4537f2359fc7030", size = 1895342, upload-time = "2026-07-20T19:51:29.794Z" },
- { url = "https://files.pythonhosted.org/packages/0c/31/2e9ca3b21c07ea8a001e2b142cb01401fd34fd07b248a0b95a04c3ebc4ec/aiohttp-3.14.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46b8887aa303075c1e5b24123f314a1a7bbfa03d0213dff8bb70503b2148c853", size = 1789175, upload-time = "2026-07-20T19:51:31.744Z" },
- { url = "https://files.pythonhosted.org/packages/6f/91/b57d3d13a80fbf765b19ae0c38d783a3a23f35b1cb82ae3b22f91abe6c24/aiohttp-3.14.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:de3b04a3f7b40ad7f1bcd3540dd447cf9bd93d57a49969bca522cbcf01290f08", size = 1586845, upload-time = "2026-07-20T19:51:33.571Z" },
- { url = "https://files.pythonhosted.org/packages/69/e8/a2f5f9f6219cd21190e9b50e2cbc978d12a2aab748d0aa7c6ee930db15c3/aiohttp-3.14.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:42372e1f1a8dca0dcd5daf922849004ec1120042d0e24f14c926f97d2275ca79", size = 1724839, upload-time = "2026-07-20T19:51:35.518Z" },
- { url = "https://files.pythonhosted.org/packages/de/d4/a2b9442336051714dd8dbd419b9f4a1003c45bb850ee0f3af87f2743e867/aiohttp-3.14.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:7871c94f3400358530ac4906dd7a526c5a24099cd5c48f53ffc4b1cb5037d7d7", size = 1756306, upload-time = "2026-07-20T19:51:37.459Z" },
- { url = "https://files.pythonhosted.org/packages/ab/93/22c94a599b2c4ea6ab88f1b912d90883f1fba5235b587ea9f13b148e9a0f/aiohttp-3.14.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f8f371794319a8185e61e15ba5e1be8407b986ebce1ade11856c02d24e090577", size = 1769133, upload-time = "2026-07-20T19:51:39.9Z" },
- { url = "https://files.pythonhosted.org/packages/fe/db/a4e4c207f7023e83c281a7cf3579f227bade48f1899cb6588c0ed6ae54de/aiohttp-3.14.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:af63ac06bad85191e6a0c4a733cb3c55adb99f8105bc7ce9913391561159a49a", size = 1578671, upload-time = "2026-07-20T19:51:42.418Z" },
- { url = "https://files.pythonhosted.org/packages/8d/6f/33746ba4947b8193652fc7ed0533e09f93b118e39777228f3029e5677c17/aiohttp-3.14.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:8c2cdb684c153f377157e856257ee8535c75d8478343e4bb1e83ca73bdfa3d31", size = 1791778, upload-time = "2026-07-20T19:51:44.298Z" },
- { url = "https://files.pythonhosted.org/packages/06/41/323856cb0ec9076b1f9a135eb0684f5f984dd97989a087873768aa34ca2f/aiohttp-3.14.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ceff4f84c1d928654faa6bcb0437ed095b279baae2a35fcfe5a3cbe0d8b9725d", size = 1768490, upload-time = "2026-07-20T19:51:46.279Z" },
- { url = "https://files.pythonhosted.org/packages/39/f0/09e29e457b6fb49253cbb61354ed2541fdf23ac192bddbb57bb34bc93d9c/aiohttp-3.14.2-cp313-cp313-win32.whl", hash = "sha256:15292b08ce7dd45e268fce542228894b4735102e8ee77163bd665b35fc2b5598", size = 451064, upload-time = "2026-07-20T19:51:48.194Z" },
- { url = "https://files.pythonhosted.org/packages/0b/76/b44703eafcfe1446f4f975be1ef2406042955425eaf56450e71b50506c49/aiohttp-3.14.2-cp313-cp313-win_amd64.whl", hash = "sha256:fc2d8e7373ceba7e1c7e9dc00adac854c2701a6d443fd21d4af2e49342d727bd", size = 476140, upload-time = "2026-07-20T19:51:50.285Z" },
- { url = "https://files.pythonhosted.org/packages/72/48/b095935c9e72a253439f80ec757b91d4733e0a9a98b63e108d84aaf39b08/aiohttp-3.14.2-cp313-cp313-win_arm64.whl", hash = "sha256:70570f50bda5037b416db8fcba595cf808ecf0fdce12d64e850b5ae1db7f64d4", size = 447585, upload-time = "2026-07-20T19:51:52.333Z" },
- { url = "https://files.pythonhosted.org/packages/4f/ca/716f720dccc3032e40dc80d0ab2d87a7365270a714976a85bdff73bc7254/aiohttp-3.14.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7719cef2a9dc5e10cd5f476ec1744b25c5ac4da733a9a687d91c42de7d4afe30", size = 508899, upload-time = "2026-07-20T19:51:54.165Z" },
- { url = "https://files.pythonhosted.org/packages/f5/80/786166589e71b3f3cfce56b414170fe5008462d0bff100bd8837285632f6/aiohttp-3.14.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:3523ec0cc524a413699f25ec8340f3da368484bc9d5f2a1bf87f233ac20599bf", size = 514722, upload-time = "2026-07-20T19:51:56.208Z" },
- { url = "https://files.pythonhosted.org/packages/eb/b7/539961cf3e3d3f179ea4b20d456cbdb67d1163c68f2e95bf220438a5afd5/aiohttp-3.14.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c8ab295ee58332ef8fbd62727df90540836dfcf7a61f545d0f2771223b80bf25", size = 488082, upload-time = "2026-07-20T19:51:58.293Z" },
- { url = "https://files.pythonhosted.org/packages/60/34/c7708eac8edacca9c049a0d6f5fa610005ac3c5634c9ea4751134263bf4e/aiohttp-3.14.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:71501bc03ede681401269c569e6f9306c761c1c7d4296675e8e78dd07147070f", size = 494140, upload-time = "2026-07-20T19:52:00.158Z" },
- { url = "https://files.pythonhosted.org/packages/d8/e1/076b88de36b4ebea62d1c0b225e32bc8a452ee71b2aded82878fa5fa7c9e/aiohttp-3.14.2-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:052478c7d01035d805302db50c2ef626b1c1ba0fe2f6d4a22ae6eaeb43bf2316", size = 502670, upload-time = "2026-07-20T19:52:02.097Z" },
- { url = "https://files.pythonhosted.org/packages/a9/71/7b7720ffe7e521aaa79a853ebff4c17937f4b5a3a586e9d13b0f38050b35/aiohttp-3.14.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b0d49be9d9a210b2c993bf32b1eda03f949f7bcda68fc4f718ae8085ae3fb4b8", size = 756406, upload-time = "2026-07-20T19:52:04.082Z" },
- { url = "https://files.pythonhosted.org/packages/b9/9a/c67c7e22fff7d7b17387e718451b30eb89f55df6b8d13d2d7f91daba6505/aiohttp-3.14.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5fe25c4c44ea5b56fd4512e2065e09384987fc8cc98e41bc8749efe12f653abb", size = 510106, upload-time = "2026-07-20T19:52:06.001Z" },
- { url = "https://files.pythonhosted.org/packages/27/2a/3caebd640229663263585d4d31898331a3937752f2e1f5ec0af509fa31be/aiohttp-3.14.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7e254b0d636957174a03ca210289e867a62bb9502081e1b44a8c2bb1f6266ecd", size = 512876, upload-time = "2026-07-20T19:52:08.036Z" },
- { url = "https://files.pythonhosted.org/packages/27/04/86945210e491493f5cd025efe5e6d8fc8a9e8aaf36f943c40f52c34eda7e/aiohttp-3.14.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6b0ce033d49dd3c6a2566b387e322a9f9029110d67902f0d64571c0fd4b73d8", size = 1750050, upload-time = "2026-07-20T19:52:09.998Z" },
- { url = "https://files.pythonhosted.org/packages/11/d8/356c62552b4db60af5ad8cddeafa6f837788918201f1b5f2466565c986b5/aiohttp-3.14.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41b5b66b1ac2c48b61e420691eb9741d17d9068f2bc23b5ee3e750faa564bc8f", size = 1707177, upload-time = "2026-07-20T19:52:12.39Z" },
- { url = "https://files.pythonhosted.org/packages/77/2d/41bb20bed3df1d6dc7fc231ca4ffb72177fd151bf4205a4ade7a93e03501/aiohttp-3.14.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30a5ed81f752f182961237414a3cd0af209c0f74f06d66f66f9fcb8964f4978d", size = 1803795, upload-time = "2026-07-20T19:52:14.705Z" },
- { url = "https://files.pythonhosted.org/packages/63/21/ad9fe6786a1d2758bcfdae4dbc1d6869ffc7b1e0571530a508de3cdde09b/aiohttp-3.14.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b9251f43d78ff675c0ddfcd53ba61abecc1f74eedc6287bb6657f6c6a033fe7", size = 1876539, upload-time = "2026-07-20T19:52:16.965Z" },
- { url = "https://files.pythonhosted.org/packages/0e/cb/d213f4fd7af279d82c4b46d30de22db93a01296d77a8d8e535289637da54/aiohttp-3.14.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf7930e83a12801b2e253d41cc8bf5553f61c0cfabef182a72ae13472cc81803", size = 1761130, upload-time = "2026-07-20T19:52:19.127Z" },
- { url = "https://files.pythonhosted.org/packages/46/4f/0792fe1a24c2b4b506d07350e980b0626c149fe73e68dd37dfb30ed89813/aiohttp-3.14.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:abb33120daba5e5643a757790ece44d638a5a11eb0598312e6e7ec2f1bd1a5a3", size = 1583576, upload-time = "2026-07-20T19:52:21.465Z" },
- { url = "https://files.pythonhosted.org/packages/46/ad/551c302f534f9cd6105bd16902e69dc64c726aa729127203f47ed4bddb2a/aiohttp-3.14.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:983a68048a48f35ed08aadfcc1ba55de9a121aa91be48a764965c9ec532b94b5", size = 1714139, upload-time = "2026-07-20T19:52:23.636Z" },
- { url = "https://files.pythonhosted.org/packages/e3/cf/a3be8601d2e80ccf7bd4f79807629cede5425d72c02636f06b1ad6a8290a/aiohttp-3.14.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:fef094bfc2f4e991a998af066fc6e3956a409ef799f5cbad2365175357181f2e", size = 1724352, upload-time = "2026-07-20T19:52:26.043Z" },
- { url = "https://files.pythonhosted.org/packages/c8/b1/6b55f6448b43a3338749f37fc6a14e7ac7dad121d2d585f22c1dd3d324fe/aiohttp-3.14.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2f7ca81d936d820ae479971a6b6214b1b867420b5b58e54a1e7157716a943754", size = 1770749, upload-time = "2026-07-20T19:52:28.467Z" },
- { url = "https://files.pythonhosted.org/packages/39/58/b3f1cf6af47fa0b0d51c6e2b98b2bf0326b44a932d74915f3c0874413ea8/aiohttp-3.14.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:da4f142fa078fedbdb3f88d0542ad9315656224e167502ae274cbba818b90c90", size = 1577547, upload-time = "2026-07-20T19:52:30.658Z" },
- { url = "https://files.pythonhosted.org/packages/d4/e2/1d79f1ab35593d70dabed98936ae842ac90d12847c9a62bba4a19d6005a4/aiohttp-3.14.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:3d4238e50a378f5ac69a1e0162715c676bd082dede2e5c4f67ca7fd0014cb09d", size = 1781840, upload-time = "2026-07-20T19:52:33.021Z" },
- { url = "https://files.pythonhosted.org/packages/01/73/770be856c6fc861563999081a5fe2d44e16b4255f5fd94b2217a2349ca65/aiohttp-3.14.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03330676d8caa28bb33fa7104b0d542d9aac93350abcd91bf68e64abd531c320", size = 1745757, upload-time = "2026-07-20T19:52:35.541Z" },
- { url = "https://files.pythonhosted.org/packages/bc/0b/861fdbe3ff90b503a4d0a12276623f4afbe54c237ca876fc560125c270a3/aiohttp-3.14.2-cp314-cp314-win32.whl", hash = "sha256:43387429e4f2ec4047aaf9f935db003d4aa1268ea9021164877fd6b012b6396a", size = 455863, upload-time = "2026-07-20T19:52:37.634Z" },
- { url = "https://files.pythonhosted.org/packages/f7/03/cc8234d0f06fe4b0e1777c7ed313cc9014f078f72a9a0cbdc144965d1bd7/aiohttp-3.14.2-cp314-cp314-win_amd64.whl", hash = "sha256:e3a6302f47518dbf2ffd3cd518f02a1fbf53f85ffeed41a224fa4a6f6a62673b", size = 480986, upload-time = "2026-07-20T19:52:39.665Z" },
- { url = "https://files.pythonhosted.org/packages/2e/b0/ae17dd629e22160f453b9041a4aee80a8a917ba2f44d5b3e252cc501ed2c/aiohttp-3.14.2-cp314-cp314-win_arm64.whl", hash = "sha256:8d1f3802887f0e0dc07387a081dca3ad0b5758e32bdf5fb619b12ac22b8e9b56", size = 453588, upload-time = "2026-07-20T19:52:41.634Z" },
- { url = "https://files.pythonhosted.org/packages/30/54/4bbcc00f04dbc380103ee669a56df30dea127c3686c8dc2ab86b05a1387b/aiohttp-3.14.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9094262ae4f2902c7291c14ba915960db5567276690ef9195cdefe8b7cbb3acb", size = 791337, upload-time = "2026-07-20T19:52:43.821Z" },
- { url = "https://files.pythonhosted.org/packages/e7/37/4f95edc1488580c63c0b5baaef429afbacadc7ccd9c47431cdcce90d9e5a/aiohttp-3.14.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:165b0dcc65960ffc9c99aa4ba1c3c76dbc7a34845c3c23a0bd3fbf33b3d12569", size = 526335, upload-time = "2026-07-20T19:52:45.903Z" },
- { url = "https://files.pythonhosted.org/packages/c3/ed/1d7ae73764d135638797a427cb637ae3d98f1fd61208c25891a9f26cdd67/aiohttp-3.14.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f518d75c03cd3f7f125eca1baadb56f8b94db94602278d2d0d19af6e177650a7", size = 532102, upload-time = "2026-07-20T19:52:47.994Z" },
- { url = "https://files.pythonhosted.org/packages/e3/12/da9816eee0d81506560259e7c0af93a4aa7df98acd0fe276958abd42d7c3/aiohttp-3.14.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b937d7864ca68f1e8a1c3a4eb2bac1de86a992f86d36492da10a135a482fab6", size = 1922727, upload-time = "2026-07-20T19:52:50.224Z" },
- { url = "https://files.pythonhosted.org/packages/2f/b9/d7ccbcde223a72c7b8f3458bdb08c2bacf5fc268a1fdd5cc29861eb0e4e2/aiohttp-3.14.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b155df7f572c73c6c4108b67be302c8639b96ae56fb02787eeae8cad0a1baf26", size = 1787202, upload-time = "2026-07-20T19:52:52.825Z" },
- { url = "https://files.pythonhosted.org/packages/c2/7c/39174069b7df18ea22b23a0fa9abffe4c091f0f86d53af83ddcbb5f44c8f/aiohttp-3.14.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0bfea68a48c8071d49aabdf5cd9a6939dcb246db65730e8dc76295fe02f7c73c", size = 1912574, upload-time = "2026-07-20T19:52:55.007Z" },
- { url = "https://files.pythonhosted.org/packages/21/ff/bd2b7473510caf352aef3f52a3d4fe10184ec22747df74ab658b4402020f/aiohttp-3.14.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8241ee6c7fff3ebb1e6b237bccc1d90b46d07c06cf978e9f2ecad43e29dac67a", size = 2005478, upload-time = "2026-07-20T19:52:57.401Z" },
- { url = "https://files.pythonhosted.org/packages/c8/ee/db26af0acc994350b653b3a73dddf9ee9886bf2fdb68b23fc98705e76442/aiohttp-3.14.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ec64d1c4605d689ed537ba1e572138e2d4ff603a0cb2bbbfe61d4552c73d19e1", size = 1879709, upload-time = "2026-07-20T19:52:59.778Z" },
- { url = "https://files.pythonhosted.org/packages/6a/4d/592451fd40edca73fcb099d8718668cea36dc1dde873dd0f8e91a4081051/aiohttp-3.14.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0fb26fcc5ebf765095fe0c6ab7501574d3108c57fca9a0d462be15a65c9deb8d", size = 1675699, upload-time = "2026-07-20T19:53:02.11Z" },
- { url = "https://files.pythonhosted.org/packages/2a/3e/c1ce4aa13fd1f910e437684843ea6cf6e0041f91d89a1b0389dce247ee59/aiohttp-3.14.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ef710fbb770aefa4def5484eeddb606e70ab3492aa37390def61b35652f6820a", size = 1843542, upload-time = "2026-07-20T19:53:04.385Z" },
- { url = "https://files.pythonhosted.org/packages/aa/a4/3f9e22fda714cc38ef558166ff3326cd2f6f65c33da92ba00e3641cbea54/aiohttp-3.14.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d813f54560b9e5bce170fff7b0adde54d88253928e4add447c36792f27f92125", size = 1827505, upload-time = "2026-07-20T19:53:07.072Z" },
- { url = "https://files.pythonhosted.org/packages/37/9d/260c7b8a25c7cd74bee63ce957556a2d25839a321e9107a089d4da3a51af/aiohttp-3.14.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1aa4f3b44563a88da4407cef8a13438e9e386967720a826a10a633493f69208f", size = 1853751, upload-time = "2026-07-20T19:53:10.012Z" },
- { url = "https://files.pythonhosted.org/packages/f8/d6/7c3cfa191e666bf7b48b4346815900b35b85b43ffff783fd0be52e45fe09/aiohttp-3.14.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4610638d3135afaefadf179bffd1bbf3434d3dc7a5d0a4c4219b99fa976e944d", size = 1668850, upload-time = "2026-07-20T19:53:12.362Z" },
- { url = "https://files.pythonhosted.org/packages/c4/af/d9b8353aee9506dace04ca22c3c4e93b7375d9343c4780c8b512972fc6f9/aiohttp-3.14.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:6e30743bd3ab6ad98e9abbad6ccb39c52bcf6f11f9e3d4b6df97afffe8df53f3", size = 1883642, upload-time = "2026-07-20T19:53:14.664Z" },
- { url = "https://files.pythonhosted.org/packages/ed/29/ed64c0a013322570a02e5b0e359df2cfd30ae58ba045748dc78175f15826/aiohttp-3.14.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:68a6f7cd8d2c70869a2a5fe97a16e86a4e13a6ed6f0d9e6029aef7573e344cd6", size = 1844092, upload-time = "2026-07-20T19:53:16.995Z" },
- { url = "https://files.pythonhosted.org/packages/3c/74/ce6f229510fdd46ee7365fbb759ffaa3004e0bcc59fbd0ec51d8a8efc775/aiohttp-3.14.2-cp314-cp314t-win32.whl", hash = "sha256:205181d896f73436ac60cf6644e545544c759ab1c3ec8c34cc1e044689611361", size = 474098, upload-time = "2026-07-20T19:53:19.645Z" },
- { url = "https://files.pythonhosted.org/packages/72/4c/877c07a6d97a1a0e5e25a06bab76dd7449b533ff8bb6c281c5af8b09c8d9/aiohttp-3.14.2-cp314-cp314t-win_amd64.whl", hash = "sha256:312d414c294a1e26aa12888e8fd37cd2e1131e9c48ddcf2a4c6b590290d52a49", size = 500480, upload-time = "2026-07-20T19:53:22.227Z" },
- { url = "https://files.pythonhosted.org/packages/42/d8/d4c74bb7990da2ee6116fe262ace41cf376ebc6b0bda694c77eeaab5a509/aiohttp-3.14.2-cp314-cp314t-win_arm64.whl", hash = "sha256:63b840c03979732ec92e570f0bd6beb6311e2b5d19cacbfcd8cc7f6dd2693900", size = 469248, upload-time = "2026-07-20T19:53:24.622Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2d/4d/4a99fb425c5e0cad715eea7bd190aff46f38b959a0a2dadb993705d34b26/aiohttp-3.14.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b", size = 765848, upload-time = "2026-07-23T01:52:08.217Z" },
+ { url = "https://files.pythonhosted.org/packages/74/e8/43b85dc55b8e950dc644babe762add781319ea881b57b33d2cce12017d12/aiohttp-3.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a", size = 517476, upload-time = "2026-07-23T01:52:10.846Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/9e/73b582c4dbbc3c12ef4473822475effaabf1f934b56f14f5b03fe5d3a2af/aiohttp-3.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5", size = 515334, upload-time = "2026-07-23T01:52:12.636Z" },
+ { url = "https://files.pythonhosted.org/packages/79/03/e98c3c9e05a5bdf97defe5ff9169baba4f0ec9a901f2d60e0f060c2f051e/aiohttp-3.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f", size = 1708830, upload-time = "2026-07-23T01:52:14.538Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/2c/26e60b694844dfd2176c57f913a22d0cd6a16f9ff202cbda7580d0328b98/aiohttp-3.14.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43", size = 1674012, upload-time = "2026-07-23T01:52:16.486Z" },
+ { url = "https://files.pythonhosted.org/packages/38/65/672df92e3172cd876aacfa97a952ac560877eb169384b2991ac5b273de4c/aiohttp-3.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9", size = 1767015, upload-time = "2026-07-23T01:52:18.28Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/c5/228dec7bfec1c373cc2217cdeb47d6456dcd7a13a4c55144930a75ae3851/aiohttp-3.14.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8", size = 1858700, upload-time = "2026-07-23T01:52:20.08Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/ff/cb36724e8c8d17f90ada567a9ff3efe1d6e9b549fba697a242aece180f21/aiohttp-3.14.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479", size = 1714075, upload-time = "2026-07-23T01:52:22.071Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/3a/296a4135c6366376263aeef54b15caca1f07676c2ae0c525d7832f2f808a/aiohttp-3.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b", size = 1588234, upload-time = "2026-07-23T01:52:23.757Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/81/9d5d853ef892dc066d1eb6db0e87a47348b920c1c879aa554612fdbd9d79/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d", size = 1677300, upload-time = "2026-07-23T01:52:25.861Z" },
+ { url = "https://files.pythonhosted.org/packages/68/96/021d386ae32d9b26d4b88df2e794546232ff56bb6be952bf6be227c0bbc7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d", size = 1691501, upload-time = "2026-07-23T01:52:28Z" },
+ { url = "https://files.pythonhosted.org/packages/29/9f/af66adce26a14af135c003cbd0f44ccaa68cebd30ff8ac99ca47fb4958f7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2", size = 1735113, upload-time = "2026-07-23T01:52:29.995Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/90/28c390d4c9851effe52ac25b5a2e1d92246acd00728b4fc7975dafb67484/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48", size = 1577486, upload-time = "2026-07-23T01:52:31.937Z" },
+ { url = "https://files.pythonhosted.org/packages/db/c2/00e23a1bf2abb70dd353f6987db7e7f2491d0261f7363997738c71c98f95/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f", size = 1751353, upload-time = "2026-07-23T01:52:33.688Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/7d/d51a706a8cbfa57f0611127daf61ab3ae02ab8420b0407412079227d1c65/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32", size = 1698681, upload-time = "2026-07-23T01:52:38.167Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/b0/90bd5cd9fdd9787cb4211d284d1fb8401339a933cb0227a15b71e789232f/aiohttp-3.14.3-cp310-cp310-win32.whl", hash = "sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e", size = 456733, upload-time = "2026-07-23T01:52:41.823Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/15/fe5b8f6a71ae112bc677163d0b0701bda5dc15005249582258ede0eb88c7/aiohttp-3.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c", size = 480460, upload-time = "2026-07-23T01:52:43.905Z" },
+ { url = "https://files.pythonhosted.org/packages/54/00/45e98b6645cd7f00a4b78b749ebd309094b0eaeb2d2e96157eadbc0d0050/aiohttp-3.14.3-cp310-cp310-win_arm64.whl", hash = "sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb", size = 453479, upload-time = "2026-07-23T01:52:46.075Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" },
+ { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" },
+ { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" },
+ { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" },
+ { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" },
+ { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" },
+ { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" },
+ { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" },
+ { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" },
+ { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" },
+ { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" },
+ { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" },
+ { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" },
+ { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" },
+ { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" },
+ { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" },
+ { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" },
+ { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" },
+ { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
+ { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
+ { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" },
+ { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" },
+ { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" },
+ { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" },
+ { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" },
+ { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" },
+ { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" },
+ { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" },
+ { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" },
+ { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" },
+ { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" },
+ { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" },
+ { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" },
+ { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" },
+ { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" },
+ { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" },
+ { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" },
+ { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" },
+ { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" },
+ { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" },
+ { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" },
+ { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" },
+ { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" },
+ { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" },
+ { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
]
[[package]]