Architecture and API layers

This page is the maintainer-facing contract for how KoopmanGraph is structured. It exists so contributors and agents do not reintroduce style drift across releases. Read it when changing package layout, exports, device handling, or shared helpers.

Design philosophy

KoopmanGraph combines four complementary styles:

  1. Composition — users assemble an encoder, decoder, and Koopman operator (discrete or continuous) into GraphKoopmanModel. Tutorials typically omit koopman= and let the model factory-build a built-in operator from dynamics_mode / koopman_parameterization strings. Advanced callers may inject a custom KoopmanOperatorContract nn.Module via koopman=... (mutually exclusive with non-default factory kwargs). Custom operators are not checkpoint-round-trippable; see serialization notes below.

  2. Sklearn-like façade — the model exposes fit / predict / evaluate / save / load as the primary workflow.

  3. Functional training and evaluation — lower-level helpers in koopman_graph.training and koopman_graph.metrics implement losses, epoch loops, and forecast metrics without requiring subclassing. The multi-epoch fit loop lives in run_fit_loop(); fit() validates sequences / controls and delegates to that helper.

  4. String-mode configuration — behavioral modes are selected with explicit strings (for example dynamics_mode="discrete"|"continuous" and koopman_parameterization="dense"|"structural"|"odo") rather than parallel class hierarchies.

Prefer extending these patterns over introducing a fifth style (global device managers, deep inheritance trees, or underscore helpers used as a second public API).

Package layout

Target style is capability packages at one level (similar to torch_geometric.nn or sklearn.linear_model): group related peers under a single shallow package when growth warrants it. Do not over-nest “because PyG does,” invent one folder per tiny module, or introduce a fifth architectural style beyond composition / sklearn façade / functional helpers / string-mode configuration.

When to nest

Convert a flat module into a capability package when any of these hold:

  • Multi-concern grab-bag — one file mixes several cohesive jobs (orchestration + schedules + history types, containers + splits + sampling, spectrum + anomaly + similarity) and is already ~1k+ lines of maintainable surface.

  • Peer implementations — two or more sibling implementations share a contract (discrete/continuous operators; encoder/decoder/GNN primitives; classical DMD-family baselines).

  • Room to grow peers — a new classical method or GNN variant should land as a sibling module, not another flat mega-file.

Phase 8 capability packages: training/, data/, operators/, nn/, analysis/, and baselines/ (all landed).

koopman_graph.training package layout:

  • historyLossWeights, FitHistory, TrainingLossBreakdown, fit input type aliases

  • schedules — constant / ramp loss-weight schedules

  • deviceresolve_device, sequence_to_device

  • objectives — training-side composition over top-level koopman_graph.losses (left flat; no cycle benefit from nesting)

  • loop — epoch helpers, input resolution, run_fit_loop

koopman_graph.data package layout (kept separate from koopman_graph.datasets):

  • containersGraphSnapshotSequence, MultiTrajectory, as_multi_trajectory, resolve_sequence, resolve_pair_delta_t

  • samplingWindowSampler

  • splitsTemporalSplit, temporal_split

  • rolloutresolve_rollout_start_indices and related type aliases

koopman_graph.operators package layout (peer discrete/continuous pair):

  • contractKoopmanOperatorContract, Parameterization, DynamicsMode, StabilityCertificate, shared structural helpers

  • discreteKoopmanOperator

  • continuousContinuousKoopmanOperator and Van Loan helpers

Prefer from koopman_graph.operators import (or the root façade for public operator classes). Former root modules koopman_graph.operator and koopman_graph.continuous were removed in v0.3.0.

koopman_graph.nn package layout (peer encoder / decoder / GNN primitives; PyG-style nn capability package, no conv/ subtree):

Prefer from koopman_graph.nn import (or the root façade for public classes). Former root modules koopman_graph.encoder, koopman_graph.decoder, and koopman_graph.gnn were removed in v0.3.0. Encoder and decoder remain peers: both import from nn.gnn; neither imports the other.

koopman_graph.analysis package layout (spectrum / similarity / anomaly / plotting):

  • spectrumcompute_spectrum, compute_generator_spectrum, discrete_spectrum_at_delta_t, decode_mode_shapes

  • similarityspectrum_distance, koopman_std, resolve_spectrum, dynamical_similarity

  • anomalyAnomalyDetectionResult, calibrate_anomaly_threshold, detect_anomaly

  • plottingplot_spectrum (discrete unit-disk / data-zoom complex-plane figures; Matplotlib call-site import)

koopman_graph.spectrum_types remains a top-level neutral leaf so koopman_graph.protocols never imports heavy analysis code. KoopmanSpectrum is re-exported from koopman_graph.analysis and the package root for the public API.

koopman_graph.baselines package layout (peer DMD-family methods behind ClassicalBaseline):

Deep imports continue to use from koopman_graph.training import , from koopman_graph.data import , from koopman_graph.analysis import , and from koopman_graph.baselines import via package __init__ re-exports (same-named package compatibility).

When to stay flat

Keep a single module (or leave an existing small package alone) when:

  • The module is small and single-purpose (for example metrics, losses, env, adaptation, observables, serialization, protocols, graph_utils, spectrum_types).

  • koopman_graph.datasets is already the correct benchmark/load subpackage — do not merge it into data/ (data structures vs datasets, same distinction as sklearn/PyG).

  • Nesting would only rename a path without clarifying capability boundaries.

Explicit non-goals unless a later audit shows multi-peer growth: nesting env, adaptation, observables, metrics, serialization, protocols, or graph_utils.

model.py stays at the package root

GraphKoopmanModel and koopman_graph.model remain top-level. Do not split the façade across packages or bury the primary workflow class under nn/, training/, or similar.

No deep trees

One nesting level is enough (koopman_graph.nn, koopman_graph.operators). Do not add deep trees such as nn/conv/... or operators/discrete/... unless a future audit shows a genuine multi-layer library surface. Prefer peer modules inside the capability package.

Three-layer API regardless of folders

Moving files must not change the layering contract above:

  1. Public façade — symbols in package __all__ / from koopman_graph import

  2. Power-user modules — importable packages or modules documented here but not newly promoted into __all__

  3. Private helpers — leading-_ names, same-module only

Folders are an organization tool; they do not create a fourth API tier.

Compatibility contract

When splitting or nesting modules:

  • Root façade is stablefrom koopman_graph import symbols and package __all__ must keep the same public names and semantics.

  • Power-user deep imports — for each move, choose one and document it in this page (and a short migration note if paths change):

    • Same-named packagekoopman_graph.training, data, analysis, baselines keep working via package __init__ re-exports when the old flat module is replaced by a package of the same name; or

    • Migrate in-repo — update every docs/tests/notebook deep import to the new path and record the rename here. Do not add long-lived root shim modules for renamed paths (operatoroperators, encodernn.encoder, …). v0.3.0 removed those temporary root shims.

Do not leave half-migrated import paths. Serialization type strings and checkpoint reconstruct rules stay intact unless a task explicitly bumps FORMAT_VERSION.

API layers

The package has three intentional layers.

Public façade

Stable, supported entry points re-exported from koopman_graph (see __all__). Prefer these in tutorials, notebooks, and application code.

v0.3.0 uses a thin root façade: keep the core encode → evolve → decode / fit / evaluate workflow at the package root; import specialized helpers from capability modules. Demotions are a hard cut (no root deprecation aliases), consistent with the shim-removal policy above.

Keep in koopman_graph.__all__ (core workflow):

Dataset generators remain via koopman_graph.datasets (not root __all__ members; use from koopman_graph.datasets import ).

Demote to module imports (still public and documented; not in root __all__):

from koopman_graph import GNNDecoder, GNNEncoder, GraphKoopmanModel
from koopman_graph.analysis import dynamical_similarity
from koopman_graph.data import as_multi_trajectory
from koopman_graph.metrics import rmse
from koopman_graph.observables import graph_laplacian_features

Power-user modules

Importable modules that are documented and useful for advanced workflows, but not advertised as a stable public contract. They may change without a major version bump. Do not re-export new power-user symbols from koopman_graph __all__ unless a blueprint task explicitly promotes them.

Examples:

from koopman_graph.training import resolve_device, train_one_epoch
from koopman_graph.datasets.dynamics import laplacian_diffusion_rollout
from koopman_graph.protocols import (
    ForecastModel,
    SpectrumProvider,
    TrainableKoopmanModel,
    UncontrolledForecastModel,
)

Phase 8 note: shared utilities live in koopman_graph.graph_utils — documented, importable as koopman_graph.graph_utils, and not listed in __all__. Prefer this module over importing leading-underscore helpers from peer packages. Typing Protocols (ForecastModel, SpectrumProvider, UncontrolledForecastModel, TrainableKoopmanModel, KoopmanOperatorContract) and neutral value types (koopman_graph.spectrum_types) stay power-user and are likewise omitted from __all__.

Dependency direction. Prefer types/protocols data / graph_utils feature modules façade:

Encode API

Prefer encode() for latent lifting. It accepts a Data snapshot or explicit (x, edge_index, edge_weight) tensors and applies hybrid physics observables when configured. Training helpers require encode via TrainableKoopmanModel (no encoder-only fallback).

Private helpers

Names that begin with a leading underscore (_helper, _validate_…) are not a public contract. They are implementation details of their enclosing module.

Rules:

  • Call private helpers only from the same module (or tightly coupled tests).

  • Do not import leading-_ symbols across modules. Cross-module reuse must go through a documented internal (power-user) module without a leading underscore (see koopman_graph.graph_utils).

  • Autodoc may still render some private members for maintainers; that does not make them supported API.

Duck-typed training vs hard-typed env / checkpoints

Two coupling styles coexist on purpose:

  • Duck-typed training and evaluationtrain_one_epoch(), rollout_sequence_loss() / rollout_multi_start_loss(), other loss helpers, and evaluate_forecast() annotate against TrainableKoopmanModel. They require encode, predict, encoder / koopman / decoder, time_step, dynamics_mode, control_dim, resolve_delta_t, and the nn.Module train/eval/parameters façade (no encoder-only fallback). Classical baselines satisfy only ForecastModel and are not training targets for these helpers. TrainableKoopmanModel disables runtime isinstance (inherited @runtime_checkable would be unreliable because submodule attributes live in nn.Module._modules). Use static typing or structural smoke tests instead.

  • Hard-typed Gymnasium and serializationGraphKoopmanEnv and checkpoint reconstruct in koopman_graph.serialization require GraphKoopmanModel specifically. They freeze or rebuild concrete encoder/decoder classes and architecture config fields that a structural Protocol cannot safely express. Env is not required to accept arbitrary Protocol implementers.

Shared graph utilities

koopman_graph.graph_utils consolidates graph-input resolution, snapshot device transfer, discrete/continuous latent propagation, and the shared autoregressive rollout loop:

  • snapshot_edge_weight, resolve_graph_inputs, resolve_edge_index, resolve_edge_weight, snapshot_to_device

  • propagate_latent / inverse_propagate_latent

  • advance_and_decode, autoregressive_latent_rollout, hold_last_topology_at, snapshot_topology_at

Continuous delta_t policy: pass an explicit delta_t when known. When delta_t is None, resolve_delta_t() (used by propagate_latent / inverse_propagate_latent) applies default_delta_t. Model-backed callers — including resolve_delta_t(), training/loss pair helpers, and GraphKoopmanEnv — pass time_step. Bare helpers and forward() soft-default to 1.0 outside a model context. Functional pair losses still prefer timestamp intervals from resolve_pair_delta_t() when present.

Autoregressive rollouts share one encode → advance → decode primitive (autoregressive_latent_rollout()). Topology policy is intentional and documented at the call site:

  • Hold-last (inference)predict / _rollout via hold_last_topology_at (optional future_topologies schedule).

  • Teacher targets (training)rollout_sequence_loss via snapshot_topology_at on observed target snapshots.

GraphKoopmanEnv.step uses the same one-step advance_and_decode helper.

Topology capability matrix

Dynamic topology is first-class in the data container and neural model path, but not every consumer supports it. Callers must not assume silent freeze/flatten behavior:

Surface

Dynamic topology (is_dynamic_topology=True)

GraphSnapshotSequence

Supported via allow_dynamic_topology=True / from_dynamic_arrays()

Model predict / inference rollout

Supported: hold-last topology (optional future_topologies schedule)

Training losses (rollout_sequence_loss)

Supported: teacher-target topology from observed snapshots

GraphKoopmanEnv

Rejected at construction (episode topology frozen from reset)

Classical baselines (DMD / EDMD / DMDc)

Rejected at fit (flatten states; freeze initial edges on predict)

Control layout capability matrix

GraphSnapshotSequence stores either global (T, control_dim) or per-node (T, N, control_dim) controls. Consumers must not assume the same physics for both layouts:

Surface

Supported control layouts

GraphSnapshotSequence

Global (T, C) and per-node (T, N, C)

GraphKoopmanModel / operators

Global broadcast and per-node row matching (u shape (C,) or (N, C) → latent u @ B)

RecursiveKoopmanAdapter

Same as neural: global or per-node rows aligned to latent samples

GraphKoopmanEnv

Global only (C,) actions; per-node action spaces are not supported

DMDcBaseline

Global only; per-node (3-D) control_inputs are rejected at fit (no silent flatten into a joint control vector)

Shared operator contract

KoopmanOperator and ContinuousKoopmanOperator share one Parameterization Literal and implement KoopmanOperatorContract:

  • matrix — assembled K (discrete) or L (continuous)

  • advance / inverse_advance — forward and inverse latent steps

  • bound_metric — cheap soft/structural monitoring bound (diagonal-factor bound for "odo"; closed-form certified bound for structural modes; equals the true spectrum for "dense")

Domain-specific names (K / L, forward / inverse_step, spectral_radius / max_real_part) remain as thin aliases. spectral_radius and max_real_part always report the true spectrum of assembled K / L via eigvals. Prefer bound_metric for ODO factor monitoring and structural certificates. Discrete ODO still satisfies ρ(K) bound_metric via the operator 2-norm; continuous ODO does not guarantee Hurwitz stability from the factor bound alone. See the quickstart stability section. KoopmanPropagator in koopman_graph.graph_utils is an alias of the Protocol and is the single typing surface for losses and adaptation.

Dynamics mode and stability-bound vocabulary

DynamicsMode is the single Literal["discrete", "continuous"] type, defined next to Parameterization in koopman_graph.operators and re-exported from koopman_graph.protocols. GraphKoopmanModel imports it from protocols; AdaptationMode is an alias of the same type for the RLS adapter API. Do not redefine the literals in call sites.

Factory / checkpoint construction uses one neutral stability knob. Domain operator attributes keep their physics-accurate names; use resolve_factory_stability_bound() when reading the bound for serialization or factory reconstruction:

Surface

Discrete

Continuous

Factory / checkpoint key

koopman_max_spectral_radius

koopman_max_spectral_radius

Built-in operator attribute

max_spectral_radius

max_real_eigenvalue

Optional operator injection

GraphKoopmanModel accepts an optional koopman= argument satisfying KoopmanOperatorContract (must be an nn.Module). Use this to compose a pre-built or custom propagator without editing the model class. String-mode factory construction remains the default when koopman is omitted.

Enforced capability tier

  • Protocol-complete (any contract nn.Module): latent propagation (propagate_latent() / inverse_propagate_latent() always call unified advance / inverse_advance with a resolved delta_t; discrete operators ignore the interval), encode/decode training and predict, spectrum() via koopman.matrix, and eigenvalue regularization via eigvals(matrix) for dense / odo and bound_metric for structural modes, plus the model’s dynamics_mode. Discrete dense backward-consistency training optionally reuses a built-in dense_inverse_matrix() when present; operators without that helper still invert via inverse_advance (inverse_matrix=None).

  • Built-in-only: checkpoint save / load round-trips KoopmanOperator and ContinuousKoopmanOperator only; RLS RecursiveKoopmanAdapter seed and write-back likewise. Custom injected operators raise on save; reconstruct with koopman=... after loading encoder/decoder state separately if needed.

Factory and dimension rules:

  • Factory kwargs (koopman_init_mode, koopman_init_scale, koopman_parameterization, koopman_max_spectral_radius) must stay at their defaults when injecting.

  • latent_dim / control_dim must match the injected operator.

  • Built-in KoopmanOperator requires dynamics_mode="discrete"; ContinuousKoopmanOperator requires "continuous". For custom operators, set dynamics_mode to match the operator’s semantics (spectrum and eigenvalue hinge follow the model flag; propagation always supplies delta_t).

from koopman_graph import GNNDecoder, GNNEncoder, GraphKoopmanModel
from koopman_graph.operators import KoopmanOperator

operator = KoopmanOperator(latent_dim=64, parameterization="odo")
model = GraphKoopmanModel(
    encoder=encoder,
    decoder=decoder,
    latent_dim=64,
    time_step=0.1,
    koopman=operator,
)
from koopman_graph.graph_utils import (
    autoregressive_latent_rollout,
    propagate_latent,
    resolve_graph_inputs,
)

Optional dependencies and result types

Optional extras

Optional dependencies must not break importing koopman_graph. Prefer fail-at-call with an ImportError that names the install extra or package:

  • Soft import + call guard (Gymnasium / ``[rl]``)koopman_graph.env soft-imports gymnasium so GraphKoopmanEnv can subclass gym.Env when present. Construction calls _require_gymnasium() and raises with pip install koopman-graph[rl] guidance when the extra is missing.

  • Call-site import (e.g. ``h5py`` for METR-LA) — import inside the function that needs the dependency and re-raise ImportError with install guidance (see koopman_graph.datasets.metr_la).

Do not fail at import of core modules for optional extras.

Public result types

Value / result objects (snapshots returned to callers or passed between pure helpers) should be @dataclass(frozen=True) with attribute access (examples: EvaluationResult, StabilityCertificate, FitHistory, AdaptationStepResult, and the module-local TrainingLossBreakdown). Prefer optional fields with None defaults over mapping/TypedDict styles for new APIs. Collection-valued fields should be immutable sequences (tuple) so callers cannot mutate nested contents in place.

Stateful workflow objects (samplers, optimizers, live training loops, accumulators that update in place) remain plain / mutable classes. Do not freeze types whose primary job is mutation across steps.

GraphSnapshotSequence follows the same collection rule: snapshots is a tuple[Data, ...] after construction (no .append / in-place replace). Individual Data objects are borrowed, not cloned — in-place mutation of node features or topology remains possible. Clone explicitly when isolation is required. MultiTrajectory stores its trajectories as a frozen tuple of sequences.

TrainingLossBreakdown is a frozen internal snapshot (batch/epoch loss terms), not a package __all__ export. Epoch aggregation uses a local dict accumulator, then constructs a new breakdown.

Multi-trajectory fit input

fit() accepts one trajectory or several trajectories of the same system. Prefer the explicit wrapper:

from koopman_graph import MultiTrajectory
from koopman_graph.data import as_multi_trajectory

model.fit(MultiTrajectory((trajectory_a, trajectory_b)), epochs=50)
# equivalent helper
model.fit(as_multi_trajectory(trajectory_a, trajectory_b), epochs=50)

Discrimination rules (used by resolve_training_sequences()):

  • MultiTrajectory — multi-trajectory (required)

  • GraphSnapshotSequence — single trajectory

  • non-empty list / tuple of only Data — single trajectory of snapshots

  • bare list / tuple of GraphSnapshotSequenceTypeError (wrap in MultiTrajectory)

  • empty list or mixed GraphSnapshotSequence / DataValueError

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.

Device and tensor conventions

There is no global device manager. Device placement is local and explicit.

Who moves tensors

  • ``GraphKoopmanModel.fit`` validates inputs then delegates to run_fit_loop(), which resolves the training device with resolve_device() (explicit device= argument, else the model’s parameter device, else CPU), moves the model with to(device), and copies training / validation sequences via sequence_to_device().

  • ``GraphKoopmanEnv`` takes an explicit device (or falls back to the wrapped model’s parameter device) and keeps graph / latent state there for stepping.

  • Callers of ``predict`` / ``evaluate`` are responsible for placing the model and inputs consistently. These APIs do not silently relocate the model the way fit does; keep inputs on the same device as the model parameters.

Façade vs functional training ownership

  • Model façadefit() owns user-facing validation (epoch bounds, control layouts via _validate_sequence_controls, multi-trajectory resolve, snapshot-length checks) and keeps the public signature / FitHistory return type.

  • Functional looprun_fit_loop() owns device placement, optimizer / scheduler setup, epoch iteration (train_one_epoch / train_windowed_epoch / eval_one_epoch), early stopping, best-weight tracking, optional checkpoint writes, and history assembly. Prefer extending that helper over growing the model class.

Online adaptation

RecursiveKoopmanAdapter keeps RLS regression state on CPU. Operator matrices are detached and copied to CPU when the adapter is constructed from a live operator. apply_to() copies adapted weights back onto the target operator’s device and dtype.

Classical baselines and datasets

DMD-family baselines (DMDBaseline, EDMDBaseline, DMDcBaseline) share ClassicalBaseline for time_step / rank, fitted metadata, and _check_fitted scaffolding. They remain siblings under that ABC (DMDc does not subclass DMD) and satisfy ForecastModel structurally. EDMDBaseline stores a least-squares reconstruction_matrix (not a GNN decoder).

Dataset / benchmark generators and these baselines default to CPU float32 tensors unless a docstring or parameter explicitly says otherwise. Move results to a training device only when feeding a GPU model. Sequences with is_dynamic_topology=True are rejected at fit (see Topology capability matrix). Per-node (3-D) controls are rejected by DMDcBaseline (see Control layout capability matrix); neural and adaptation paths keep per-node row matching.

ForecastModel call-site matrix

ForecastModel is a loose façade (*args / **kwargs). Peers share method names, not interchangeable signatures. Prefer UncontrolledForecastModel (and accepts_uncontrolled_data_predict()) when code must call predict(data, steps) portably.

Implementer

predict initial

predict extras

spectrum kwargs

fit returns

GraphKoopmanModel

Tensor or Data

Optional edge_*, controls, future_topologies; controls required when control_dim > 0

Optional delta_t in continuous mode

FitHistory

DMDBaseline

Data only

None (uncontrolled peer)

None

self (sklearn chaining)

EDMDBaseline

Data only

None (uncontrolled peer)

None

self (sklearn chaining)

DMDcBaseline

Data only

Required controls (global (C,) only; not an uncontrolled peer)

None

self (sklearn chaining)

``fit`` return divergence. Classical baselines return self for chaining (baseline.fit(seq).predict(...)). fit() returns a frozen FitHistory with per-epoch losses and early-stop metadata. Do not assume a portable fit return type across ForecastModel peers; this is intentional for v0.3.0 (document rather than unify).

Spectral similarity vs mode shapes

dynamical_similarity() compares spectra, not concrete model classes. Documented call patterns:

  • Precomputed spectra — dynamical_similarity(spectrum_a, spectrum_b)

  • Classical baselines — dynamical_similarity(dmd_a, dmd_b)

  • Mixed peers — dynamical_similarity(dmd, neural_model)

  • Continuous neural horizon — dynamical_similarity(model_a, model_b, delta_t=0.1) (delta_t is forwarded only when spectrum accepts it; ignored for precomputed KoopmanSpectrum values and classical baselines)

resolve_spectrum() is the shared resolver. decode_mode_shapes() stays hard-typed to GraphKoopmanModel because it needs encode / decode and a GNN decoder; do not widen it to SpectrumProvider.

Interchangeable under the uncontrolled peer contract: DMDBaseline, EDMDBaseline, and GraphKoopmanModel when called as predict(data, steps).

Dataset factory idioms

koopman_graph.datasets uses two complementary factory styles:

  • Simulated dynamicsBenchmark.generate(...) synthesizes a GraphSnapshotSequence (synthetic path/ring, grid, IEEE 118 voltage/load diffusion). Prefer these classmethods in tutorials and application code.

  • Real telemetryBenchmark.load_topology / load_sequence (METR-LA) read cached downloaded artifacts. There is no generate because the time series is observed, not simulated.

Seed defaults. All simulated generate methods default seed=None (unseeded RNG). Pass an explicit integer for reproducible runs; tutorial notebooks and quickstart examples use seed=42.

Topology payloads. IEEE 118 and METR-LA load_topology return a frozen TopologyPayload (attribute access preferred). Mapping-style access (payload["edge_index"]) remains supported for existing notebooks. On-disk caches stay plain dict; the typed payload is the public Python return type.

IEEE 118 also exposes a module-level load_topology free function used by download scripts; prefer IEEE118DynamicBenchmark.load_topology.

Generation validators. Shared physical parameters for Laplacian-diffusion benchmarks (synthetic, grid, IEEE 118) route through validate_diffusion_generation_params() (diffusion_rate [0, 1], decay_rate > 0, noise_std 0, optional initial_state). Anisotropic advection uses the same helper for noise / initial state, but its self-retention factor is validated separately by validate_advection_decay_rate() (decay_rate (0, 1)) — same parameter name, stricter open interval. Benchmark-specific knobs (load ramp, grid size, neighbor weights) stay local.

Laplacian helpers: sparse vs dense

The symmetric normalized Laplacian L_sym = I - D^{-1/2} A D^{-1/2} has one shared weight core in koopman_graph.graph_utils (symmetric_normalized_adjacency_edge_weights()):

Keep both surfaces: they are numerically aligned on the same L_sym but serve different performance and API roles.

Hybrid physics checkpoint schema

When hybrid observables are enabled, checkpoint config.physics owns dim, preset, and position. build_model_config() writes position from model.physics_position; reconstruct_model() reads and validates it via resolve_physics_position() (missing position defaults to "prepend"). Only "prepend" is supported today — unsupported values raise on load so a future "append" mode cannot silently mis-restore. No FORMAT_VERSION bump was required: the field was already emitted in v2.

v0.3.0 architectural consistency outcomes

Phase 8 standardized style and release quality without bumping the package beyond 0.3.0 or FORMAT_VERSION 2. Outcomes folded into the first public v0.3.0 cut:

Highest-impact API remediations (second style audit)

  1. Shared rolloutpredict / training losses / the RL env share autoregressive_latent_rollout() (and one-step advance_and_decode), with hold-last vs teacher-target topology policies documented above.

  2. ``ForecastModel`` call sites — loose Protocol for method presence; UncontrolledForecastModel for portable predict(data, steps) among autonomous peers (see call-site matrix).

  3. Optional operator injectionkoopman= composes a custom KoopmanOperatorContract nn.Module with Protocol-complete propagate / spectrum / eigenvalue paths; RLS and checkpoints remain built-in-only (see Optional operator injection).

  4. Classical baseline scaffoldingClassicalBaseline ABC; EDMD stores reconstruction_matrix (not a GNN decoder).

  5. Frozen public result typesFitHistory, AdaptationStepResult, and related dataclasses use frozen=True with tuple series; TrainingLossBreakdown is a frozen internal snapshot (not package-exported).

Contract and boundary follow-ups

  • Honest continuous/discrete dispatch for injected operators; sequence immutability; topology and control-layout capability guards; shared GNN primitives; thin fit orchestration; spectrum-based dynamical similarity; shared dataset validators; centralized dynamics/stability vocabulary; hybrid physics.position round-trip; loss-breakdown hygiene.

Tutorial claim↔result integrity

  • Notebooks 02 / 03 / 06 / 14 / 15 remediations so scientific claims match stored outputs (history-constrained Vm scope, METR-LA baseline protocol, epidemic spectrum wording, physics-informed evidence scope, RL takeaway metrics).

Capability packages and thin façade

  • Layout policy plus training/, data/, operators/, nn/, analysis/, and baselines/ capability packages; deep-import shim hard cut; thin root __all__ with metrics / analysis / data / adaptation / observables secondaries demoted to capability imports.

Analysis UX and release quality

  • plot_spectrum() with limits="unit_disk" / "data" (capability-module import only).

  • Enforced pytest coverage gate of 90% (fail_under / CI --cov-fail-under); branch-aware suite remains well above the floor.