An agronomist watching a crop-water-stress index through the season,
and suspecting it responds to a slow-moving climate driver, asks a
question that is subtly different from an ordinary correlation: does the
future of the index actually carry information about a hidden climate
state, how far ahead could that give a decision-relevant warning, and
can the answer be trusted, or does the read-out itself need to say when
it does not know? aci() answers this by reading a
divergence off the two passes kalmix already provides: the
Kalman filter and the Rauch-Tung-Striebel smoother of Getting
started: state-space models with kalmix. Assimilative causal
inference (Andreou, Chen and Bollt, 2026) casts causal inference as a
Bayesian data-assimilation inverse problem: a hidden state is a cause of
an observed effect at time \(t\)
exactly when incorporating the future of the effect sharpens
the estimate of the state at \(t\).
aci() runs the
filter and smoother over an ssm and reads the causal
information series and decision lead-time off the two passes, returning
an aci_fit. causal_information_rate()
computes the lead-time alone, and is what aci() calls
internally. innovation_diagnostics()
is the model-adequacy battery aci() grounds its verdict
through: a Ljung-Box whiteness test, a scale test on the total
normalised innovation, and a Jarque-Bera normality test, combined
conservatively.
The filter estimates the state from the past alone, giving \(N(x_{t\mid t}, P_{t\mid t})\); the smoother adds the future, giving \(N(x_{t\mid n}, P_{t\mid n})\). The causal information the measurement carries about the state at \(t\) is the relative entropy (Kullback-Leibler divergence) of the smoother from the filter,
\[I_t = D_{\mathrm{KL}}\!\left( N(x_{t\mid n}, P_{t\mid n}) \,\Vert\, N(x_{t\mid t}, P_{t\mid t}) \right).\]
For two Gaussians this is a closed form, which kalmix
computes natively. Time averaged, and integrated over an expanding
future window, the divergence yields a threshold-free causal
information rate, a decision lead-time in the time units of the
series.
The cleanest way to trust a causal read-out is to run it on a system
whose truth is controlled. We simulate a synthetic series from a
noise-sustained recharge oscillator (Jin, 1997), the canonical
conceptual model of the El Nino-Southern Oscillation (ENSO). The hidden
state \((T, h)\) is a
sea-surface-temperature anomaly \(T\)
and a thermocline-depth anomaly \(h\);
their coupled, weakly damped dynamics produce an irregular oscillation
of roughly four years. We observe only a scalar that warm \(T\) drives through a coupling
kappa, standing in for a crop-water-stress index that rises
in an El Nino year. When kappa is zero the observation
tells us nothing about the hidden cause.
simulate_enso <- function(n = 600, kappa = 0.6, dt = 1 / 12, obs_sd = 0.5) {
drift <- matrix(c(0, 1.6, -1.6, -0.2), nrow = 2, byrow = TRUE)
amat <- diag(2) + drift * dt
z <- matrix(0, nrow = n, ncol = 2)
x <- numeric(n)
for (t in 2:n) {
z[t, ] <- as.numeric(amat %*% z[t - 1, ]) +
c(rnorm(1, sd = 0.35 * sqrt(dt)), rnorm(1, sd = 0.25 * sqrt(dt)))
x[t] <- kappa * z[t, 1] + rnorm(1, sd = obs_sd)
}
list(x = x, z = z, dt = dt, amat = amat)
}
sim <- simulate_enso(kappa = 0.6)We give aci() a state-space model matching the
data-generating process. The observation operator maps the first hidden
component (the SST anomaly) to the observed series through the
coupling:
model <- ssm(
transition = sim$amat,
observation = matrix(c(0.6, 0), nrow = 1),
state_cov = diag(c(0.35^2 * sim$dt, 0.25^2 * sim$dt)),
obs_cov = 0.5^2,
init_state = c(0, 0),
init_cov = diag(c(1, 1))
)
fit <- aci(model, sim$x, dt = sim$dt)
fit
#> <aci_fit>: assimilative causal inference (600 steps)
#> mean causal information : 0.6204 nats
#> decision lead-time : 0.527 time units
#> grounding : ungrounded (mechanism_unverified)
recovery_cor <- cor(fit@smoother@smoothed_mean[, 1], sim$z[, 1])Two things to check against the known truth. First, the smoother recovers the hidden SST anomaly from the observed series alone, the inverse-problem essence, at a correlation of 0.983 with the simulated truth. Second, the causal information series should be positive where the future of the series is informative about the hidden state.
years <- (seq_along(sim$x) - 1) * sim$dt
plot_data <- rbind(
data.frame(year = years, value = sim$z[, 1], series = "truth (simulated)",
panel = "Hidden ENSO state"),
data.frame(year = years, value = fit@smoother@smoothed_mean[, 1],
series = "smoother estimate", panel = "Hidden ENSO state"),
data.frame(year = years, value = fit@causal_information,
series = "causal information",
panel = "Causal information I_t (nats)")
)
panel_levels <- c("Hidden ENSO state", "Causal information I_t (nats)")
plot_data$panel <- factor(plot_data$panel, levels = panel_levels)
ggplot2::ggplot(plot_data, ggplot2::aes(x = year, y = value, colour = series)) +
ggplot2::geom_line(linewidth = 0.5) +
ggplot2::facet_wrap(~panel, ncol = 1, scales = "free_y") +
ggplot2::scale_colour_viridis_d(name = NULL, end = 0.8) +
ggplot2::labs(
x = "year", y = NULL,
title = "Recovered state and causal information, coupled system"
) +
ggplot2::theme_minimal() +
ggplot2::theme(legend.position = "bottom")Top: the hidden ENSO state recovered from the observed index alone, against the truth used to simulate it. Bottom: the per-step causal information series that recovery is built from.
The smoother trace tracks the truth closely in the top panel although the model never observes the state directly, and the causal-information trace in the bottom panel rises and falls with the oscillation rather than sitting flat at zero, which is the discriminating signature the null case below is checked against.
Under the null of no coupling between the hidden state and the observation, the future of the series adds nothing the filter did not already have, so the causal information collapses to zero and the lead-time with it:
sim_null <- simulate_enso(kappa = 0)
model_null <- ssm(
transition = sim_null$amat,
observation = matrix(c(1e-6, 0), nrow = 1),
state_cov = diag(c(0.35^2 * sim_null$dt, 0.25^2 * sim_null$dt)),
obs_cov = 0.5^2,
init_state = c(0, 0),
init_cov = diag(c(1, 1))
)
fit_null <- aci(model_null, sim_null$x, dt = sim_null$dt)| System | Mean causal information (nats) | Lead-time (years) |
|---|---|---|
| coupled (kappa = 0.6) | 0.6204 | 0.527 |
| null (kappa = 0) | 0.0000 | 0.000 |
The metric aci() reads off the two passes is the
relative entropy between the smoother and filter Gaussians at each step.
This is canonical Gaussian arithmetic, so kalmix implements
it natively and stays self-contained. Where the kernR
package is installed, its relative_entropy() provides an
independent check on the value: the package’s test suite asserts the two
agree and skips the comparison when kernR is absent, so
kalmix builds and checks cleanly either way.
ACI reads its causal verdict off a state-space model, so the verdict
is only as good as the model. A model that cannot even describe the
observed series cannot support a causal claim about it.
aci() therefore grounds every read-out through
innovation_diagnostics(), run on the standardised
one-step-ahead innovations. When the innovations reject the model it is
declared inadequate and the verdict abstains, with a distinct grounding
token instead of a confident lead-time:
## A frozen-state local level cannot track the oscillating ENSO index: its
## innovations are autocorrelated and mis-scaled, so ACI abstains.
frozen <- ssm(transition = 1, observation = 1, state_cov = 1e-6, obs_cov = 1,
init_state = 0, init_cov = 10)
abstained <- aci(frozen, sim$x, dt = sim$dt)
abstained@grounding
#> [1] "ungrounded"
abstained@grounding_reason
#> [1] "model_inadequate"
abstained@adequacy@adequate
#> [1] FALSE
abstention_classes <- class(abstained)
abstention_classes
#> [1] "orchestra_refusal" "kalmix_abstention" "kalmix::aci_fit"
#> [4] "S7_object"## Adequate but unverified by default; grounded only with dated provenance.
adequate_unverified <- aci(model, sim$x, dt = sim$dt)@grounding
adequate_grounded <- aci(
model, sim$x, dt = sim$dt,
mechanism = list(source = "validated against the OSSE truth",
verified_on = as.Date("2026-06-14"))
)@groundingThe decision lead-time integrates a divergence profile over an
expanding future window. Formed directly, each window length reruns the
smoother on a truncated series. kalmix instead grows the
smoothed estimate of the anchor state forward in place as each later
observation is assimilated (a fixed-point smoother recursion that
accumulates the Rauch-Tung-Striebel gains), so the whole profile costs
one filter-smoother pass and a light forward recursion per anchor, with
no re-smoothing. This online engine is the default; the
expanding engine is retained as an independent
cross-check:
cir_online <- causal_information_rate(model, sim$x, dt = sim$dt,
engine = "online")
cir_expanding <- causal_information_rate(model, sim$x, dt = sim$dt,
engine = "expanding")| Engine | Lead-time (years) |
|---|---|
| online (default) | 0.52716 |
| expanding | 0.52716 |
Because the online recursion never truncates the series, it also
applies to time-varying state-space models, where the expanding
construction cannot: a model whose system matrices change step by step
still yields a lead-time from a single forward pass. The two engines
agreeing shows only that the smoother recursion is implemented
consistently between them: they share the quadrature and the reduction,
so their agreement is not evidence about the range functional itself.
That functional is graded separately, in the package’s own conformance
suite (tests/testthat/test-cir-conformance.R), which
reduces acir‘s own divergence profile with
kalmix’s reducer and requires the objective and subjective
ranges of Andreou, Chen and Bollt (2026) to come back; acir
is in turn graded cell-by-cell against the method authors’ MATLAB
reference. That chain, back to the authors’ own code, is what makes it
an oracle here, and it is not recomputed in this vignette.
The known-truth oscillator proves the read-out is internally
consistent; the package’s test suite also runs aci() on the
real NOAA Oceanic Nino Index (ONI) as an external check, in
tests/testthat/test-aci.R, on a fixture shipped with the
package (tests/testthat/fixtures/oni_noaa.csv, the CPC ONI
series, 1980 to 2020, public-domain US Government data, NOAA Climate
Prediction Center). The same model and data are reproduced here
directly, rather than cited unrun, so the number below is computed in
this vignette:
oni_path <- file.path("..", "tests", "testthat", "fixtures", "oni_noaa.csv")
has_oni <- file.exists(oni_path)oni <- read.csv(oni_path, stringsAsFactors = FALSE)$oni
## A two-state local oscillator: a damped rotation captures the quasi-
## periodic ENSO swing, observed through the index with measurement noise.
dt_year <- 1 / 12
angle <- 2 * pi * dt_year / 3.8
rho <- 0.96
rot <- rho * matrix(
c(cos(angle), -sin(angle), sin(angle), cos(angle)),
nrow = 2L, byrow = TRUE
)
oni_model <- ssm(
transition = rot,
observation = matrix(c(1, 0), nrow = 1L),
state_cov = diag(c(0.05, 0.05)),
obs_cov = 0.2,
init_state = c(oni[1L], 0),
init_cov = diag(c(1, 1))
)
oni_fit <- aci(oni_model, oni, dt = dt_year)
oni_tracked <- cor(oni_fit@smoother@smoothed_mean[, 1L], oni)| Quantity | Value |
|---|---|
| smoother-to-index correlation | 0.991 |
| decision lead-time (years) | 0.178 |
Two things bound how far that figure should be pushed. The quadrature
is evaluated at every lag in the window rather than at a dozen points,
and warns when a grid the caller thins has not converged, so the number
is not grid-dependent at the default. And an anchor whose future window
runs past the end of the record is right-censored: its range is
integrated over the lags the record supports and returned as a lower
bound with censored set, rather than extrapolated. A
rolling read-out near the end of a record is censored by construction,
and a censored lead-time cannot carry a grounded verdict.
On the simulated oscillator, the smoother recovers the hidden state at a correlation of 0.983 with the truth it never observes directly. The coupled system carries a mean causal information of 0.6204 nats and a lead-time of 0.527 years, while the null carries 0 nats and a lead-time of 0: the coupling is what the causal-information read-out is discriminating, not an artefact of the recursion.
The abstention example carries abstained@grounding equal
to ungrounded, with reason model_inadequate,
because the frozen local level’s innovations fail
innovation_diagnostics()
(abstained@adequacy@adequate is FALSE). The returned object
carries the classes orchestra_refusal,
kalmix_abstention, kalmix::aci_fit,
S7_object, so a caller elsewhere in the orchestra can
recognise this as a structured refusal, kalmix_abstention,
without parsing the grounding string. Passing the diagnostics is a
different matter from having a verified mechanism: the same coupled
model with no declared provenance carries grounding
ungrounded, and only rises to grounded once a
dated mechanism record is supplied. An adequate model is not thereby a
verified one; the two chunks above show both halves of that distinction
on the same model.
The online and expanding engines agree on the coupled system to within 1.1e-16 years, floating-point precision rather than a genuine numerical difference, confirming they implement the same smoother recursion. On the real NOAA ONI series the smoother tracks the observed index at a correlation of 0.991, and the decision lead-time comes out at 0.178 years, the kind of sub-decadal predictability horizon the ENSO literature reports.
Every read-out here is only as trustworthy as the state-space model
behind it, and aci()’s adequacy check establishes
self-consistency, not correspondence with the world: an adequate model
that was never checked against an external authority stays labelled as
unverified rather than grounded, exactly as shown above. The oscillator
example is a known-truth system built to demonstrate the read-out, not a
validated ENSO model, and the real-index section, while run on genuine
public data, uses a deliberately simple damped-rotation model rather
than a dynamical model fitted to the physical system. Under a Student-t
observation family the filter pass is the outlier-robust variational
update, whose per-step posteriors are Gaussian approximations; the
causal information and lead-time then inherit that approximation rather
than being exact. A regime-switching model compares the collapsed
Gaussian posteriors of the Kim smoother and the GPB1 filter rather than
the true mixtures, and its lead-time uses the expanding-window
construction only, because the online recursion is linear-Gaussian only.
The online and expanding engines agreeing on this vignette’s models is
evidence the smoother recursion is implemented consistently between
them, not evidence that the underlying range functional matches the
source paper; that grading is external, cited above and not recomputed
here.
Getting started: state-space models with kalmix covers the
kalman_filter() and rts_smoother() passes this
vignette reads a divergence between. Change-points and hidden states
with kalmix and Pairs trading with kalmix cover the
discrete-state and regime-switching models an aci() call
can also be given. The Elliott-van der Hoek-Malcolm spread model
with kalmix shows the same ssm_fit() machinery this
vignette’s models were specified by hand instead of estimating.
Seed 1 for the simulated oscillator; the real-index
section draws no random numbers.
sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04.1 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
#> [3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
#> [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
#> [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
#> [9] LC_ADDRESS=C LC_TELEPHONE=C
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] kalmix_0.6.0.9000 rmarkdown_2.32
#>
#> loaded via a namespace (and not attached):
#> [1] vctrs_0.7.3 cli_3.6.6 knitr_1.52 rlang_1.3.0
#> [5] xfun_0.61 otel_0.2.0 S7_0.2.2 jsonlite_2.0.0
#> [9] labeling_0.4.3 glue_1.8.1 buildtools_1.0.0 htmltools_0.5.9
#> [13] maketools_1.3.2 sys_3.4.3 sass_0.4.10 scales_1.4.0
#> [17] grid_4.6.1 evaluate_1.0.5 jquerylib_0.1.4 fastmap_1.2.0
#> [21] yaml_2.3.12 lifecycle_1.0.5 compiler_4.6.1 RColorBrewer_1.1-3
#> [25] farver_2.1.2 digest_0.6.39 viridisLite_0.4.3 R6_2.6.1
#> [29] bslib_0.12.0 withr_3.0.3 tools_4.6.1 gtable_0.3.6
#> [33] ggplot2_4.0.3 cachem_1.1.0