Reproducing the reference MATLAB codebase
Source:vignettes/vignette-3-matlab.Rmd
vignette-3-matlab.RmdAssimilative causal inference (ACI) in R
(vignette("vignette-1-intro")) runs the engine and The
closed-form ACI engine
(vignette("vignette-2-advanced")) documents its arguments.
This one is about the codebase underneath both: the reference MATLAB
implementation ACI_code, which models and which numbers
this package reproduces from it, where it deliberately departs, and how
far each correspondence has actually been checked.
Two claims are kept apart throughout. Coefficient and
inference agreement says that given the same driving path, the
package’s realised model coefficients, filter, smoother and metric match
the script’s. Pathwise simulator agreement says that
simulate() generates the same trajectories the script does.
Only the first is claimed here, and only where a constructor says
so.
1. What is claimed, model by model
Three constructors carry the reference codebase’s models. Each records its provenance and how far the correspondence has been checked:
aci_dyad_model()$meta$provenance
#> [1] "andreou2026aci Sections 3.1 and SI.4.1; ACI_code-main/dyad_interaction_model.m"
aci_dyad_model()$meta$source_status
#> [1] "paper + MATLAB checked"
aci_enso_model(hidden = "tau")$meta$source_partition
#> [1] "tau"
aci_enso_model(hidden = "tau")$meta$matlab_simulator_parity
#> [1] FALSE
aci_predprey_model(hidden = "prey")$meta$provenance
#> [1] "andreou2026aci SI.4.2; ACI_code noisy_predator_prey_model.m"
aci_predprey_model(hidden = "prey")$meta$source_status
#> [1] "paper + MATLAB checked"
aci_predprey_model(hidden = "prey")$meta$matlab_simulator_parity
#> [1] TRUEmatlab_simulator_parity = FALSE on the ENSO family is a
deliberate, recorded limitation rather than an omission:
simulate() is Euler-Maruyama throughout, while the MATLAB
ENSO scripts mix Euler updates for the interannual variables with
Milstein-style updates for I and \tau.
The predator-prey partitions
The stochastic Lotka-Volterra example of the supplementary material is supplied as two constructors rather than one, because the MATLAB file contains sequential direction-specific blocks: one run treats the prey as hidden, a later one treats the predator as hidden, and they are separate causal questions on the same system. The package keeps them separate, and the metric answers each on its own model:
mp_prey <- aci_predprey_model(hidden = "prey")
mp_pred <- aci_predprey_model(hidden = "predator")
c(prey_hidden = mp_prey$meta$vars$hidden,
prey_observed = mp_prey$meta$vars$observed,
predator_hidden = mp_pred$meta$vars$hidden,
predator_observed = mp_pred$meta$vars$observed)
#> prey_hidden prey_observed predator_hidden predator_observed
#> "prey" "predator" "predator" "prey"
sp_prey <- simulate(mp_prey, seed = 11, t_end = 20, dt = 0.01, burn_in = 5)
sp_pred <- simulate(mp_pred, seed = 11, t_end = 20, dt = 0.01, burn_in = 5)
a_prey <- aci(mp_prey, as_obs(sp_prey),
init = list(mean = mp_prey$meta$ic_default$y0,
cov = matrix(1, 1, 1)))
a_pred <- aci(mp_pred, as_obs(sp_pred),
init = list(mean = mp_pred$meta$ic_default$y0,
cov = matrix(1, 1, 1)))
c(peak_prey_hidden = max(a_prey$aci), peak_predator_hidden = max(a_pred$aci))
#> peak_prey_hidden peak_predator_hidden
#> 0.9290962 2.3191319The two numbers are not comparable to each other. They are peaks of two different metrics, on two different hidden states, along two different paths. Reading one against the other would be reading a direction off an accident of scaling. Compare each against its own decoupled behaviour instead.
Note what the metadata carries here:
aci_predprey_model() records
source_status = "paper + MATLAB checked" (its coefficients
are graded against the authors’ source in the oracle suite), and
matlab_simulator_parity = TRUE records that
simulate() and noisy_predator_prey_model.m use
the same Euler-Maruyama scheme. The simulation_convention
field states the limit of that claim: scheme agreement only, since the
RNG streams differ, and no pathwise parity is claimed.
2. The one partition that is an approximation, and says so
aci_enso_model() covers the five hidden partitions of
the reference codebase. Four of them split the six-state drift exactly.
The fifth, T_C hidden, cannot: the
damping c_1(t, T_C)\,T_C is cubic in
T_C, so the system is not conditionally
Gaussian with T_C withheld, and the
reference script restores conditional linearity by freezing c_1 at the climatology T_C = 0. The constructor requires that
substitution to be named, and the model it returns assimilates only
(T_E, I), with u, h_W and
\tau supplied as prescribed forcings
looked up by index on their own grid:
mj <- aci_enso_model(hidden = c("u", "hW", "tau"))
sj <- simulate(mj, seed = 21, t_end = 4, dt = 5e-3, burn_in = 1)
path <- data.frame(t = sj$obs$t, u = sj$hidden[, 1], hW = sj$hidden[, 2],
tau = sj$hidden[, 3])
mtc <- aci_enso_model(hidden = "TC", approximation = "zeroth_order_c1",
prescribed = path)
mtc
#> <cgns_model> 'ENSO6[aci_code] (TC hidden, zeroth-order c1)': k = 2 observed, l = 1 hidden
c(observed = paste(mtc$meta$vars$observed, collapse = ", "),
prescribed = paste(mtc$meta$vars$prescribed, collapse = ", "),
approximation = mtc$meta$approximation)
#> observed prescribed approximation
#> "TE, I" "u, hW, tau" "zeroth_order_c1"The refusal is shown through a small helper that catches the classed condition and prints its class and message, so the reader sees what the package says rather than a halted chunk:
refused <- function(expr) {
tryCatch({
force(expr)
cat("no condition was signalled\n")
}, error = function(e) {
cat(class(e)[1L], ": ", conditionMessage(e), "\n", sep = "")
})
}What the filter and smoother return are the moments of that approximating model, not of the six-state system, so the model refuses to generate a path of its own and names the constructor that can:
refused(simulate(mtc, seed = 1, t_end = 1, dt = 5e-3))
#> aci_error_model_contract: 'ENSO6[aci_code] (TC hidden, zeroth-order c1)' is an inference-only model: its coefficients approximate a larger system that it cannot itself generate. L_y is the time-only series r_C - c1(t, 0), a zeroth-order Taylor expansion of the cubic damping about the climatology TC = 0 (ENSO_model_cond_ACI_T_C_unobs.m:1052, :1150). The filter and smoother moments are those of this approximating model, not of the six-state system, whose simulator keeps the full nonlinear c1(t, TC) (:1105-1106, :1124). Simulate the full system with aci_enso_model(hidden = c("u", "hW", "tau"), variant = "aci_code") and build this model from that path.3. matlab_defect_compat, and the defect it names
matlab_defect_compat = TRUE reproduces one further
departure: the script’s assimilation forcing omits a thermocline term
its own simulator drift carries. The omission moves the means and leaves
both covariance sequences untouched, because the term enters only the
mean equations:
obc <- observed_trajectory(sj$obs$t,
cbind(TE = sj$obs$x[, "TE"], I = sj$obs$x[, "I"]))
init_c <- list(mean = sj$obs$x[1, "TC"], cov = matrix(0.1, 1, 1))
mtd <- aci_enso_model(hidden = "TC", approximation = "zeroth_order_c1",
prescribed = path, matlab_defect_compat = TRUE)
f_inc <- aci_filter(mtc, obc, init = init_c) # term included
f_omit <- aci_filter(mtd, obc, init = init_c) # script verbatim
a_inc <- aci(mtc, obc, init = init_c)
a_omit <- aci(mtd, obc, init = init_c)
identical(f_inc$cov, f_omit$cov) # an identity, not a tolerance
#> [1] TRUE
c(filter_mean_gap = max(abs(f_inc$mean - f_omit$mean)),
aci_gap = max(abs(a_inc$aci - a_omit$aci)),
integrated_ratio = sum(a_omit$aci) / sum(a_inc$aci))
#> filter_mean_gap aci_gap integrated_ratio
#> 0.01268602 0.20901185 1.23047787The default is the corrected term, not the script. That is a
deliberate choice and it is the one place in the package where the
default is not the reference behaviour: the term is available,
its omission is inconsistent with the same script’s own simulator, and
the sibling scripts carry it. Anyone grading against published
T_C output should set
matlab_defect_compat = TRUE and say so.
4. The first-slice convention in the conditional scripts
The reference conditional scripts fill the first slice of the
observation-precision array with the full Gram inverse
and mask only the later slices. That is a convention, not a kernel
failure, and first_step names both sides of it.
"uniform" masks every slice and is the default, and
"matlab" reproduces the asymmetry:
me <- aci_enso_model(hidden = "hW")
se <- simulate(me, seed = 12, t_end = 4, dt = 5e-3, burn_in = 1)
obe <- as_obs(se)
init_e <- list(mean = me$meta$ic_default$y0, cov = matrix(0.1, 1, 1))
by_target <- aci_conditional(target = "TC", method = "mask")
by_matlab <- aci_conditional(target = "TC", method = "mask",
first_step = "matlab")
f_u <- aci_filter(me, obe, init = init_e, conditional = by_target)
f_m <- aci_filter(me, obe, init = init_e, conditional = by_matlab)
a_tc <- aci(me, obe, init = init_e, conditional = by_target)
a_m <- aci(me, obe, init = init_e, conditional = by_matlab)
c(step2_filter_mean_gap = abs(f_u$mean[2] - f_m$mean[2]),
step2_relative = abs(f_u$mean[2] - f_m$mean[2]) / abs(f_u$mean[2]),
last_step_gap = abs(f_u$mean[nrow(f_u$mean)] -
f_m$mean[nrow(f_m$mean)]),
peak_aci_gap = max(a_tc$aci) - max(a_m$aci),
integrated_relative = sum(a_m$aci) / sum(a_tc$aci) - 1)
#> step2_filter_mean_gap step2_relative last_step_gap
#> 0.311642248 5.639302856 0.004861257
#> peak_aci_gap integrated_relative
#> -0.277286818 -0.142954074One slice out of 801 moves the run by that much, and the difference
decays through the record rather than vanishing: it is still above
round-off at the last step. "matlab" applies to
method = "mask" only, which is where a masked precision
path exists, and it is exactly inert wherever the mask itself is.
5. The \tau partition: two estimands, not two implementations
The \tau-hidden reference script prescribes u and h_W as known series and runs a three-channel observed process, stating that this “will yield the same results” as the five-channel form. On our path it does not. Both prescribed channels have non-zero L_x rows, so they carry genuine \tau information, and prescribing them reproduces their effect on the drift but not their innovations.
That is why the estimand is a constructor argument here rather than a
caller’s option. observations = "reduced" is the script’s
estimand and the default for this partition, carried as a specification
the assimilation runs whenever the caller supplies none, and
"full" is the five-channel construction:
mt_red <- aci_enso_model(hidden = "tau")
mt_full <- aci_enso_model(hidden = "tau", observations = "full")
c(default = mt_red$meta$observations, alternative = mt_full$meta$observations)
#> default alternative
#> "reduced" "full"
mt_red$meta$estimand_nontarget
#> <aci_conditional_spec> x_B = {1, 2}, method = reduce
st <- simulate(mt_full, seed = 3, t_end = 8, dt = 5e-3, burn_in = 1)
obt <- as_obs(st)
init_t <- list(mean = mt_red$meta$ic_default$y0, cov = matrix(0.1, 1, 1))
a_reduced <- aci(mt_red, obt, init = init_t)
a_full <- aci(mt_full, obt, init = init_t)
Lx_tau <- sapply(seq_along(obt$t),
function(j) abs(drop(mt_full$Lx(obt$t[j], obt$x[j, ]))))
setNames(round(apply(Lx_tau, 1, max), 5), mt_full$meta$vars$observed)
#> u hW TC TE I
#> 0.04368 0.08737 0.17473 0.21842 0.00000
c(peak_full = max(a_full$aci),
peak_reduced = max(a_reduced$aci),
max_abs_diff = max(abs(a_full$aci - a_reduced$aci)),
mean_ratio = mean(a_reduced$aci) / mean(a_full$aci),
pearson = cor(a_full$aci, a_reduced$aci))
#> peak_full peak_reduced max_abs_diff mean_ratio pearson
#> 1.3300178 1.1042079 0.3462538 0.9578252 0.9088972Observations are supplied on all five channels either way. What changes is which of them are assimilated and which are read as known forcing.
The independent measurement on the shared comparison record, kept in the development record (section 7), found the same shape at larger amplitude: time-resolved ACI differing by up to 0.776, which is 3.1 times its own mean level, with Pearson correlation 0.905, while the time-averaged ACI agreed to within 0.5%. The shortcut roughly preserves the average level while materially distorting the time-resolved curve, and the time-resolved curve is what the method reports.
The two forms are therefore different estimands, and
any fidelity claim has to say which observation set it reproduces.
meta$observations records which one a model carries, and
the development record has the measurements behind the choice of
default.
6. Reading the conventions off a result
Reproducing a published number means matching every convention that number was computed under, and the package’s position is that those conventions belong on the result object rather than in a reader’s memory. The fields to check, and where each is explained:
m <- aci_dyad_model()
sim <- simulate(m, seed = 7, t_end = 4, dt = 0.01, burn_in = 1)
ob <- as_obs(sim)
init <- list(mean = 0, cov = matrix(1, 1, 1))
a <- aci(m, ob, init = init)
fc <- aci_range(a, anchors = seq(1L, length(ob$t), by = 20L))
#> Warning in .forward_cir_compiled(bundle, filter = x$paths$filter, init =
#> x$handles$init, : 1 forward CIR values masked (M < 1e-05); interpret CIRs
#> jointly with the ACI metric (Andreou & Chen 2026, Remark B.4).
c(smoother_scheme = a$meta$smoother_scheme,
regularization = a$meta$regularization$policy,
cir_method = fc$method,
cir_bound = fc$bound,
cir_quadrature = fc$meta$quadrature,
cir_convention = fc$meta$convention)
#> smoother_scheme regularization cir_method
#> "backward_ode_euler" "none" "exact"
#> cir_bound cir_quadrature cir_convention
#> "layer_cake_objective" "simpson" "count"-
smoother_schemedistinguishes the continuous backward equation from the discrete Theorem 3 posterior. They differ at first order in \Delta t and the difference is not small. The closed-form ACI engine, section 5, measures it. -
regularizationis"none"on a strict run and"floor"where the caller opted into projecting covariances back into the positive-definite cone. On a"floor"run the same list records how many times it fired and where. -
methodandbounddistinguish the definitional objective from the reference script’s efficient ratio, and say whether the table behind the number was truncated. The two functionals are not interchangeable, and the word “objective” names the ratio in the predecessor R implementation. -
quadraturecovers the"matlab_eps_grid"compatibility mode and the"simpson"/"sum"split between the ACI and FBCIR reference scripts. -
conventiondistinguishes the reference script’s index-count read-out of the subjective range from the paper’s lag time, which differ by exactly one grid step.
The arguments behind the CIR-side fields are documented in The closed-form ACI engine, section 3, with the size of each change measured there. What this section adds is only the instruction to read them off the object before comparing anything against a figure.
7. What the evidence is, and what it is not
Fidelity claims in this package are graded by the kind of evidence behind them, and the kinds are not interchangeable.
- An authors-source fixture is numerical output produced by the reference MATLAB itself. It is the strongest evidence available and the package has it only where the source distribution shipped it.
- A source-derived harness drives the package with inputs read out of the reference scripts and compares against quantities the scripts define. It tests the translation, not the original execution.
- An independent transcription re-implements a
script’s numerics from the source text in R, separately from the
package, and compares the two. The T_C
partition’s fixtures are of this kind: their oracle is the script
make-tc-fixtures.Runder the repository’stools/fixtures/, and no MATLAB was executed to make them. Stronger than package-to-package agreement, weaker than an authors-source fixture. - Analytic identities are checks that must hold by construction, such as the two covariance sequences of section 3 being bit-identical because the omitted term enters only the mean equations. These are asserted as identities rather than as tolerances.
- Package-to-package agreement between two R implementations sharing a fixture set is shared evidence, not a second oracle. It is useful for locating a disagreement and it establishes nothing about MATLAB fidelity on its own.
- Behavioural tests fix the package’s own contracts, such as which inputs are refused. They say nothing about the reference at all.
The measurements cited above by name are in the repository’s
development record, dev/acir-process-rationale.md, which
records each adopted change with the measurement and the accuracy gate
behind it. Numbers computed in this vignette are computed on its own
short records and are not those measurements.