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
ObserverModuleinstance. - An Observer factory (callable with the signature
(model, sim, record_interval)and the__observer_factory__flag). Dynlib-built factories such aslyapunov_mle_observerset that flag soSimcan inject the compiled model automatically. - A sequence of
ObserverModules.Simwraps multiple modules into aCombinedObserverso they execute in a single pass. record_intervalis forwarded to factories; provide it at theSim.runcall 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. CombinedObserverenforces 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
TracePlanorrecord_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
ObserverModulecarries: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 optionaltracemetadata (TraceSpec), which together determine how much runtime storage the runner allocates.hooks(ObserverHooks) whosepre_step/post_stepcallbacks run each integration step; these hooks receive the currentt,dt, states, params, runtime workspace, analysis workspace, output buffers, and trace buffers.TraceSpecdescribes recorded trace layout and requires aTracePlanwhen width > 0. For most observers you can pass aFixedTracePlan(record_interval=K)or simply rely on therecord_intervalyou supplied to the factory.- Observers expose helper methods such as
needs_trace,trace_stride, andtrace_capacity(total_steps=...)soSimcan tune buffer sizes and detect overflow;build_observer_metadata(...)collects this information into the finalResultspayload. - If you intend to compile per-step hooks with Numba (fast-path runners),
ObserverModule.resolve_hooks(jit=True, dtype=...)compiles them on demand, whileobserver_noop_hook()keeps runtimes type-stable when no hook is installed.
Reference Lyapunov observers
The builtin dynlib.runtime.observers package provides two observer factories:
lyapunov_mle_observer(...)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, andmode="auto"infers the right behavior frommodel.spec.kind. - Trace sampling: Specify
record_interval(ortrace_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_steppingis enabled.prefer_variational_combined=Truetries to reuse the stepper’s combined state+tangent integrator; otherwise the observer falls back to the tangent-only callback generated bystepper_spec.emit_tangent_step(...). - Analysis kind: Pass
analysis_kind=1or another integer to tag the module for downstream metadata or caching; the value migrates throughbuild_observer_metadataand 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.observersreturns a dictionary ofObserverResultobjects keyed by each observer’skey. EachObserverResultexposes:- Mapping access (
result["out"],result["trace"]) for backwards compatibility. - Auto-generated attribute access (
result.log_growth,result.steps, etc.) derived fromoutput_namesandtrace_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 rawResults.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
tracedata still contribute output slots, soSim.results().observerswill returnObserverResults even iftrace_planwasNone. - If you craft custom observers, follow the same pattern: declare requirements, return an
ObserverModule, and setworkspace_size/output_names/trace_namesso 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.