Skip to content

Runtime observers

Dynlib ships a small analysis subsystem that runs alongside each Sim.run() invocation. Observer modules inject pre/post step hooks, carry workspace buffers, and optionally log traces into a shared Results object so you can compute diagnostics (Lyapunov exponents, spectra, convergence traces, etc.) without post-processing recorded trajectories.

Registering observers with Sim.run

  • Sim.run(..., observers=…) accepts:
  • An ObserverModule instance.
  • An Observer factory (callable with the signature (model, sim, record_interval) and the __observer_factory__ flag). Dynlib-built factories such as lyapunov_mle_observer set that flag so Sim can inject the compiled model automatically.
  • A sequence of ObserverModules. Sim wraps multiple modules into a CombinedObserver so they execute in a single pass.
  • record_interval is forwarded to factories; provide it at the Sim.run call if you want to sample traces at a coarser cadence than the step size.
  • All observers are validated against the current stepper (ObserverModule.validate_stepper) before the run begins; incompatible combinations (e.g., a fixed-step observer on an adaptive stepper) raise immediately.
  • CombinedObserver enforces unique keys, rejects observers that mutate state, and allows at most one variational integrator per step, so pick either a single module or a carefully constructed combination.
  • Because the runner can only emit one variational stepper per pass and observers share the same trace buffers/counters, you cannot mix multiple variational observers or let more than one observer mutate the model state in a single run. Each observer must also agree on the trace plan (same TracePlan or record_interval) so recorded traces stay aligned, and any observer requiring fast-path-incompatible features (event logs, accept/reject hooks, fixed-step enforcement) can force the run back to the wrapper path even if you requested the fast runner.

Core observer building blocks

  • ObserverModule carries:
  • requirements (ObserverRequirements) that declare needs such as fixed-step execution, Jacobian-vector products (need_jvp), dense Jacobians, event logs, or variational stepping hooks.
  • workspace_size, output_size, output_names, and optional trace metadata (TraceSpec), which together determine how much runtime storage the runner allocates.
  • hooks (ObserverHooks) whose pre_step/post_step callbacks run each integration step; these hooks receive the current t, dt, states, params, runtime workspace, analysis workspace, output buffers, and trace buffers.
  • TraceSpec describes recorded trace layout and requires a TracePlan when width > 0. For most observers you can pass a FixedTracePlan(record_interval=K) or simply rely on the record_interval you supplied to the factory.
  • Observers expose helper methods such as needs_trace, trace_stride, and trace_capacity(total_steps=...) so Sim can tune buffer sizes and detect overflow; build_observer_metadata(...) collects this information into the final Results payload.
  • If you intend to compile per-step hooks with Numba (fast-path runners), ObserverModule.resolve_hooks(jit=True, dtype=...) compiles them on demand, while observer_noop_hook() keeps runtimes type-stable when no hook is installed.

Reference Lyapunov observers

The builtin dynlib.runtime.observers package provides two observer factories:

  1. lyapunov_mle_observer(...)
  2. lyapunov_spectrum_observer(...)

Both factories auto-detect the required jvp and n_state from the provided model (or require you to pass them explicitly). They build variational hooks that either rely on the stepper’s combined variational step or manually call the tangent integrator, so:

  • Flow vs map mode: mode="flow" keeps denominators in time units, mode="map" sums iteration counts, and mode="auto" infers the right behavior from model.spec.kind.
  • Trace sampling: Specify record_interval (or trace_plan=FixedTracePlan(record_interval=K)) to capture convergence traces; without a trace plan the observer only updates its output registers.
  • Variational stepping: Flow-mode observers require a stepper whose caps.variational_stepping is enabled. prefer_variational_combined=True tries to reuse the stepper’s combined state+tangent integrator; otherwise the observer falls back to the tangent-only callback generated by stepper_spec.emit_tangent_step(...).
  • Analysis kind: Pass analysis_kind=1 or another integer to tag the module for downstream metadata or caching; the value migrates through build_observer_metadata and runner caches.

Factory usage is idiomatic:

from dynlib.runtime.observers import lyapunov_mle_observer, lyapunov_spectrum_observer

sim.run(
    N=5000,
    dt=1.0,
    record_interval=1,
    observers=[
        lyapunov_mle_observer(model=sim.model, record_interval=1),
        lyapunov_spectrum_observer(model=sim.model, k=1, record_interval=1),
    ],
)
res = sim.results()

The logistic-map demo in examples/analysis/lyapunov_logistic_map_demo.py shows this exact pattern, including how to read res.observers afterward.

Inspecting observer outputs

  • ResultsView.observers returns a dictionary of ObserverResult objects keyed by each observer’s key. Each ObserverResult exposes:
  • Mapping access (result["out"], result["trace"]) for backwards compatibility.
  • Auto-generated attribute access (result.log_growth, result.steps, etc.) derived from output_names and trace_names.
  • Discovery helpers (result.output_names, result.trace_names, list(result)).
  • Trace helpers (result.trace, result.trace_steps, result.trace_time) that align trace rows with step/time indices whenever the runner recorded them.
  • Example:
lyap = res.observers["lyapunov_mle"]
mle = lyap.mle          # final converged exponent
log_growth = lyap.log_growth
n_steps = int(lyap.steps)
trace = lyap["mle"]     # full convergence trace (record_interval spacing)
  • Runtime metadata survives post-processing via res.observer_metadata (or the raw Results.observer_metadata) so you can inspect workspace sizes, trace strides, or whether a trace overflowed.

Fast-path and practical notes

  • When observers are present, the runner switches to the analysis-aware variants (RunnerVariant.ANALYSIS / FASTPATH_ANALYSIS). CombinedObserver.supports_fastpath(...) gates whether a fast-path runner is compatible; for instance, observers requiring event logs, accept/reject hooks, or state mutation disable fast-path execution.
  • Observers without trace data still contribute output slots, so Sim.results().observers will return ObserverResults even if trace_plan was None.
  • If you craft custom observers, follow the same pattern: declare requirements, return an ObserverModule, and set workspace_size/output_names/trace_names so downstream code (runner caches, metadata builders) can align buffers automatically.

Use the observer infrastructure to monitor runtime diagnostics (Lyapunov exponents, spectra, growth rates) without extra post-processing and rely on ResultsView.observers to keep the diagnostics keyed and time-aligned with your simulation run.