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 for GraphKoopman versus STGCN, DCRNN, and Graph WaveNet teaching baselines

-

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": "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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/gL3FTwfevToIT/++KN07969xrzYNoYD7onDhw+3u6/CdRJm0RowVbY0fZ4yZYpV+bFjx6r5GnDhgMue5T1/7dq16n6GIJiWwF0TzwsstwT7YFmnK+CeOm/ePLngggusMj2427Yr++MuOAfI3INtszXXh9uq5TNo//79KtAnXGA8+Qz65JNPas4hgrXjeQIXKZiqOwOm/3hvQVwzrINnCVwsHHH33XerZxCymJ188skqMCpcMeG2gbggrgZGrc3/Xws0D9ccW+DOhf3E78IR7l4X9VnX0fW8Z88e3X23nTZu3Gi3LtwT9FzgEBDZsi24xYC6/B7xiXsKYoxZYnsdu3ONewJXf6N4h8VxwjJsm6WrC459bbhyHbm7TfU55/58r23ZsqX69HaCCYoghNQTBEBFlH68XFi+PMJPDv5y6BBraH5yCFBmi+08d8pq6PnmIs4HXmjg3wi/YDwcNNFizZo1Ne3gU69e3KRsI7H/8MMP6uUZPpe4eWP/cSPX/IkRk6M2XN0ud3G13jlz5qh0e3hZhDiCmzY6Dps2bZL6ookN8C/dsmWLiuVy55131uore8UVV6jtR1TsDz/8UP1dG9hu+F4/9thj6rz8+uuvKlMRg8cR4tvZYSA447eOYNzI/gFw/wDo+Fre/zBBYLW9r+o9M5yBFKh6L6yWL6toA2I9hBdb8OKLuAP12Qbc5yAoo3OO56/l88ndtl3ZH3fBOYCgAeHd9hx8/vnnNecAgwYPP/ywysqCfUFMBXQ0tJgg9UETG/AMQiwYCGD33ntvrVlYIILADx/xJ/Du4sozCNt9ySWXqDhc6IhCjM/Ly1PzcI16AsQoA4hRZktmZqY654gz4Ah3r4v6rOvu9ewKttepJgY5ml+X3yPKIWaGbScZvweII3W5xj2BK79RvIeeccYZan8QS6K4uFhd/1pKZ1feSV25jtzZpvriz/fa/P9+57i3eBOKIITUE4zww5pAT93FCM9LL71k1WHVbra22M5zp6yG5YiepYiBGw2EAbwQ4cGFFxNtG3ft2lXTnl69uJHZvsjgZRoB9zB6061bt5oHJEa1XMXV7XIXV+vFsbrvvvvUNmNChoWtW7cqSxhHAUzdBS8GGFn96KOP1PbgBdMZOJbI9gCrImw7gtS6CwSdc889V6nrGKEjhPgPmmiNYIaW9z/tHmjbibR9ZuAeaPkMs80W4sooIl6M0ZHW63ggBattUG+955YjkO4VQgECFmI0Gs+h+rTt7qioK6ANbFdpaanuOThw4IAqh87jXXfdpcRxZAJbsmSJej7hGeTOs7S2ZxCsAyCqA1gbYhscAcvFkSNHyqxZs5QVK8q7CyxccI7wHHUW5NwdMECDzqkm8lmCjjvOOY63I9y9LrxxPXft2tVhsEnLCZYUtji6Tj35e8Q7IERV2+sD9wxYJdflGvcEruwjrBVwreN9G5ZxdXkndeU6cmebvHXO/eFee/jw4Rrrdm9CEYSQeoARm7///tvOVFED1hJQqPFiCdDBxc35iy++sCqHF1a4v1jiTllnwPwNDzGMNjkDLz4oh6wolkDV1wPqr+VNDQ9BPIgsQRRzYPsAdWe79PB0vbBogdoNUz+8+GnWIM7acQeo4rfffrsaoYAg4gxsB84x9kMTwvRYtmyZsjLRAxkA8EIA01RCiP+A+wY6IrAmayxGjRqltgGZAGxHFSGqY3ldQAcNJtWoG6brepaL3mrb3WcQrCNtt8EZEB8gkiNjGTphmjWIp55B6JDcc889yk3SNhW7o2cQ3mucpbmFu4wjy0k8g/B+oJm11xd03mBRqefmCdEfHW9n+1Wf66Khrilv4er2IyMHrjO4OlsCSzNPXOPeRrNI0dATcZ39nly5jpoavnqv/e2339T9A+5e3oQiCCH1tAKBOjpixAiHwgJGL7S0g+jUzp07V1555RWVGg8vGhj5ueqqq+yUYHfKOgOuEri5w6wV6e4wkgI3Hby8INWV5uOHFzCMGF155ZXKlxdlcPODeautKSTibWB7YF4Ls1f4PcKP0jYeCUzZoCKvW7dOlavLdunhiXqxvxh5g9qNByJG4uDOA+EA1hS1teMuGFmLj49XL6LOfL/hw4oHcW1p9TDSgjgg8MnevXu32gdYf8ANC/EBrr32Wo+9gBJCmgfoyKATC+tExIBAZxTCLmI1PPjgg8rywNvgHox7KNw9vvnmG3X/xYsx7skYpUW8rLpw8cUXK+uCVatW1cQ+aKi2bcFzH2nhv//+eztzboyeTpgwQa655hr1/MaoJtJQYsAE9/9HHnmk5l6PeExwUYHwgTScKI+60eGvrR13gcsuzOHh3+/MPQBxYPAMcjQAooFrC3FAIPBrz1GMvOPv1atXq+vQmYWFu6DjjWNomzYTxxvLcM2//PLLyqIVI9HoAOP5WN/rwlPXVH3iQ9QHV7cf5fAOh3cHxPRAua+++koNxMHtpS7XuLNtQidXz7KnLuCdFC4weMfD7wTvQriObe8Ttf1ua7uO3MXb59xX77Xr1q1TsQ/10mF7FK+GXSXEhyksLDS3aNFCpaR1xplnnmmOiYkxl5aWqr8RHfmxxx5TkfqR1ePEE080//zzzyqDCCL1W+JOWUSXRnR2PZAu7amnnjIPHDjQHB4ertKdnXDCCSp6sxY1GyD9GrKmIEUYJrSTk5Ojorpr6Rc1XnzxRRVpGpHhkWUFkaIROR63lb/++qum3Ndff23u27evih6PZTfccIPb26VHfevdt2+finSNzArYBxxPpIKz3Pba2nE3FS22CctfffVVu+wwztDLDoOI3qgHqX+RVQaplJH9ZtiwYercWEbhri07DK4tQkjzTJFrC377L7/8snnIkCEqVS4mpNO8//77Vep0y4wADz74oEttO8uUhQwIF154odU8pEtEhgjt3oT05MiWprVfW516GQuQEc3RPcwym1h929bbHz2QgeO4446reTZY3seR7QvZ0vDcwTMIz1OkNcX+4JkKkCoS2cDw/MQzCNuJdLOWKWpra8fdVLRIDY86kA1OLzuMI/Syw+AdCM99pKTHOwqONZ61Q4cONT/33HPqGNhul7PnUG0gYx6Ok16GGzzj8K7Uu3dv9Txr166dyoSE7GzuXBeOMmXU95qqK47qRGYNzH/yySet5jvKfuLK9gP8jfc+nEe83+KdCBkJbVPkunqNOzqeuGZwX8I15O6+O/qNIqtLjx491DWCT9wDtXcnZNFy5fdU23XkiftGbfj7vfbAgQMqa8yKFSvM3saA/7wrsxBCXEFzR0FQMU+WrS8Y7UGQvSeeeEJmz57t9fYIIYQQQmzBKD0CnMOyyTZgO2kewBIWFkKwjp0/f35jbw5pYiCJACyht23bpht01ZPwDkJIEwABLOGC4sitpq5lPYEWUK2h2iOEEEIIsQWuPHgH0rJ+kOYHBu8QuwMZ+gixjUUCN/WFCxd6XQABtAQhpIFBvAb4z8FfsUOHDkrtvOmmm1Rch3/++UfNq0tZT/DAAw9I9+7dlaUJHlKICYL28LdecCxCCCGEEEIIaU7QEoSQBgYBzxB885JLLlEpuZBBBsHKkDnEVtRwp6wnQDo8BENDTnC0A1NFpPnlqAshhBBCCCHEF6AlCCGEEEIIIYQQQvwCWoIQQgghhBBCCCHEL6AIQgghhBBCCCGEEL8gUPwMk8mkIku3aNFCDAZDY28OIcQHKCsrEy3bOALKBgUFuXw/sk3zh3nl5eV2ZVFOi5Zt2Z5GaGhoPfaAEM+Ba7OgoEASExOZxtIGvoMQQgghjf8O4nciCAQQbwSUJIQQQsj/c/DgQWnfvj0PCd9BCCGEkCb1DuJ3IggsQLQD07Jly8beHEKIDzF9+nRlkfHCCy84LFNZWSlDhgxRmX8effRRle744osvlrvuukuuuuoqu/Kw+ujZs6dKX4zsPQDq9ieffCInnniiV/eHkLqQn5+vBhu05y35f/gOQgghhDT+O4jfiSCaCwwEEIoghBBPAjcYTM7uLevWrZP9+/fLhg0bpHXr1ioV8XXXXScrVqyQ2bNn25XH/KqqKiWwhIeH18yPiIhQ6ZOdmfoR0pjQ5dTxMeE7CCGEENJ47yB8eyaEkAZk8+bNkpycrAQQjYEDB8qWLVvs4nyApUuXKgsQSwEE1ibnnnuu+uzRo4csW7aswbafEEIIIYSQ5ozfWYIQQkhjgmBNtpYirVq1koqKCuX6YhngdNu2bfLzzz/Liy++aFU+OztbfaL8Z599pkQSWIVcdtllDbQXhBBCCCGENE8oghBCSAMCAeTYsWNW83Jzc1XmF9sML4gtMmzYMOnTp49uXSEhIXLRRRfJmjVr5L333qMIQgghhBDig8BaGHHl4CJNRLmfIyNjXaEIQgghXgYWG4GBgepmffzxx0tqaqpkZmZKXFycWv7bb7/JgAEDrNYpLi5W8UCee+65WuuHFQljgxBCCCGE+B7l5eVy+PBh9W5I/j/mB7K/wBK6LlAEIYQQDzycTCaTmqDQl5aWKsEDKrXm7oLYHldccUWNZcdNN90kixcvlp07dyp3l6eeesqqzrfeeksJJ7D0sOTDDz9UbjITJ06U6Oho5Q4DsWTlypU8j4QQQgghPgTeLRFQH++VyA4Iy2F/DzxuNpslKytL0tLSpFu3bnWyCKEIQggh9QRBSn/44Yeav99//30588wz5dNPP1V/w81Fu0HDYgPCxfXXXy99+/ZVQsY999wj06ZNs6rz9ddfVylz4fJi2xbS6p5//vkqNkjXrl1VWVuxhBBCCCGE+MZAG9K+WgbJ93fatGkjKSkpyhq6LiKIwayXjsDHcwdjVDYvL48pcgkhhBA/f87+/vvvKi4PhEt32LVrl+zbt08GDRqkXsZ88dgQQghpXGBdDEuQTp062cWO82dKHRwXV5+zTJFLCCGEEL/j1VdfVdZY5513nowdO9atdY8cOSKnnXaajBkzRn799VevbSMhhBDiKnfffXeNFbI3eOCBB1Qgfl+AIgghhBBC/A74E7/99tuyYMECt9aDWfKUKVNkxowZXts2QgghxF2++eYb2b59u9cO3Lp162Tz5s3iCzAmCCHEpxg0dJCkH0lv7M3wCxITEuXPX/5s7M0gpE7cdttt6vPPP927hh999FEVmO7GG2+U+fPn8+gTQvyasReNlbSsNDGZTXIs45hUlldKdEK0BAVXB4d3h4ryCsk9kiuBwYESFR8lRoPj8fr2bdrLlx98Wc+tJ+4wb948l90/mzoUQQghPgUEkNj7Yht7M/yC9PkUm4h/8csvv6i01Zs2bXI5PTYmS19loGWTIoSQ5k56drqYJpkk9clUKT1UKslzkiW4c7Db9RTvK5aUxSkS2i5UkmYnSUCY82CX6a+m+8V9FPuIEJ7aVBso88EHH8j333+vAobCcvGEE06wKvPjjz8qt5bCwkIZOHCgCsRvGXQVbjWDBw+WkpISWbNmjfTq1UvmzJmj1uvZs6dyJf3rr7/k8ccft2sfbaGsq+2cdNJJKrips+11tJ+YbJ+nrl4TFEEIIYQQQmrh2LFjctlll6l01/Hx8So7U23A1UbPWgSuOAjqRgghzZ2uyV0l46sMiWgTIXHXxklIvHVWO1coyyiTzLWZkjAqQdqMbyPG4NojNhg6GSQzM1N8HQgE6NhXVlaqyRkQBZ588kkZOnSoilkFwR7f//jjD+ndu7cq8+abb8rMmTNl1qxZ0r17d1m2bJmsWLFCZTnUsqzAreall15SQsj48eNVGlq0DXcYPLsmTJggrVu3VhkLNYqLi+WWW26RyMhIVdbVdpYvX+50ex2BNnBcjh49KkFB/291VFBQ4NJxpQhCCCGEEFILd955p7Rt21a9bH399dc1Vh14aYuLi1Mvi3rr4KVQA+sgzSHMiZkdhhDiC/z2629y+OBhZQFS2rpUxHk/3bkFyJQkyTfmu1SHYb9B3Xt9HYgO6NgHBgaqyRkGg0GJB6tWraqZB4uN999/Xx588EFVF55LDz30kNx6661q+aRJk1SGlbfeekumT59eU09ycrJ8/vnndvUbjUa1HR07dpTJkyfXiC+XXnqptGvXThYtWqQEClfbcba9zsA2YFsgxlhmh3E1gw5FEEIIIYSQWoAAgnR8Tz31VM3oHPjkk0+Uqa+eCBISEqImW/DihokQQpo75aXl0nFORwntHComMbkvgCysFkA6zO4ghjCDy3UYzNUdcl8H+wixQJtqA5nLLMt17dpVDh8+rObt3r1bWc9cfPHFNWUgyp9++uny888/y5VXXlmz3ujRo3Xb09sOCBarV69W2dKioqJU8FRX23G2vc7QtsP2eerqNUERhBBCCCFEh/Xr1yvXF/hAIyCcJXCHwUsd3F0sTYIJIcSfQBDUwM7udyktBZCkObXHACGuYWsJAVGgqqpKfdfcOGNiYqzK4G9bF89WrVq51B4GAiCCIDVvjx493G7H2fZ6E9+XzwghhBBCbMBIFdxatmzZokx58R0TYn9oTJs2TcUAIYQQok9dssB4QgApyiviKXGTpKQk9blv3z6r+Xv37q1Z5g7btm2TqVOnyiOPPKJienirHW9AEYQQQgghfgcCssG1BS9xZ5xxhvqO6dChQzVlRowYoaLi64E0uWeddZbXfNLhbpOe3nAZmBq6veYOgtuWl5c39mYoH38EBiSkueAJASTz00wpPFbole3zZTp37iwnnniiyuqiZVFBBhe4qCCmhzvk5OTI+eefryYt7oc32vEWdIchhBBCiN+BlzbbFzdbXn/9dYfLENgUliPeYufOnTJx4kTZunWrx0QO+GgjcJ0320M7COJnawbtChAVEF9FWxd/OxJmcPwt20AqRwSehfuSI5C9AFNs7P+nUYfZ9cGDB1VwvRYtWqh5eGnHPEcjlmgHAtiGDRtq5iEVcm5uriQkJDjdx9TUVKs0l9jesLAwJWYcOXLELvBf+/btnR5bHCOIeNgWvfgzhPiiAJL5UaZ07t/ZK9voyyCGBrKxjB07Vvr166eeBxAm7rrrLjnllFPcqgvPR1h2IF0unh0aED+QItdT7XgLiiCEEEIIIT4OXlaR6vDff//1Sv0QExAf5bnnnlOdfMRLefvtt2XgwIG1rgvR4brrrpM33nhDpVeEMIB60NkfN26cKlNUVKQEEk3kgKsS2ktJSVHrrl27VgXkgyhw9dVXq2URERGqLMpce+218t133ynxBD7oMN++/PLLlUUHMhacc845NZkQMMLZpUsXh+koX3jhBbnoootqRAekfbz++utVBwMZE7788kuVBUgP1GspbKAjAYujP//8sybTgia0YLsw39mxxf6MGjVKXnvtNbWPhPiDABI3IU4isqt/3+T/wX3N9t5z4403WgUZPe6442TPnj1KlICoitS1yARTWz0AsbFw/wEQOBAw3Bbt/lbXdmy311tQBCGEEEIIacLk5eUpcSAgIMBOPIArBCwbLF8a0VFG5x5WD+Hh4cplJyMjQ1kSQBCwtTDQaw/rIR2wq8AKAiIE3IkgDtx9992q446geSAtLU1tp176QqRHhIiBbYPIAaEGQfZuu+02NU8TGtDRX7NmjZX1BwQA+KIjpSKOEV62X3rpJZUFYcCAAcrCAmXwwv7RRx8pYQQWF08//XRNPbDEQJs//fSTDBs2rNZ9hXChpXREezfccIN89dVXMnToULn55pvlnnvucWpFhI6BbapLjI5q+wqQVUHbltqOLQSZ2bNnUwQhfiOAxJ0XJ/KKVza1WXPmmWfazRsyZIjdvNDQUHVfdKcegOwuGgiCqgVCdURd2tHbXp+MCYIUOFC28XBzlR07digV/tVXX7UzHSSEEEII8QXQyYeFAlIGYvTN0v3miSeeUOJG//791UjaunXraiw+unXrpkyQkXrwzjvvVPMhKBw4cEDNszRdtgSCAaxFunfvrtqzFBzQQdebIG4ACBy33HKLcrmBSw1EiOOPP75mfbT5999/67YLIQGuJ5qVB7LxYHtrA0IExIRnnnlGCSAALi3YDgggAMIHxCOIHpplCFxWFixYYJWNANkNtGPlDGQ2gBiBYwRw3NERgIgBIWrWrFk1AokjEHwXVi3O2kDMmilTprh0bGERgmML0YsQvxBACGmuliAw7cMDcePGjerBhQeTdrN3BgQT+PCed955Shn/3//+p8wXTz311AbZbkIIIYSQhgCR9Z999lll6QAB5KqrrlIxJfDu9N5778kPP/ygLDY2bdokM2bMUBYG77zzjkyYMEEFpLMElgm1ucNA1MAA0/Dhw2XlypXKVHn06NFqGcQTPWBRAgsKDWwrhBGYPS9ZsqRmvmUZWxAo78MPP5TBgwcrMQHt4z3P1lrClu3bt8sJJ5ygRAwAYcEyNSNcRRD4Fj7qWhlH4J0UxwzvlM5GIrFvEGs0yxsIIpaxQ+AOA1EHkxZjxBIcF2wPLHMGDRqk3FpsrXJwrnAcEafEEkfHFhY7SGeJgUW42xDSVKAAQpoqjWYJAlPNSy65RD3gEVzKFXDjhxKOFwI85KGSw1wQLwWWQaYIIYQQQpo76FBraQfPPvtsZVq8f/9+ZX2AQKZw8YA4gXcjDC7BPQaDQuhEQ1hAel90xl0FHWgIEACuHZqVB/j+++91J7yPWQLRAYNUyBhw2WWXudQu3FE+++wzNUF0QZ0wodayCjgCnX9Yy2h88cUX6nggUB/8z7UsPpZlHAFRA6IPXE2cvVNCmLGMFYL64WakoS3DfD0gVOEc4hjBmgQBBG15+eWX1butLc6OLbbBHfclQrwNBRDSlGk0EQQPEQgY7tyw4fuIh4pl4KiZM2eqB8pff/3lpS0lhBBCCGl44N6gdcjxieCgEEIQFwLWHpZuKehYw9oBIgY6y7CkgKWIZinrSqA5y3cylLcUISAu6E2aa41lWWyHO0FYIeAAWFjA/QfWEb/88ot6v3PGySefrCxMEEQUQPjBsUCMDEdlbNu0BIJTdHS0vPXWWw7bRLBSxFvRhA/8DYsUDXxPTEysNVMLlmMwEK4tliCIIM67ZoHjyrGF0IWAsGiXkKYABRDS1GlWgVERCwQmh5YPFviNasv0IpAjZRkmDe0hiAdKbSMMhJDmB14Q8Y80zLHmfZTYwmvCcyBLCSwTMOqPjjnSDMJ14sILL1RuIHAfGTlypBogwrsRIvVjHbiFwJIDgomWMQSuFYij9s8//6jvzgKj6mEZtFMPuJHATQcDXHjvuv/++1XqVg1ngVFfeeUVlQVl0qRJKhbJu+++q6xDHKXz1UD9iI0BKxkEI4UlC+JtQDzRYmZoZSBwILMB9nvLli3K2mL16tV2dT766KPK0sIRcN/G+ya2F24zEIKwv7AiQRu33367XHHFFVbHDW1i8A/iBuJ6AMRnue+++6wCDQKklZw+fbqV+05txxbCCa6D2tyHCGkIKICQ5kCzultC6UZ0bEvgb4mAV47MPRH4ChG0bYGKjwBghBDfok+vPtIysGVjb4ZfEN8rvuaFnhANd9wviGMgbMAaAO89SOWKjvQHH3ygLDQQKPXbb7+Vhx9+WAVIRccYHX0E5EQAULjDoEOMgSOsAyBAIHMJBBVYO9jG6EB7lqJDbRlkbEEqW3T4EZsEYNsh4GjAYmTRokW68Tbg+oH9QucesTIQaBT7pwUyBYgfh4CmlmAdZJHBMUBgU7iKwDLjyiuvrBEitDJoGyIIBCIcK7hWA7xDWsb0wPZBVIGw4AgISwjoj7JYH5bKiFeHFL8QNSDIaEAkWb9+vWoDn0jni21CUFu4M91xxx01ZXEef//9d3Uc3Dm2iN+iLSOkMaEAQpoLBnMTCKaBhzIexn/88YfTcnhw4KG0efPmmnl4mEEIwYNn6tSpLlmCIIo6HpQImEUI8S06dO8gsffGNvZm+AXZD2bLwV0HG3szSBMDz1l0spFmlc9Z+2ODAJY8Ns3f2gnCBNx2GvsahxAN13BkwSGkMThuxHFivtLc4AKI4RWDbF7//31CXwWD9nB3hMCrZ0nnr5Q6OC6uPmeblSUIRgbwwEHQKc3kD6ngANLB6QHzUD2/TGUyX0ukcEJI80O5ugld3RrqWPM+SmzhNUH84RpHENamACxKKICQxoYWIA0PXOq0bFieJDY2VgXlro1hw4Y5zfrV1GnSIggsOJACDD6Wffr0Uar73Llzldkh/GHBa6+9psw3kW6MEEIIIYQQQkjDUFFe0aAWIKRaAOnVq5eKM+RpwsPDVazN2oQQZy6DrpCeni6PPfaYCvKMjF633XabCo7tFyIIBAwoWMhvDx9Q+GuC//3vfzVpz+BjCUUKIggCXsEHEgGjoDwh+BfSqMHfFT6ZhBBCCCGEEEIahtwjuRRAGhj0nyGAIDYRxBBPsWPHDpkyZYqq3xVrkPqAdhDs+dxzz5VXX31VZctC3CS/EEGOHj2qIpUjujkmfAdamBL49yB/OtQhDQQ5RdCpdevWqQjiiHKuZYghhBBCCCGEENIwBAYHStJsWoA0BhBA9LKjNhQ//vij8tpAsGdku+rdu7fL68KlEFnAtL4/YoQ2JI0qgkDgcAZEEM06xJIRI0aoiRBCCCGEEEJI4xAVHyWGMEODu8CYzIz/1tg8/fTTcvXVVysPDVh2wLsDIKPZvn37dNdBanKIH5iQaQvrwDBCy2TWUDTpmCCEEEIIIYQQQpomRoNRzGJuUAGkqqRK8jPy3V6PeJYXX3xRWrduLWeddZYsXrxYqqqqVIiK2bNnS1FRke46lglLbrjhBjl8+LC89NJLKjU40pk3FBRBCCGEEEIIIYR4HU8IIKmLU6VFZQuvbB9xHQggAO4wED8qKirU55NPPlmrJQg47rjj1HTqqadKRESEsgjR6vQ2FEEIIYQQEXnqqafkzTffVJnJkJXsgQceqHlQ24I89A8++KCKT4VUwePHj1eBu7X07e7UN2nSJNmwYYOsXbtWBQAnhBBCfBFPCSClh0qlY3/vBu4kdac2S5C0tDTZvn27nHnmmTWxRSIjIyUqKkoaCooghBBC/B4IFg8//LCKUB4TEyPXX3+9MtFE5HU9LrroIhWZ/YUXXpDS0lJVHhnL4B/rTn3Lly+XvXv3SmpqqhpBIYQQQnwRTwogybcmS9D3QeLvIJtLU6zv+OOPd7o8Ojpali5dKtddd50aHEKWWGSNbchsrxRBCCGE+D3wZb3nnntUqjaAaOennXaaCs6dkJBgdXwwggFzzr/++ksGDBig5j3++OMyYcIEZR3SsmVLl+o7cOCAEkqQ6n3IkCF+fw6INRDNIKLdddddPtleU4XHQdS9DcIsLNyaQsyB8847T9q2bdvYm0KakAAS3jlc5Hv/PSWxsbESHh6ugpF6mvDwcFV/bcB6wxK8F1nG+3AGXF8++ugj9R4Ey9quXbuqhCgNCUUQQgghfg3Ss0PYGDZsWM28U045RX0iavnYsWOtymsmnhA7NFq1aqXcXv7++2/p3r17rfUhHdz06dOVi0x8fLzX95E0P+Ab/dZbb3lMlMBIG4LPQZzzVnswgdYsmp544gkJDg6uWYaX3bfffltKSkpk3LhxMmjQILfq3rp1qwqal5ubK926dVPWWLCy8jTOjkNBQYHceeed8uyzz9bMgzVYUFCQypDgKyBVJUZpLTs7n332mbRp00ZmzJih7nd6PProo+reZ8mVV16pUnh+/vnn8vXXX6t5uAda3ledLYuLi1OpN5ctW+bx/STNWADxczp27KisNrKzsz1ed2xsrKq/NizfccDJJ5/sdluutOMtjI3WMiGEENIEQKcKWHaojEaj8k2Fi4stiNuBB7cWCR3iB4KAaXW5Uh86URj1mDp1qtf3jxDt2nTk3uUpevTooUb0nnvuOSkvL6+Z//vvv6u4ONgG/F5gFYWOr6vgt4YX7IMHD6qOOMREWE/t2rVLGhIIoNg3jblz56q4PxBkfMkKBEJWr1691N/ffvutEq1gso40mKNGjVIirh6dOnWSnj17qql9+/ZKSIGIAXDeMH/Lli3y22+/Wa3nbBna/uabb3TvxaTpQwHEe+A9BAKjp6eOjShMNCS0BCGEEOLXwPQTFBYWWs3H3zDZtAXBT5HP/oorrlB+rRA4MDqK6OiwDqmtvj179siCBQvsXvZJw4N4LhjhRucc59AVYO0DqwSMlmEkDMHcvMmff/4pn3zyiepUYlRdC76L6+n1119XFhaIrj9x4sQaf+pVq1apYLuIW9O3b1+ZOXOmPP/888oa5MYbb1TR9+fPn6/b3j///KPMlNF5veaaa6ysOWoD7eCYwiLEknbt2ikhRPttoEP9/vvv17iLYaQfZt2w8LAFo52wysD62E8NnLPKykorFxYILR9//LGcf/75SpC5/fbb1e8SgiTaOvHEE63KI62j3rGt7TggGPK1114rKSkpygRcu0+g7V9//VXVB0svbX+dLcN5Oemkk2Tjxo2SmZmpzqMmQGDZCSecIH/88YeysMA2wgoDVhHYd8QagoAAIHChDmxn7969lcCqmaajnqFDh6pr4tChQ3LppZdKv379dM8htlMLVqgJULhfwXcf4ge2DQGhIYbYgnot4x2hHuwvwD5i+vfff+3Wc7YM5wQCGCxFLrvsMt1tJk0TCiCkKUNLEEIIIX5Nhw4dlHiBUUiNnTt3qpFsdCb0QGdq27Ztym8enZPTTz9ddTgwilJbfd9//73k5eWpDnRycnKNSSk6FehwkIbhoYceUlY9cElCZ7I2kO4P1gfowKJDNm/ePLU+Rse9BSwfbrvtNtVhRtA4zeUCFgnDhw9XQXXhTgVrBAgQ4N1335V77723ZnQd1yPAJ65RzHOUhQjXMtw90KlfuXKl3HHHHTXLIJ7oTRAwaiMxMdFKENi9e3dNRx9geyyXW/LFF1/IiBEjrAQQAAFSEwDgwoK4PNheiAQtWrRQghD2FWIIvl988cU1wiPKP/bYY7rHtrbjoGV0ghk6rFk0AQRuRhBr8Nv/6quvVMpHWIrVtgwuPpdccokSJ+Bug/sBxBVtGTr+cNlDe7jPQCSBGIHMCohDpIGYGdr5RhvTpk2rWYZ6sM3YL9yHcDw1izVbIP6gHsu/sb0AghKuO8yrDYggV111lXiCPn36KAsV0nygAEKaOrQEIYQQ4tfAkmPy5MmycOFCNZKMkf37779fBg8erDpQACPbCN718ssvq7/xCdECIgZM8m+55RaZNWtWja+8s/owKj569Oia9tExQcfijTfecDtOAqk7MN1HZw4dWVfiOaDT+swzz6jzqIGOJiaIEd4Ao/2w6kDn+YYbblDXDlyvYEWBZejQQpzBdQhLAMT8gGUIrD/QWdYEEM2tANcthAtnvwW0B7EEVhKWHWnLjrEllrFxXAGCA0b8EfBS4/LLL3dYHoIFRBQNWDNAmAAXXHBBjUUCrDOw7ZZiCkQriCgQEHDsvvzyy5rz5+jY1nYcwA8//CCPPPJIjaUF2sa6sBDCdYW/cQ4geI4cOdLhMm3bYYUEqxVNaICAAJFOc7nRBC6YqeNeBKsWbD/EHlwDsP7AfkFc2r9/vwq+jPuJJTfddFON2AdBARZGlvchDQi0ltZNEEssY4DgeNXmmoJ9xXWJgKaeAPsJQZA0DyiAkOYALUEIIYT4PRhFTkpKUp0HxO5AZwJBHDWysrKUK4EGTMnROYJLBIQLmN+jU+RKfehgoNOqTZq5ODphKEsaBghVrkTA14CrhqUAAnANoLOHOBfeAKP7msiAjiD+hisHLJC0kX9M6FQjKCUsBOCmASsJuBcgPgeCfLoKxAatY4+Or6VLF4QLvckdAQgiDGJqIM6EI8sPvW3CMdbAdmGf4SJiaR0Awceyzp9//lkJPxAJcO7wWzx27Fitx7a24wDgDgLrDljgANQLkQwihyaiwNIF1h3OlmloYivAvqWnp9f8jXuEBrZX+xtuIthGuDyBs88+W7nmYD9QP+Zr7kKgc+fOVsfQdp808JuwPE6wtrEMvghRSovz4QiIOHDHQcBYTwAhxp3fKmk8KICQ5gItQQghhPg9ECbgC49RUHSaNDN7jaeeekqN4GqgMwwXF/jwo8Np+7JfW32WQATB6C1EENK8gBUJXJwcpQWEOGIpkMCaCOBasrye9MByjH7DUgidcnSM0UnH9YJOM4JFwoLAMo4FRBBcexAaMMH9A6P9sApBOXSKHbWrzXf0adlRtwQCgmWdlutZzkdcCsQwgSsROuG17b8GrAngtgKLDlhWacIPYoRgf7V2YEFhWSesNeBKgrTV2t+W5R0dW80VRe84WB4LCDmIeYE6Ec8EbjEQZuASh3OO77AQg7DpaBnqw/qwbtGsJiDeQLTRltkeR72/USeOB4QNnGfEHtHK6dWjV68GRF248mnLIKYhbg7ckZDZZ82aNcoyxdH5w7bAKgmWLnpl0LbWvqvLEIcHMZhcvWZIw2I0GMUsZsn8LFOyP86WhAsTJG5c3bLAHHzyoJQfLpfOt3auNQuM7W/eV9F+w9pE7O8Xes+g2qAIQgghhPyHo9SPjkYhaxsRdVSf1YM4MNBqtJc0D2AFgPgb6Jw7AgEl9QKQwrIIAUSdAZcDCAxwfUCnG51jBOjECD/cKBDYE7ES0EmFOwRcJdA5RYcVnWotbgSWQ6xDGYzwI/4ErJRuvfVWu/YgkqCsNuIPCwbtb8TUcIRWZsmSJTUpUuFigm2CG8b69euVuxjcV7R2kUoaHVsA17ELL7zQylpBAwIT6oWQ079/f7XtEJNgXYX60LbttmvWWhBBsD1wEdIES628o2Pr7DhoFhH4juCysLJBMFAtdS6sMXBu4GaFc4NYJyjrbBlEUohpEB4gIEBcheijLcM507YF24Xt0/7GOca1hHIQWLQ4Qwgmi/sKyuHTth60A4HW8nhpIHMPYnlowW1xXHDuIdwgqDNEP7SBdRF7BHXDEk4D4i+sXnDPtKwfQsZ7772ngrfinEKEQqBXWCs5WwbhBW1DiNbbXtL4dOvUTY5tOiYhB0Kk7419pdXg2p97tpjKTZL1VZZEtImQuGvjJCReX1i2xNDJ4BfXBLI1oWOP37+ldVdjc/vtt6vYSo2FJurjHm05EIX7sStQBCGEEEIIcQNkFEEcEVg3oHPrCHR+MeKvgc473DZgGVRbLA10Xp9++mnVKf3uu+9Udg4tQCVYu3at/Pjjj8olBR1RBEiFKAcrAggsCAaK1K1nnHFGjbUILENQF5bZCngog4Cq2nxkcIGAU5vQZwnccuB2gowmAB1hrA/xAvtiCQQSrW4IFjgujtpChxiCB9xQ4BqBTjaynWAb9bYdjB07Vh0fdKDRcYerDDr+KIOOE7YTApbtsXV2HGBlg7gw2jJ8Yn2tDrhHwSID1waCmGKkGuAacLQM4hTc5/ASj+3C9aSl17755pvVsdPaw3bhGGvLNbc71AELDQRAxXfU/8477yjXGLRjWw9ig0D80TvemIdlEJlOOeUUZe2yefNmVT+u23POOacmCxFECnTQLOuBuAPRyrZuCCfHH3+8mmyvAWfLXnnlFRUzBvtJmiZ///O37Ptnn8RdECdlA8skszLTbQuQ1CdTpfRQqSTPSZbS1qUiLvT1DfsNbt2fmiu4n6Njj3uTpeUfxF1LV7X6cOjgQamqqPj/oNNxcUpkDQoIqLlX2YJYRzMdBD/GuhVVVWpd1OGIg4cOya9//KHEXU00dxUcC7gXQpAODQ2tmW/53RkGs5/Z1eAFBCNzeBC6G8yLENL0adu5rcTeR9/hhiB7frYc3lftQ+9phg8cJJkWfvHEO8QlJsqPm/706+eslhnE1RE2LZvHE0884VJWmeZ8bHwVBO6EZQk+GxuIHhApnIlpDQ1izmCyFN0aU3BEcFn+Xpou8cnxIoNE4s6rmwtM6uL/BJBbk2t1gbHE8IpBNq/fLP4ggsBlFuKv1sGHAKLF/vE0YUhh/t57cnLHjhJmdBw+NLxfPym2yIKnUWIySUp5uYQajJIUHCRGByLKug0b5JKbb1YiJ8RTuCa6kwZb77i485ylJQghhJAmBwSQjxkk1OuMp9DkFGQh0awOANwW6iqAkKYDXrbvvvtuaQrAbchRKu7GAlYXTcXywtLVhjRNIqMiJfw818ULTwgg/g4sQCCAIP6OZbrx+rJjxw4V4yi8qMipAKKRnpkpH3z1lbL4uOLCCyUwLMwlAQQ2GLc+9pjMu/tuuf2uu6QxoAhCCCGEEL8DrhVIb6zFz1i6dKmaP378eOUqAdBRxt8QQZBSFO4lcPWACa5WXss0gxgTpHkAd5JJkyZJUwAZbAhpzkS0ilCBURtaAKkor3bf8GcggCAekKcJcSBe2HL1XXfJ0IED5Y8tW2T1hg3y2JNPKgHkk7ffkgwHrjrz//c/ycrJkT2pqdK7Vy+577771DMXGaUs03N7G4oghBBCCPE7YO6PgIww/UXwR3wHiKFhKW4MGDBAfYdQogXy1MpqOAsaSgghxLMCSPG+Yik6UsTD2sgsmTdPunTsKLklJdJ5+HAJEYOyAGntxJIX8sqR7GwJDQmRBx5+WM4dN04F9H7hhRdUDCfENWoIKIIQQgghxO+YPn26mpzx8MMP13yHBYgW8JMQQkjjCSApC1OkbYe2PAWNTJeOHVUMkCMGg7KQTBCzcoHJyctzaAkCm6HYqCgpKCqSh+bPl7POOUdleYHrKYJHIyBzQ0ARhBBCCCGEEEJIsxBAQtuFSlS8Y2sD0jCUWARBRQQRLQZITKtWynpSD5Ron5AgiXFxUvZfqngEJkemKctUt96GIgghhBBCCCGEkGYhgCTNSRLj27UH7vR1EMgUAbzff/995ZYJF069Mvfff7+0b99eHnrooZrU4holJSVyzz33SFpamkybNk3cwTIIqiVTx4+vdd27rrtOrr3hBvl67VplAYL03w1pbUkRhBBCCCGEEEJIsxBAAsIC/PpMxcbGSnh4uMrkooGsZZgcsWfPHhk2bJjTeu+9914JDwuT2OjoWi1A/jdrllUWGAQ8DQp0XVoYN368xHTrJkdzc2XMmDFyzjnnKJeahoIiCCGEEEIIIYQQj0IBxDt07NhRWXggVa4nSN27V+L/c1+BANKhbdtaXWBmXD7NKg3u9TpWKI7IqqyUzMpKGXDccdKrXz9pDCiCEEIIIYQQQgjxGBRAvC+EYPIEkSEh0sFBDA9HMUAsBRB30ASQuMBAKW1Ayw9b6ExFCCGEEEIIIcQjUADxPUo8LIC0ccN1xhtQBCGEEEIIIYQQUm8ogPgeJT4mgACKIIQQQgghhBBC6gUFEN+jxAcFEEARhBBCCCGEEEJInaEA4nuU+KgAAiiCEEIIIYQQQgipExRAfI8SHxZA3BJBsrKyJD093WkZ5B/etm2bJ7aLEEIIIYQQQkgTxmQ2SeriVCk9VCrJtyZLeOdwt+so3lcsKQtTJLRdqCTNSZKAsACvbCvxnACy6ttvm60AAlzeotdff12OHDkiixYtUn8/9dRTkpGRIQsWLKgp8/HHH1uVIYQQQgghhBDimxzLOEYBpBE4cOCAZGdne6Su1L17Jfu/FLmRrVpJZevWtVqATJ4zR4q3bKmzAPL9r7/K7vx82bxjh5x++ukSExNjtXzLli1KVxg4cKC0bt1aPE2dZZnKykqpqKio9waYzWZJSUmR0NBQadu2rcttHzp0SIKDgyUhIUEMdTDPIYQQQgghhBBSdyrLK2kB0ggCSK9evaS4uNjjdYeEhMjnH34oJ3fs6FUXmDU//yzb0tLkp19+kTVr1siQIUPU/IKCArn44ouVB0pcXJz88ccf8tZbb8nYsWPFkzSqbcpvv/0mkyZNkmPHjklJSYmccMIJ8t577ylhwxFPPPGEPPDAA9KqVSu1Tnh4uDz//PMePzCEEEIIIYQQQhwTnRAtgZ3d71LSBabuwAIEAsiKFSuUGFJUVCQ33nij7N27V/WL+/bt63adW7dulZkzZ6r+dVhhoUsCCAwi/ty2TVDypAED3HKBeeiWW+SgwSBjzz/faj7av+WWW+TMM89Ufz/33HPy4IMP+o4IgpM1fvx4ueCCC2TJkiVqh0ePHi3Tpk2Tb775RncdKEFz5syRd999Vy655BIxmUwyd+5c9T0nJ0dZhhBCCCGEEEII8T5BwUFilmpXioYUQIryisTfgQDSrVs3Ofvss5Vnxbp162Tw4MF1Mky46aabVF2bN2922cvioptuUmd+1/79MmrECLn+lluUALL5t98kv7BQd53zRo2SgADH5xvWH5oAAlq2bKkmT+OWCFJYWChpaWnqe15entXf2jxX+fTTTyUzM1Pmz58vRqNRIiIi5O6775Zx48apk5icnGy3jtbWmDFj1CfWw0l/8sknlQjizIKEEEIIIYQQQkjj4QkBJPPTTJFjXtm8ZgWMCtAXhhXH6tWr6yyAnHHGGcp65NFHH5VTTz3V5XVvvfpqGXbCCbIjM1OGjxsn9992m7IA+fann+TgkSO664wZMcKpCGIJYoLAA+TVV1+VRhVBXnzxRTXZzrMElhqu8Oeff0qXLl0kNja2Zt7QoUNrlumJIGeddZZymbnhhhvk+uuvVyd+3rx5cs0111AAIYQQQgghhBAfF0AyP8qUzv07i78DFxgYD3hCAPn6669l9+7dbq0/dOBA5QJjgqWG2SwtTSY1/8xhwxxaggS5mCkGMUFg+PD444/LsGHDpNFEELicDBgwoNZySUlJLtV39OhRu0iv0dHRyrrDUaTbsLAwefjhh5XLzHfffadcaNAeXGIcUVZWpiaN/Px89QlXGkyEEN8C9xD8Iw1zrL11H0XdZiPPY3M8h3y2EkJIwzP2orFyMPOgVFVWSUBggEOXBiSYUO9KDp6xWh8p0EFnFUktLNtIS0+TdtKuQQWQuAlxEpEdIf4OYoDUxwXmDAsBpEWLFm7XcdRkqokBYvjv2gAuW4I48KJKTU1VFi7IQovwGd7AZRGkY8eOavJYw4GBUl5ebhdcBT+6oKAg3XV++uknOeecc1RMkAkTJqiy99xzj5xyyimyc+dOu9Q6AAcPLje2ZGVlSWlpqcf2hxDSNOjTq4+0DPS87yCxJ75XvHJr9AY9+vaRoohIHnYv06Oo0OPnEJHdCSGENCxpWWmyL3OfFO8slvAe4dL5TntLiZx1OZL+RroYQ43S5YEuEhIXYrW8LLNM9s7bK6ZSkyRenigxp9v3rfYtsG6jcn5lgwsgcefFibzidhU+B4KgNpYAAhwFQX3sttukNn7fskX+PHRIeXbAuAGWH+jfwxgCLjkjR45Ugt0HH3ygEqE0ucCoe/bsUSY4SG977rnnOlQNbenQoYN8+eWXVvMOHz5cs0yPjz76SLp27aoOEICCefvttyuhY+3atSqdji133nmnijBraQmC+tu0aeOVICuEkMZl245tElv5/252xHtk78hWAay8wc6t2yQiKsordROL43zsmMfPIVLeE0IIaVgQnLR4V3XKVHyaTWYxGK2tQQp3VLsoQOQoTS21E0FKU0rVMq2srQiCOm3baBQBhChgUbFp0ya3jgbih1x//fUqLAVigFi6wOzYscOlOuACc/rpp1sJICrgqYsWvCazWb7etEl+//tvZcyAUBh///236uMj5uiJJ56oPt955x1VHt4jjSqCzJo1S20AzFPAL7/8IqNGjVLuJjB/gd+OrbDhCKg79913n/zzzz/Sv39/Ne+LL75QL09anmBYemzfvl3at28vUVFRyl0G6XSrqqpqzGhg0QGwzFGuY0y2ODMDI4Q0X5QZp9DVraGOtbfuo6jbQJfFZnkO+WwlhJCGxyAGSZiYIDnf5UjMyBg7AQS0OaeNlB0uk+C4YGlxnP3of4v+LaTFwBZSnlmuytq1Yay9DUsogHgHxNSEdcSUKVPqXMfmzZt1g6CGh4VJrIN+NdDS4L66eLGVBcjLCxa4LICkllfIRZdeqkJc9LBJ54u4oLD+8DYuiyAHDhxQ5jLIxKKBoKQQLD7++GM5ePCgEjZ++OEHl6LKDh8+XKW/mTp1qixcuFBld0F2mNtuu63GJAdWG/369VMRYa+44gqZNGmSCo5y+eWXWwVGRXogqEiEEEIIIa4CV6CVK1eqARY911k9MGr22WefKZfe0aNHq4DthBDSFIg9K1ZNjghLDpNuD3VzuNwYYpSkWUn1akODAoj3QIgKWG04iqPpLql790r8f/E8IIB0aNvWqQCi5wLjjgBSajZJcnCwZLuYitcbuLz1P//8sxI8NAsMBCX98ccfVarbVq1aqWnixInyxx9/uJxa58MPP1SBTiFkwFrjoYceUuKGBtrq06dPjZUHXGF+/fVXWbx4sdx6660SHBys2sJ3BE0lhBBCCHE1qj4GcWASjHccV0SQTz75RC699FJlsosBG7yD4D1m9uzZPOiEEPIfFEC8jyfjdUaGhEiH/0QQR3haAAlrZI8Ml/cAVhmWfr7w20Eg05NPPrlmHvyKMZriKpGRkSqehyPwggG/JUt69+4tL7/8ssttEEIIIYTYcsEFF8gTTzwhb731lhJBagPvPDNnzlSCh/bugoB0GLyBMJKYmMiDTAjxeyiA+B5ZPiaAAJe3oFu3bioAanFxcU2Q0oEDB1oFF923b58aUSGEEEIIacogphksSl0FcdCOHDki06dPr5kHN13EQPn888+9tJWEENJ8oADie2T5oAACXN6T0047TQkecE+B0IGcxMuWLatZDt9YxANxZtlBCLEH6Z/wAr1//37p0aOHCjDsKLf7V199pYIJ2wKT7KFDh7pUH2LpIAgxsjEhUxKyOrnTESCEEH9k586d6l5qOdiDwHTt2rVTy/RA4HhMlla1NQGcGfiXEOJBjAajyhDT0KhkE2JUAsiBxQckvEO4JM2uYxaYzzIl++NsSbgwQeLGOc8Cg/uxP9xHsY9IQILEIPhsVgKIiKRWVEiZmCU5OETCdALp1nWftONi+zx19ZoIdOcCh/Dx2GOPqc7V0qVL5aqrrqpZvmXLFpkxY4YkJCS4uw+E+C0wr0aAYAgSEBqffvppOf7442XVqlW65RGLx9LlLDc3V4mRCNLnSn1paWly0kknSadOnWTQoEHy+uuvy1133SUbNmxwmGGJEEJItYAM0UOLjaaBmGhYpgcGhvRijSCzXWlpKQ8rIcRjdOvUTcyB1h3Kkr0lkvtTroQmhUrMadbpbkFVYZVkf1UdXDN2TKwERNoLFznf56h0utHDoiWsi30MxlY9W0mro60kc22mJIxKkDbj24gx2P3R/rzf8iTkQIj0vbGvtBrcqtbyhk4GFdza19E6+ocOHVJZYYKCgjzeRkBQkJTYiBGFJpMUGAwSFxIikUajlLhZJ2o7WlWFVK3SFs9Ng8GujgCDQaXCrcsxQVIVHBf0iyyz0hUUFLhUh1uSDnL0IjuLHuhQYSKEuM6bb76p4t4g2wDSQKenp6sAwBA1xo0bZ1cewfgwaSBbE6w5YO3hSn1vvPGGmg+rLdwwMEIJQQSpqK655hqeOkIIcRLHDGIHrO0CLUbE8AKGZXrceeedcsstt1hZguCe3aZNGyt3YkIIqS+79+8Wc6V1R3bXi7uk/Ei5+t61U1cJbff/8R1BxtcZkvVZlvoeZ4yTuPOtrS9KD5XKnlf2qO/B24Kl+yPd7drd+89eKfu+TNWdNCVJ8o35IpXuW4BkrsqUuAvipGxgmWRW1i5uGPYbVDxKfwADlXDHxOQNsg4fFkspv6CqSgpMJmlhNEpVQIC4HvGzGthi5FRWSoXZLK0DA6XSgYV7LiwmK928WP4D/ZikpCSJiIiwmm8Zw9QjIghGoB2NdFiCURJMhJDa+fLLL+Wcc85RwgRAYL3TTz9dzdcTQWx58cUXlXihjUzWVh+yKEFB1hRTvMhj4m+WEEKc07NnT/UJkblXr17qO96LIDZry2xB5jtMuubjTcQvmhDiG5jMJjt3mPDe4VKaXipBrYMkoHWAmFT39P8J6x5WMy+0W6jdcqwTEB0gFUcrVF22y0FFYYUEtw2WDrM7iCHMoFvGGZmfZkrmR5kSNyFOYsfFury+wWzwm/soOvbo8EOEh1uMp3nk5pvlzopqMeKdY7nyZm6uTI2OlolR7luJl5hNMu/wEUmtKJcHE9pKe51noMZLQYGy/NNP67TN6M/YWmYCV68Jl0WQ5557TqWirY05c+bIokWLXK2WEL/m4MGDctZZZ1nNwygh5tfGd999J3v37pWrr77a5frgsoYsCOeff75yk8F3BAdEZgNCCCHWLF68WAkeY8eOVdnwICwjQ532ngPrO5jlIrYSIYQ0NdpObisxI2OqRZBQ+w5jZO9I6bGoh/oeFGPvZoF1uj7cVYkgIYn6nVlDoEGS5tQxBoiFABJ3nn9YddQVxEBBx98b7jDZhw+LsaJSlh7Nlmeys2VWbKxcFhomUnLYrXqKTFUy42Ca7C4vk+XtO0ivnBzn7QYFumy54WkC3TnwUFvQwZo8ebLExNj7lQGY1hNCXAMvz7ZBUPG3K0GCEJfnvPPOk7Zt27pcX0ZGhppgOgYTbsQQwSjm0aNHJT4+nqeNEOI3wA3w77//ls2bNyu/4nvuuadGLO7YsaP6vmTJEhk/frwSQWA199JLL8mFF16oYqPBBea9995TQgnjoRFCmiJ4B7R1gbFFT/ywFUIC2jkWOIIigxpFAIHlC/EcSy0EkJmtY91e31YAOS7MPn5MU8JlEeSGG25QnSSMgCAgKl4K8IlRZEeZLAghzmnfvr0KdGQJ/ka2AWcgEBSCndqmZaytvttuu019f+edd2qW4zf84IMPyrPPPsvTRQjxGzCahhGowYMHq0nD8p1m7ty5KsuWBsSQHTt2qAxbyIqH5f369WvwbSeEkKZCXfqB9RVAqkqqJD+jOtsWqT85RUV+JYC4JYLgRWHKlClq2rNnjxJDLr/8cuXresUVVyiT/No6boQQa8444wx54IEHlF85rDNgkbF27Vp57bXX1HKMNr777rsye/ZsK79y/P4wUon13akPsX0QWdoS1MssBYQQfwNugZicceONN9rNS05OVgNDhBBCpFEEkNTFqdKisgUPv4fIKSzyKwEE1CmaDLJNIO3bgQMH5Pbbb1ffkaWCEOIeV155pbLeOPXUU1UWgeHDh8uQIUNqMsDs3LlTzYd4oQGzbZhkw2TbVn2vrb5Zs2YpK5BJkyaptI2w6Fq/fr3MnDmTp44QQgghhDR5AQRZa6IT3A/aSfSJiYzwKwGkziII4gjAFB8dqJtvvllGjx4tF110kee3jhAfB1YYP/74oxpthGk2fNK//vrrmsjGnTt3VkKjZdAgxPS45JJLlODhbn2I6bNr1y4ZNmyYihOC1Lr79u2TE044oQH3mhBCCCGEeJqSAyWSsSpDfToSEbI+z5JjGx0nPcUylEHZurTREAJI8q3JEhTs+QCh/kqMTZrZhhJASisqpMm7w4Dt27fLK6+8oqKhI789OmHLli1T0dIJIXUDAsf06dN1l3Xv3l0effRRq3kIhGo7z9X6AFJs0ZSbEEIIIcR3QHrclMdSpKqoSnLW5EjPJT3FYLS2GM54P0Ny1lVn7AhsFSiRvSKtlhfuKJS0pWnqe0VOhSRebt3HM5vs22gMASS8c7jI925XQzxEkQcEkM0lJZJeVtr0RRCIHddff72ceeaZ8vzzz8uIESNqlmVnZ9d8Dw8PVxNpOIqLi1VMB0cZe/TIycmRqKgoq1zKyBZSWFhoVQ5lEAHfleWEEEIIIYQQ4lUBhDR7AeTqtIOSZJHhssm6w+Tn50tVVZV89dVXyvWlTZs2utO8efO8u8WkhrKyMhWoNjo6WsWBGDRokHJ1cMaHH36oXCwQVLNDhw6yYsWKmmVwnejZs6eKIaFNK1eudHk5IYQQQgghpOExiEGSb0+WNue3UZ+2ViAg/uJ4ib8oXtrPbG9nBQIwD8tQBmXt2jDW3oYlFEB8iyIPCiDdgkOkbasoafKWIIhBMGDAgFrLwdSeNAz333+//P7775KamiqtW7eWa665RglUmzdv1i3/7bffyrRp0+Stt96ScePGSUFBgTz99NNWZSCqLF261GGbtS1v7pw25HjJPGKdYpZ4h7iEdvL9xr94eAkhhBBCPEBYxzA1OSIgLEDanNvGaR1RQ6Lq1YYGBRDfosjDAsiyDu3lnlpEtCYhgsByABNpOiDt6X333ScJCQnq7wcffFCdo02bNsnAgQPtyiMbCFyazjnnHMnNzVXuM/fee69dObg3wc0lMFD/8qhteXMGAsj2K8oaezP8gt6vUWwihBBCCPE1KID4FkVeEEAijAHS7LLDOGLPnj3yxRdfeLJK4iSmx5EjR+S4446rmQf3FogT27ZtsytfWVkpv/76q5SXl0u7du2USwwCbCJdqgbcauDu1LdvX4mIiJDzzjtP0tLSXF5OCCGEEEII8V8ogPgWRT4ogNRJBNm7d6/qCCNTjKVlwE033SS9e/dWHW3ifYqKitRnixYtrOYja4+2zBIENEVMl88++0z++OMPFeR04cKFcvnll8vOnTtrLEngWgNx5cCBA1JSUqLcoDRqW04IIYQQQgjxTyiA+BZFPiqAuC2C3HbbbdK1a1cZO3as9OnTR2bPni0//fSTCpaJzvXLL7+s4lQQ7wOLD80ixBL8DYsNW5DBBe4rED0QRFWL7xEfHy/r16+3K4/5CxYskA0bNlhl/3F1OSGEEEIIIaTxMJvNKqtKVWmVwzJIhYvJWXYW1IG6nEEBxLco8mEBxC0RZOvWrSqI5vLly5UVCIJrvvLKK3LBBRfIjBkzlDXB1KlTrVKuEu8BC5AePXrIzz//XDPvn3/+URYfyBIDYO2RlZWlvkMAQWBbyxS3sAxBel0tpbHJZLJqA+sbDAYJDQ11aTkhhBBCCCGkaXB45WHZc/ceNekJIYXbC2Xn3J1qwnc9AWTPPdXroy49TFUmCiA+RpGPCyDA5ciWcKGYMGGCXHXVVervXr16ycaNG1UckEceecSb20gcAEucO++8U/r16yeJiYnKJQlZX2CtA+644w75999/5fvvv1d/z5kzR2bOnClDhw5V5+/555+XoKAgGTNmjFo+YsQImTt3rlqGVLu33HKLTJw4UVmRuLKcEEIIIYQQ0jQo3FotbFQcrVBTQDvrjmjRziKR/8Y4i3cVS2TvSHsrkaMVVnXZUllaKZkfZUrchDiJOy/O7W2E0JK6OFVZmyTfmizhnasHZ0njUOQHAohbIsjRo0dVQE1LEIjTFzOENBeuvfZaFeh03rx5Kg7I6NGj5dFHH61ZDreYNm3+Pw0WBAuURxlYcfTv31+5wiC9LliyZIkStGBRgvUgeM2aNatm/dqWE0IIIYQQQpoGCZckyJH3jkhk30gJSQyxWx4zIkaKdlTHEoweYe9Oj3ViRscoAQR16WEqM1EA8RGK/EQAAS4rGPADgyuFZTaQvLw81fm2nAc3jVatWnl+S4kusP7ApAdidtiCmCCY9IC7zHvvvefwSNe2nBBCCCGEEFeZesFZkpdxwKWyR/OLJTuvWGJbhUvrlu5bC5hMZknLzpOyiirp36+fvPP5Wp8/US0HtlSTI4JigqTzXZ0dLofbe+KURKdtBIUH0QLEByjyIwEEuGXG8eKLL6pJb74GXC4WLVrkma0jhBBCCCGE+CQQQD49M73Wcg/9UCb3biiTB0eGyD2nliMynVvtFJSZ5eyVxbI/s0pWT42Qh7bV3iZxjYDQgEZxgakodxzMtbkyacwYybUwLmgoDqWlyYzSMr8RQNwSQZAKFZYAtZGUlFTfbSKEEEIIIYSQagHkO00AsXfpcFUA2fqfADIYcTG28cA2Fp4QQIr3FUvRkWo3Hl8CAsiTFZUN3u7okhLJr6jwGwHELRGkY8eOaiKOGXjSKZJ+JIOHyMskJsTLpl//PysOIYQQ3yYjI0N27Nghp512mvq7tLRUueQiXbtGamqqStuO+FeEEN/AKwIIafYCSMrCFGnboa1XttEfqTSb/UoAAYxq6kEggIROetqTVRK94/z2/3hcCCHEj9iyZYsK6q2JID/99JP6e82aNTVldu/eLcuXL6cIQoiPQAHEt/CkABLaLlSi4qO8sp3+SFRgoF8JIMDY2BtACCGEEEIIIRoUQOpG9jfZsuv2XepTj5KUEtl9z25JfSZVZXWxBfOwDGVQti5tNIQAkjQnSYwG3+/GlphMctOhNBm/f79sLy3VLfN6To6M2bdXfeqxrbRUrY96UJ9eG4Y6bJsnBJCcosZzafL9q4cQQgghhBDSLKAAUjfMYpYj7xyR8oxy9Wk2me3KZH2RJWVpZVKwqUAKNhfYLS/4p0AtQxmUtWvDVHsbDSGABIQ1H4uD+rC+qFDWFhbKrvIyeSnnqN3yKrNZFmZlSmpFhfrE37Yszzmq1kc9PxQV6rZh0lnP2wLI0qPZklNIEYQQQgghhBDix1AAqTsGMUh492qBAZ8Go/34fmSvSPVpDDVKaFKo3fLQ5FC1zLKsVRvG2tuwhAJI/egdEirh/1m8nBRuLx4FGAwy6D83Fnzib1u09VBPr5BQ3TYaQwB5JjtbYiIjpNnFBKmoqFD+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": [ + "
" + ] + }, + "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+epz377zz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", + "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+fr1693NQAAAAIAcWUrRuXNne/TRR61OnTrWrVs3V2O8fft2++ijj9zNNtSt2h9//OHuYPfCCy9kTKsBAACArA6MCxYs6O5O9/zzz7sM8ebNm618+fLWs2dPu+uuu1zGWI958+ald1sBAACA+AmMpUiRIjZ06FD3AAAAAHJtYOzZvXu3uz108D1ClC0uU6ZMerQNAAAAiN+L70TdsJ122mlWunRpq1mzptWqVSvwGDVqVPq3EgAAAIi3jLF6m9CNPZ566in79ddfXca4T58+Nm7cOPvyyy9t0KBBGdNSAAAAIJ4yxt999527q91NN93kbuahemP1a/zqq6/aueeea9OnT8+YlgIAAADxFBhv27bNqlat6v4vUaKE7dy5M/Da+eefb2vWrEnfFgIAAADxGBjrQjvd2U7q1q3rMsRbt261/fv3u1KKChUqZEQ7AQAAgPiqMa5cubIVKlTI/a8SCj0qVapkefPmdZlk3eADAAAAyPGBcffu3UOe6453ixYtcreIbtq0aSBoBgAAAHJ0KcWSJUvs+PHj/zeBPHnsvPPOs9atW7tbQk+ePDm92wgAAADEX2A8Y8YM69u3b8hNPUQ1xu3bt7cFCxakZ/sAAACA+AyMb7jhBps7d64NGTIkMOyff/6xdu3a2eHDh+2ee+5J7zYCAAAA8VdjrL6Lp02bZs2aNXO3fr799ttdpvjo0aNuePHixTOmpQAAAEC83RJat4NWEPzkk09agwYN7NixY/bVV18RFAMAACBnZ4x/++03W7FiRaLhqjV+5ZVX7P7773e1x1KrVi2rU6dO+rcUAAAAyOrA+IsvvrDhw4cn+fqAAQMC/9955502cuTI9GkdAAAAEE+B8R133OEeAAAAQE6VqhpjAAAAwHJ7rxSiPownTZpkP/30k+3atSukT+M2bdrYFVdckZ5tBAAAAOIzMO7QoYMtXbrU9UKhbtpOPvlk+/HHH11Xbo0aNUr/VgIAAADxVkoxc+ZMW7Vqla1evdpuuukmlx2eP3++LV682N0qunHjxhnTUgAAACCeAmMFxZdeeqkVK1bM8uXLZwcPHnTDzznnHLv++uttypQpGdFOAAAAIL4C4wMHDljRokXd/xUqVLC1a9cGXktISLD9+/enbwsBAACAeK0x9rRq1cqVU6iP41KlStmrr75qH374Yfq1DgAAAIjXwLhz5852+PBh978utlMg/Mgjj9jevXvtgQcecGUWAAAAQI4PjE8//fSQ5+3atXMPAAAAIFfe4OOzzz5zJRQff/yxe75x40bXhRsAAACQa2qMe/fu7XqfKFOmjB05csS6dOlihQoVsmuuucZ12+ZdnAcAAADk2IzxggULbPr06bZmzRq75ZZbAsMVJDdt2tQmTJiQoum9+OKLVrt2bStZsqRddNFF9sMPP8Q8rmqaCxYsaEOGDEnRZwIAAABpDox1G2jd+U6BsLpnC1atWrWQ7tuS8/bbb9ugQYNs5MiRtnLlSmvQoIG1bdvWNm3alOy4M2bMcEF4lSpV3N33AAAAgEwNjAsUKGC7du2K+NqyZcusXLlyMU9r9OjRrru3q6++2ipVqmRPPfWUFSlSxGWRo9mxY4fdeOON9tZbb7n3AwAAAJkeGKs7tmnTptm3334byBjrVtDPPPOMqzvu1KlTTNPZvXu3/fzzz3bxxRf/X2Py5HHP58yZk+R4Pp/PevXqZX379rUmTZqktPkAAABA+lx8p7vd6UYe7du3t7x581r+/PntpZdechfhKdNbo0aNmKazZcsW9zc8w6znixYtSnK8sWPH2p49e+zee++Nuc3qd9nre1nU5zIAAACQ5l4punbtai1atHAZYtUDq95YdceqMU4pZYnDnysrHMmSJUts1KhRtnDhQheUx0rjPPTQQyluGwAAAHKPVN8Sunz58q6cIS3je/XCwbZv3x54LZxKLFSCceaZZwaGKVO9YsUKe/nll23//v0RA+Zhw4a5i/yCM8a6aA8AAABIUWCsvom/++67WN5qDRs2dN2uJUdZ5lq1atmsWbPsyiuvdMOUKdbzHj16RBxnwIABiYLxxo0bW/PmzW3MmDFJZpF1waAeAAAAQJoCY/U28frrr8fyVnchXiyBsdx111129913uwv2dCHd448/bn/99Zf169cvJBhW38aqO1bgGx786gJADVN/xgAAAECGBsbqGk2P9KagV6URyhDv3LnTlUjoVtPVq1cPvEd9FAdfOAcAAABkhARfUle6ZbITJ04kuhBPjh075l5T7xeRqMZY4+XLF3u5tGqMS5Qo4Xq3KF68eJraDSDjberejcWMuFNpfMru9Aoga6Qk7kv1xXfpLVJQLMkFvEkFzAAAAECG3uADAAAAyIkIjAEAAAACYwAAAMCPjDEAAABAYAwAAAD4kTEGAAAACIwBAAAAPzLGAAAAAIExAAAA4EfGGAAAACAwBgAAAPzIGAMAAAAExgAAAIAfGWMAAACAwBgAAADwI2MMAAAAEBgDAAAAfmSMAQAAAAJjAAAAwI+MMQAAAEBgDAAAAPiRMQYAAAAIjAEAAAA/MsYAAAAAgTEAAADgR8YYAAAAIDAGAAAA/MgYAwAAAATGAAAAgB8ZYwAAAIDAGAAAAPAjYwwAAAAQGAMAAAB+ZIwBAAAAM8uX1Uvh2LFj9v3339u2bdvs7LPPtjp16iQ7ztatW23hwoWWL18+a9iwoZUrVy5T2goAAICcK0sD4127dlnbtm3d37p169qsWbOsT58+9vTTT0d8v8/nc69/8803Vq9ePTtw4IDNmzfPnnjiCRswYECmtx8AAAA5R5YGxsOHD7eDBw/a8uXLrWjRorZgwQK74IILrF27dnbppZdGDIxbtGhhr732mssWy3//+1/r16+fXXbZZVatWrUsmAsAAADkBFlWY6wgd/z48S4DrKBYGjdu7B7vvfdexHHy5MljN954YyAolk6dOtnx48dt1apVmdZ2AAAA5DxZljHesGGD7dmzJ1FNsUoqFi9eHPN0pk6d6gJmjZeUw4cPu4dn7969qWw1AAAAcqosyxgrKJaSJUuGDC9dunTgteT89ttvdtddd9kdd9xhp556apLvGzVqlJUoUSLwqFKlShpbDwAAgJwmywLjQoUKub/79+8PGb5v377Aa9GsX7/e2rRpYy1btrQxY8ZEfe+wYcNcsO09lK0GAAAA4qKUQhnek046yf7444+Q4Xpeo0aNqOMqsFVAXL9+fVenHFxzHEmBAgXcAwAAAIi7jHH+/PldV20TJkwIDFNfxjNmzLCOHTsGhs2dO9cmT54ceL5x40YXFJ977rn2wQcfuOAaAAAAyNbdtY0ePdqaNWtmXbt2tSZNmtgbb7zh+ifu1atX4D3jxo2z+fPn2+WXX26HDh2yVq1a2T///GPt27e3d999N/A+TadWrVpZNCcAAADI7rI0MFZPEkuXLnXB75o1a1x/xH379g3JAivgPeWUUwJ3yWvatKn7f86cOSHTql69OoExAAAAUi3Bpw6Fcxl116beKXQhXvHixbO6OQCSsal7N5YR4k6l8f9XCgggZ8R9WVZjDAAAAMQTAmMAAACAwBgAAADwI2MMAAAAEBgDAAAAfmSMAQAAAAJjAAAAwI+MMQAAAEBgDAAAAPiRMQYAAAAIjAEAAAA/MsYAAAAAgTEAAADgR8YYAAAAIDAGAAAA/MgYAwAAAATGAAAAgB8ZYwAAAIDAGAAAAPAjYwwAAAAQGAMAAAB+ZIwBAAAAAmMAAADAj4wxAAAAQGAMAAAA+JExBgAAAAiMAQAAAD8yxgAAAACBMQAAAOBHxhgAAAAgMAYAAAD8yBgDAAAABMYAAACAHxljAAAAgMAYAAAAiJOM8XfffWfdunWzli1b2u23325btmzJkHEAAACAuA2MZ86caa1bt7ZatWrZkCFD7Ndff7VmzZrZvn370nUcAAAAIDkJPp/PZ1lEAW2VKlVs/Pjx7vmBAwesQoUK9sADD9jgwYPTbZxwe/futRIlStiePXusePHi6ThHADLCpu7dWLCIO5XGT8jqJgBI57gvyzLGCmjnz59vnTp1CgwrXLiwtWnTxqZPn55u4wAAAACxyGdZZMOGDXbixAmrWLFiyHA9/+abb9JtHDl8+LB7eHTE4B1BAIh/+44ezeomAImwDwGy17YaS5FElgXGR///jq5AgQIhwwsVKhR4LT3GkVGjRtlDDz2UaLhKMgAASJVPJrLggGxE16OppCIuA+PSpUu7v7t27QoZvnPnTitTpky6jSPDhg2zQYMGBZ4r66xpaJyEhIQ0zQey5shPBzU6g0CNOMC2A7DfQTTKFCsoDq84iKvAWI075ZRTbOHChdaxY8fA8AULFthFF12UbuN4GebwLHPJkiXTZT6QdRQUExgDbDsA+x0kJ7lMcVx019anTx97/fXXbdOmTe75xx9/bD///LMbHlwGce2116ZoHAAAACClsixjLOpiTf0Q16xZ050a37hxoz3//PN23nnnBd6zdu1aW7p0aYrGAQAAALJVYKzyhg8++MA2b95s27Ztc8FusWLFQt4zfPhw++eff1I0DnI2rQMjRoxIVB4DgG0HYL+DbHuDDwAAACBeZGmNMQAAABAvCIwBAAAAAmNkFNWFjx8/3vbv35/maamefNq0aZYZ/v77b9fuI0eOpNs0//rrL/vss8/SbXrI+ZYtW2bffvttpn3e999/7y50zgyLFy+22bNnp+s01WXnqlWr0nWaiB8HDx50v8vqhzaebd261b788stM+SwtCy0TLZv0ovs7fPrpp5bbkTFGhv1A9OjRw3bs2JHmafXr1891yZcZ/vjjD9fulN7qVXde1A/i5MmTI/adeOedd9qMGTPSsaWINz/99JPNmjUrXcZ777337JFHHrHMWuevuuoqK1KkSKZ83rhx4+yJJ55I1QHyhAkTbPny5REPaK+88sqod0BF9qWbeOl32eumNb0ocaPgMvgC/7S4/fbbQ3rRykhaFlomWjYpoQNgJWp++OEHO3bsWKJ91dChQ+2LL76w3IzAGHFNmaW5c+fagAEDLF49/PDDVqNGDfvXv/7lgvhwJ510kg0ePNjuueeeLGkfMsc777zj+l1Pj/HOPfdcu/jiiy0zPPjgg9azZ09386R4pMD96quvtiZNmljfvn1dcBzu0ksvtcKFC9ubb76ZJW1E9qTEjYJLJXLS6scff3RnNhUcxyMFxK1atbLLLrvMXnnlFbfNn3XWWbZy5crAe/LmzWtDhgxxdwvOzbK0uzZkrcOHD9v8+fPdUXP9+vWtQoUKIa/v2bPHvZ4vXz63U/IySjrK/Oijj9z/eq1q1apufG1Uydm9e3dgmhrn5JNPjvp+9VHdvXv3QNdsygxNnTrVZbiURdbGfsEFF1i5cuWSbVPwuNrZ/vLLL1a9enX34xDNn3/+afPmzXM/KvqccLr7nk7lvvvuu0lmwjQPyhrrx7NRo0bJLidkDXUBOXPmTPd96Q6b+u4VoOr28bqVvLIsylxqvalXr15gPJ3GX7NmjdvBKgMluhunti2VDnjrSd26de3UU09Ndrw6deok2h6jfb7ocw4cOGANGzZ0WWhtv+rfXW1PirJN+lyt3+HT0faj4fq/c+fObnuJNi+pbYPobIpOCXfo0CHRa5qGvg+1U5+ZlOuvv95eeOEFu/nmm6N+FtKfviOtK3ny5HHrZfDv5CeffOKGaZ0NpptzaR2rVq1a4GBHv92nnXaaG67/o5WnTZ8+3bp27eo+U1T+ps9S4Kc726rDrWjT1X5sypQp7v/PP//cypcv7w4OW7Zsmew8JbWv0gGct59UqYOme/nll7t7L+jRuHFjq1SpUrLzGzyuMsOrV692+7Ro67/ovg5KJmkewg90NU3dB8KbP82/pt+7d2/3u+K55ppr7NZbb3XTufDCCy1XUndtyH1+/fVXX6VKlXz16tXzderUyVetWjXf2LFjA6+PGzfOV7RoUV+jRo18bdq08Z1xxhm+ZcuWudcOHTrk69atm3tcddVVvqpVq/oaNGjg2759e8j0tXqtW7cuMOzNN9/0lShRwteqVStf27Zt3f8alpRjx46593z00UeBYUuWLHHT7dChg69u3bq+rl27+hYvXhxTm7xxO3fu7DvnnHN87du39xUqVMh3//33J3rPjh073POlS5f6TjnlFN+wYcOSXaZjxozxlS9fPsnXtSxHjBiR7HSQdb7++uvA+qX1R+uX1uWdO3f6Gjdu7KtYsaKvXbt2vtKlS/s6duzoO3z4sBtv8uTJvtq1a7t1xVsPFy5c6Pviiy8Czy+99FJfkSJFfA888EDg85Iab+jQob7WrVsH3pfc58utt97qq1Onju+ss87yXXLJJa79ep/W6aS8//77vlKlSvlOnDiRaDpql7bTfv36ueHJzUusbdB7Lr/88sDzkSNH+k4++WTfDz/8kOz3ozbde++9EV/76aef3He3fv36ZKeD9PPee+/5SpYs6WvZsqVbL/Sb/eqrrwZe13raq1evkHH0m63vas2aNb7jx4+H/HZXr17d/T5v3rw58P4NGza4969atco9//77793zgwcPBt6j32wN89a15KarfYba5m3vet8jjzwS0zyF0/ZTtmxZ3zvvvBMYprZq2vqMM88803fNNdf45s+fH9P8euNqX6X9nNpXuHBh35AhQxK9R8tGVq5c6fbpgwcPDtmeo3nllVd8+fPnT/T+Cy+80P0G5VYExrnUwIED3Q7Wc/ToUd+nn34a+NHKmzdvSNCqnc2iRYsiTkvjageqaSYVGCuo1o5UO33PzJkzXWDqbdjh9KOpafz888+JAte+fftG3fgjtckbN3jYpEmTfPny5fPt2rUr5D36kdWPr4KGJ5980heL5ALj3r17u2Ac8R8Yh+8UbrvtNhfw7dmzxz3XOqsdYfC6oR2SdqLRaF0uWLCgb/ny5VHHCw+MY/l8BZwnnXSS2349OuhVcJ8U7WibNm0aMkzTSUhI8M2ZMyfF8xJLG7zAWNuvtsXKlSuHbOOpDYy1zavdn3zySUzTQtqtXr3a/a7PnTs3MEzrjdaLtWvXBg6+ihUr5jtw4EDIOn/eeeclmRBRMNm/f/80BcaxTFf7J42j/VVK5incn3/+6aYTvN57gev1118fdV8VqV3euAMGDAiMO3XqVF+ePHl8W7ZsCXmPlo0C7jJlyriDzJRQsF6/fv1EwwcMGOASWLkVpRS5VKFChdxp4+3bt7tTRDqN06lTp0DN45lnnmm9evUKvF+339YjmC6CWbdunTt1qtO+wadjwv3vf/9zp11VwqBxvPvK5M+f352u0umbSKfLpFSpUoleUx1XQkJCouGxtCm4XlmnlXRKSSUZwSUOujjhjjvusOeeey5kOaSF5iO4ngvxS999MJ3G/89//uNKCKRy5cpuvdDwQYMGRZ2WLuxZsmSJK5c4fvy4Kx9SmUZyp0VT8/kqedJpWU+LFi3ctpcUbWORti9tC02bNk3VvMTSBm1zar+2zzlz5iQqyUgN/YbpLqje7wYynsrHdMpep/s//PDDwO+6vgd9ryqf0On6W265xZUtqPRBJUFab+++++6Qaa1YscL9dqv8SGUN0fYnKZHS6cYyTynZV912220R91WxtKt///6BcbWv0rL77bffQsokvv76axs4cKAr40tJGZH28ypniXShXalSpXL1dkRgnEvpYjDVLam+SRf66OIVbcBly5a19evX2+mnn57kuOqxQXVcCiYbNGjgdta///67q+FNigJi/QB4dcAeTUdXwkZStGhR9zdSl2/h9ZcpaVPp0qUD/3u1y4cOHQp5jy6k69KlS7oFxd58cPvy7CF4/dJBlnYS4TtEXXD59ttvR52OdjrXXXedC/xUI1iwYEG3HuiANFYp+fzgddtbv8PX7fBtTDvn5LavlMxLLG3waorVJV16BMXBy4ptLPPod13fbfjvuq7H8IJEJWHUY4gCTgXG+s6VlFHduGgdat++vbtmRAdk+u3WdFOyjUSS2unGMk9p2VelpF2x7KtUD6za/JQExUr83HTTTS7xo31/uP25fF9FYJxL6WIYXaiggFJF9mPHjnVZHV1gowsXdKFAUl5++WUXcCqA9jZWdS0V7Ypwbfy66MC7wCgWCgSUBdKOW0FAsPAj8NS0KRq1U0HA448/niizkVqaj9q1a6fLtJCxgtcv9Xagh/r4DKbnyV08qkxO+FXeCiq9LFQs0vL5ydEBcKR+SyNluNJjXjyXXHKJG7dbt27uYkedoUqrDRs2uEw021jm0e+6zjgm97uu31IFb1pnFSC3bds2cDHbf//7X5ed1W+3gmhR9vPpp59OcnreBXfKoHrCA8bUTDcl8xRMCSbtd/Qbrwtno21LqW1XUrQ8dQGdzijp4rrk6KI+XSSoz1QCKJJ1uXxfRXdtuZTXH6R+BHT0OmbMGLcx6KhVOy11kaZTNh7t/LxTKzqNqp2aF4Dqx2nixIlRP0/ZXPXcoNOw4b1UJNVBuY7Czz//fHf6KjmpaVM0Oo2sfokVXGvZpJVOO2v+27Rpk+ZpIfM1a9bMHUgGbw86DRl81bbW1/Cds9bL4B2Myg60QwwWabzUfH5qtG7d2vW8oavZkxPLvKSEAgEFxur1Iz1uzqHfCR0o6AwYMod+19X7iHoaCqYeHYKzp8q26rtR8kXrrQLl4PVKZw28IFHrdvC6HomSLBK8j9IBVrBYputleoO3v1jnKZj2O9pGY91XpXR+o1GvLyqnUHJLXYdGo32agmIlfJLqVs7n87nyxty8ryJjnEs99thjruxAQbC6l3njjTdcMFixYkVXQqAbDKjbKG08KnXQj5nKL3TUr66bnn32WdcvrzK5OrJW2UK0Lpk0TdUR6wdS9Zv6YVC9rbJVCsK9H4lwOj00evRoGzFiRNT5SU2bkqMfOp0+bteunTvq//e//x3xfbo5w5YtW9yPqX5gvUyDlpV3OkrdxGkeO3bsmOr2IOtoR6L14YYbbnDbxaRJk1yGMjjbqtOiOojS6UmVJOl9V1xxhcuy6oBTZzQUDIbfSCPSeKn5/NRQplbT1Tqb1PrtiWVeUuqZZ55xO2L9LiiwOeOMMxK9RwfO3o1z1OWUTkGrvVpWCuw96gKrT58+gWwiMp5+z7RO6nS89hXqek0HOfq+VDLhrR/qNlOlE/fdd19gXfLo2hat31r/dOClEgZNI9q6pQxt8+bNXambsp7aFpQ5DRbLdBWsK6EycuRI937t/2Kdp0j7quHDh9ujjz4a8YxLWuY3luBYfShrf67P9pZzMNUwq6tSHUyHZ8Q1XNf7eGVOJ06ccOUvuRW/ILmUdsIKUFXbpMyPfgi82y5rx6JA+Kmnngr096ubAHh9jOoiAPUhqTIMHVWrg3QF0sFBnwJCZYO8jV0b6/vvv29vvfWW6ztVR6T6cVO/vtH6h1Qn5Do9+tVXX7nnqvHSdL3MsCeWNkUaVz/YGqadbKT36EdER9mLFi1K8o5GmhcFKmqnsg36X4/g25dqeaskQzf7QPzSRS36/sOpH1Md+GjHqdsna0ek9cHLXInWNXWcrwtA9f3rYOm1115z25nWSdVVaueq58F9Z0caL/wGH7F8vvoODg+qVSoRqW/gYDr9+tJLLwXuGhdpOhLLvMTShvD36IBWwY2WQaRbsSsw9rYpjacduP7XcvDobJcOUFXugcylcjX91iqjqiSH1kmdHQteN0U1rVoPlORQaZBH/dAr4FRSQb+lCtI++OADd9GeR+/XduldfOrVyapmWeuj9jM6sNJ7vDrgWKbrJS10MatKDLyMb6zzFEyZWNXdeweraqvaEzyvsbYrqXE1zLvwLvw9Oruq/aQSTtqvhlO2W5+hZJG3PXkP3dMgeF81ePBgNy+5VYK6psjqRgDR6AhWR7vZ9c5xuiGDdgYKPqJ1Wg9kFWWYdNZFO9fsSNlCr6cLIKvoYE1B7/33359t7wQ4dOhQt68KTz7lJgTGAAAAAKUUAAAAgB81xgAAAACBMQAAAOBHxhgAAAAgMAYAAAD8yBgDAAAABMYAAACAHxljAAAAgMAYAAAA8CNjDAAAABAYAwAAAOb8P/2BPqTxH6NtAAAAAElFTkSuQmCC", + "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": "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", + "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+/PnStGlTXZYwYUJp3LixDBo0SLJnz+6R1xARERH5M6+oit20aZOEhoZKnTp1TMtq1aolceLEkY0bN3rsNURERET+zCsydmfPnpXAwEDNuBnix48vqVKl0sc89ZqnT5/qzXD37l39/969ex78NBTdXj5+Kb7IV7fDV08fiS/y1e+byJ/x+B3+8QoJLZ8I7J4/f65BmTUEbXjMU68ZM2aMjBw5MszyLFmyuLTeRJ6UvGtyfqFRKPlEft1E5FvH7/v370vy5Mm9P7BLmTKl3Lp1K8zymzdvagbOU68ZMmSIdrAw71WLMvD8WLFiufUZyLkrDwTT58+fl2TJkvGrI7/C7Zv8Gbfv6IFMHYK6jBkzRvhcrwjsihYtqlWkBw4ckODgYF127Ngx3YCKFCnisdcgw2ed5UuRIoXHPw85BkEdAzvyV9y+yZ9x+456EWXqvKrzRNmyZSUoKEg++eQTzaIhMv34448lZ86cUqlSJdPzqlWrJt98841TryEiIiKKKbwiYxcQECALFy6URo0aSYYMGbRaNHHixLJkyRLt5WrYsWOHlCtXzqnXEBEREcUUXhMB5c+fX6tSMcAwsm/IxiF4M7d+/XpJnz69U68h74Pq8I8++shm5xciX8ftm/wZt2/vFyvUkb6zREREROT1mN4iIiIi8hMM7IiIiIj8BAM7P/D48WP5+eefdYybyHbixAlZvXq115QTEbS/XLduXaS/D3mPgwcPWkwruHfvXtmyZUu0rc+zZ890CkTMa439FLft27dH2fvv3LlT/vzzzyh7PyJnrFixQk6fPs0vzYMY2PkBDMrcqlUruXjxYqS/19q1ay0GeXbE8ePHZc2aNW6X44rly5fLBx98ECnfOXpl//333x4vm9yzaNEiHfrI8OOPP+qsM562Z88e+f333yMM6kqUKKHb+tKlS2XZsmU6UPqXX34pUQVDRE2YMMHj5eJ4g2AVgTT5rlWrVsnJkyejrexu3bp5/MLr5cuXejGD/S0mBo1e0yuWXJcoUSJp0aKF1w72i8wcTi41a9Y0LcudO7fUqVNHfM2NGzekX79+8ttvv+lJu27dulKqVKnoXi0KBwYsT506tce/o9mzZ8u///4rFSpUsPscZOaQNUY23egF3qFDB3nx4oXP/manTp2S/v37ayYQM/fg7wIFCkT3apGLevXqpb8hxoD1pbLDO0ZXq1ZNt81cuXLJtm3bZPDgwTJs2DCJKRjY+RDMqmFkiMqUKSNJkiQxzY/bsGFDSZo0qd7HSQRXSg0aNNAqT9wQfGTOnFkfP3PmjOzbt08yZcokxYoVM02nhiqru3fvWgzwjIP4oUOHpF69ejbX6cKFC/LHH3/o3xhHMG/evLozGc6ePauZDZSLKijAe2bPnl2qVKkSpjw8F1dYWFdkOsynesMJEtOQvfnmm7r+V69e1RlIMI6hM3CyvXTpkn5nzg6P8/DhQ6lcubJ89dVXdr8TsoQZYrZu3arfNYIsfPfXrl3T7xEQIOA5hQsXlr/++kuePHmi3y2GMsL2ALhowQwztuZ1RoCN8rGt2Jp1BsMiGdu+OWzX2DewH+B15uNfGuuE7cvYL0qWLKlTGcLhw4c1E33lyhXTdl2xYkWL6X7279+vGYO4ceNqtg5atmxpc/PAIOv//POPfjc4CRYqVMgiO4yhnpo1a2baXlFuunTp9DgAly9f1gxF06ZNHdr8NmzYoJ+vdu3a4ix8F23btpUFCxbo/k7uQZCP3x6/c/HixcMczx48eKC/LfYLbIPWj6MqE/sGjr/YVnGhX7p06TDjueKxc+fO6faFfQKwXaF8bO/GdoxtFE0ZsI9in8IQY9g3rWc9iOh97ZUdEWyXqGnBxT/e11n9+/fX/Qn7KNYNtUNIKiDYw/rFBAzsfMTcuXOla9euGjRhblsETD/99JOeeIyqWAQ+ONCiigT3kU1CGhxX0zix4YDw3nvvybx583QDRwCIYPCXX37R4BBVVqhWMQ/ssIOPGjXKbhCD98JJBrATI8jDCWjmzJm6DIEYpn3DexnPw8kRJ0VURxknFhy0MNg0BqHGwQsn9Bw5cmiAakz7hp19+vTpkiZNGj3I4IS+e/duXV61alWHvsdvv/1W+vTpo1VIOEni4GXeHssWHASNaetee+01ad++vUPvRaKByhtvvKG/FcaZxPaVLVs2iR07timwQzYXAR0O6Hjs9ddf1+0NQZexzdy5c0e3rUGDBllceSO4R6CPgDtfvny6reGiAeUbrLfrR48e6f6BEw4uMpB1QzZt5cqVpgDQWCecdLNmzarbCU6KaCuHbQFBJ/YtbPPGOubJk8cisEPgiBP28+fPTc+xdWLD/osTD4IzlI2LNwSJqOpHUIjP0rp1a933sb/jM2NfwXMRPMIPP/ygg7M7Etihmhr73q+//qr3cSEVUZMC7JPYHwFBsL1pG8k52F7xW6LqENsvgpGhQ4fKO++8o4/jgqVx48Z6QRMYGKgXpWhWgEyYeVUmXovtEf/j2IntGK+NFy+ebn/Yn7APIHDEhT22aWwvqALFvoMLZWzLxjaKfQ3rgtHQ8HyUjUDe2GcdeV97ZYcH5wlccGOdsT8CglqcR8KD1yRIkECPMwsWLJDPP/9cgzqoUaOGrh/OoQzsyGtgB0P1DQ7G7777ri7DSQDZsvDgQICTi5H1Gjt2rLY/QjCEkxBs3rxZdwYEdq5AJtC4GjNO5AULFtQTDHao8uXLS7t27fREaf48BHbmJk2apAcAHOgQgN6+fVt3QpyEvvjiC9PzcHL9+uuvTdW6CHYx2LEjgd24ceMkJCRET2iYks5I2xsnXXsQABqBHTkHQRgCcxzksY3hd8dVOAIFc9hOUWVivhwXJrgZjBMTti0EiUb5yFwjSEPG4OjRoxp0hFc9jjZuOOEgG42ADicvZKB69+6t+4cBwRsuNLA9Ay5CsP3gBIGTMT4TgkLz7docgkcEZp06dbL7HMCJHPsgLsxwoYVtHJ8BFzHvv/++fn9YB+yrCOzwPzJ6eD62X1QzYxkC6PDgcyIgwIUQ2gYaxwC8X0T7ALKDRmBHnoGLBgQk2FZRrY9tBduBcaGJv99++23djqZMmaLLcEGKbRXHP+P3g+vXr2uQj2AGVZC4uFm8eLG+FoEWgiNchBvNddA8BsEkpuREggCBJC76DcOHD7dYVxyDce7BxZa58N7XXtn2YFuuVauWZggRnBnnJOyDEXU2wusQ2CHIfPz4cZimATh+49wSUzBj5wNwIsEOYwR1gOAnoirIHj16WFRl4uCBk4z5ASGik4EjkGnZtWuXBpu40sJVGzIVCOwchRMfDmLGZ0JQiivCzz77zCKww/dg3lYP64/MRkSQ6ZkzZ46ejM13elzJhXfSJffgII+g3ThIo3oFTQSwrZjDyc062ANc6eNCBFkqnIiQrUYQZwR2+O0nTpyoQR0gY12/fn19vi2oosF+gAslZAQQ7OCG7e67776zeC6mLzSCOkDGz5FtzVk4WWM7N5pSIJuCiyFslwjsjO0c2UJ0wkAQh4AX3ym2ZwQHOHEbz7UF+yUCAmRV8Fzzqml8Ls6vHfUQXOPiAoEcgjpApss4viGgwePmnb/QlhoXAtivzJe/9dZbpgwVakSw3eLCBLCdGBcOxgWPI1XwSBwgqYCLbGSNcRGDv3FsduR9nYGLCyQBcAxArYp5NTIuuJxpJgBGkwkDjhu46IspGNj5AGz05u3WHGUd+KEcnFg9CQEcTtTIKmAdcYLFlReqrpyBqmXrjADagiAAwEEJBzxbOywyLqjGDQ8OTgg8USVnfSXnbFUsOdcmFAdaVK+aw33rwM7WRQoCLwQ4eD5u+K1RjWpsWygfVbTW5SP4txfY4flYJ1y9o32cObTBQeBntGNzZVtzFj4DTpa2tn1kLQwI7HDCQ3CLwA4ZHPyNYA9BGr4XnBjtQWcfZDJQxWbd3tDZqljyDByPsU2heYe9YyKyUNb7BrYNPGYuvG0VtRPoPICsFgIctG3u3LmzNkMIr50aMsZo54ymLzgGA/Y988DOU/sIMuEoF22XrdsGOlMVa3RQevD/Vb8G3MfjMQUDOx+AoMk6Be4I82ydUQ7a89iDExpObOYi2klxwEAbkKlTp5qW4QTj7Ex1qE5CKt8c7qPqwAjqXIWsHK50UW2LIACZGAOrYiMPqkjx2yGYModAJqJtFVBtiKzEgAEDTMsQlBjbFsrHgdy6PFvlG5BdQPYB7SQRNEY3ZOlwwrG17Zv35EVGDScnDBuEQAwnawR26KGN9lc4SRsZP1uQoUEVF6qxERiaZ+1ZFRs9cDxGbYfR1tkafn8cfxG0GxlpY9sILyizZeTIkdpsAdlvZIKRucNFOar2bV0Ijx8/XptH4NgJ6ByBzhKRNQMp2nHjwhtNHNBUwDwIc6YqFhd1sWLF0m3avD2drcSBP2Ng5wOqV6+uV0/Y4YwdDYz2Nc6Ug44TOFEajcuRMcDJETf0DkSKHzuvcaJFRiA8yHrgasmAhraoKjPvzYQTcEQBIoJB9BzElaLx3mjvFF4Wwhn4zPhcOADg5Gi0sWNVbOTBhQIOrjhQI6trVAmijSMOwBFB1s08AMHB3XysRpSPXqFoH2ZsgzhRosMNOmDYgu0c2a8ZM2Zo1aR5r2iUjX3AUY5s1xHBto7PgIbs6HQEuLjCffNt32hnN2LECM2i4ESPx9FmEdXDtnqYW0MbXewD6GyC/dr4blkVGz3w+6GaFE1E0OzEvN0asmQIuvA747jYpk0bfQxVswjOEKg5CvsRzhPIhCHzihuq/3GcxntYb8c4pqNq2HyIEvO2p85wdB9B5g+9aLEd41hhHtw5UxWL/aRs2bK6TzRv3ty0XyPr9/3330tMwcDOB6A9Da60cUJCOxqk03HgRwYK2TJHoTErslU4oOBAgStFXL2hnQ5OeE2aNNEMCbIZONjjyh7tQMyvFq3hhPrpp5/qCQMnJLSnsk55o8E7sgzovIH2Q7auNnHCwkEGOzV6cOHEgxt6JnrKwIEDdT3RhsU8uHOW0SYPB0wEKriPTBCHP7F9JY6DNTJ3+H3RmBoZCFsZOmvYFpCRQoccZOGwbRlD/BhGjx6t+wXKx3aF9qgI7sKDakwEN9jG0UsPvyG2tbRp05p6czsC2zXWCdlq7JPWw504Cp16MBYe2v1hm8SJHCdX63G38DnRntBYjm0OnxkBL/ZtR5gHd9i/XWmagY4nRo9FZBHRqQX7AL4/816TFD5sMxg4umfPntr+DR1iUCWO4ye2UQRj6BiGttXoFICqSvz+aLuMC1RHIeOFDkM4h6DGAsd07INoemBsx2h3iuAK+xHGF0Vgiecjg4YAEIGgK6zLDq9XrHlwh/MKLthcqT4dN26cbt9oT45OSKjeRYbSkaFW/AVnnvABOAnihDht2jRta4CDAAIwI6izHqAY/+O+dUCGkw5S6sgMYGfFzo0DtDGcCKq5cBDATo02aTiA4H3NAxbrgYVxQsHJGz1acfBB71eceMyDN2TvcLJCcIcrMfxvXQ4CPpSBHREHHrQ7QUNv87ZtyK5Zd8jAOoc3xIP1a9CJAsMF4OrN1SnYcMDBDW3v0P4Ff3PaMtsQsKBdFzJj6D2HixEcYM2rnpCBspWZRZuy7t27a+CCtj3YVnHffOw0BEII/pFhQLs5dBhCdts8wMA2Zd5JCB0vUM2Ekwe2d+xTXbp0sQjqbK0TMlzmJ1QEngjqsN1iG7DVrg/VpLhgMoeTjDH+HGBfwbaOnqf4rpDlxH6KqlNz2G+xX5tnyNHbEMvCy2ybfxYcS7DOCBbQuxxBrbNw3DD2AXyvyAThb2M8S3Icfgd8b9h+sZ1je8bFgvnFKLJPaJOKTB2avlj3YMZ2aJ0BR2BjjIWIxAAybrjwxkU8jrXYvozXYGgQbFsY2xBlY99ED3Uc3xD8YzvEMdl6EPyI3tdW2baYl4NgF8/FuQrnHleUKVNGz18YEgv7NwI8BIzmQyD5u1ihkVVpTkQxHrI7OGkZ7SQxxAOCdQR3yEYQEZFnsSqWiCINqlCRWUZ7F2SQcRWOYM+Rca2IiMh5zNgRUaRCtSc67aA6FdWoGGrBW+c1JiLydQzsiIiIiPwEO08QERER+QkGdkRERER+goEdERERkZ9gYEdERETkJxjYEREREfkJBnZEREREfoKBHREREZGfYGBHRERE5CcY2BERERGJf/g/WDRGSNzXGTwAAAAASUVORK5CYII=", + "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": "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M7YXfVExDitaA+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]]