Changelog
Source:NEWS.md
tulpaRatio 1.7.3
latent_factor()gives each process its own loading on each factor (#134). The loading matrix is lower triangular with a positive diagonal, so one factor adds a rank-one and two factors a full residual covariance between the numerator and denominator predictors; more factors than processes are refused, since the extra ones are not identified. Before, every factor loaded with one sigma on both processes, so K factors were one effect andn_factors = 2gave about 80 divergences. The factor scores carry their N(0, 1) prior on the sampled scale, which keeps the direction the sum-to-zero constraint removes proper. Draws are namedlambda_latent[process,factor]; thescaleargument, which nothing read, is gone.A per-group temporal walk (
temporal_rw1(group_var = ),temporal_rw2(group_var = )) sums to zero within each group (#135), so it carries the group’s trend and a group intercept such as(1 | site)its level. Before, only the global mean was removed and the walk levels lay along the intercept’s direction: 25 divergences at 1000 iterations with(1 | site), 95 in the non-centred coordinate; now 0 to 9 and 0.spatial_multiscale()represents both scales on one Hilbert-space basis (#136). Two nearest-neighbour GP fields over the same locations leave the split of their variance to the priors, and NUTS diverged on most draws there in both coordinates (100 to 190 of 200 on the 40-location fixture, also with the variances sampled as a total and a share); the basis samples the same fixture without divergences.approx,nn_local,nn_regional,parameterization,covandnuare gone (the basis is squared exponential, andcovwas ignored on it), andsampler = "lbfgs"now reaches the field.The PC prior on an HSGP field’s sd is proper (#146). On the log-variance coordinate it lacked the
d sigma / d log sigma^2Jacobian, so its density tended to a constant as sigma went to zero (single-scale and multi-scale HSGP).A multi-scale field combined with anything beyond a temporal walk (a space-time interaction, latent factors, a varying coefficient) receives its gradient (#147); the composite gradient left all of its parameters at zero.
spatiotemporal(parameterization = "collapsed")integrates a GP space-time interaction (type = "separable"or"nonsep_gp") out with a Laplace approximation inside HMC (#143; Margossian et al. 2020): the sampler moves the interaction’s three hyperparameters and every other block, its gradient follows from the implicit function theorem with one adjoint solve, and the interaction is drawn from its Gaussian approximation at each saved iteration. The soft margin constraints enter as a low-rank term through a quasi-definite augmented system, so one sparse factorization serves every solve. On the spatial-temporal vignette’s model (25 regions x 4 years, Gneiting,nn = 15) NUTS takes about 20 leapfrog steps per draw instead of about 390, and 400 iterations of one chain take about 2 minutes where 200 centred ones took 10. For a likelihood Gaussian in the interaction the approximation is exact, and a test checks the density against the centred one there to 1e-8. It needs a log-concave family with canonical links and no inflation.The composite and latent-factor gradients read an observation’s residuals and dispersion derivatives from the log posterior’s own per-observation density (#148). Each carried its own family algebra: on gamma-gamma and lognormal both were off by order one, beta-binomial dispersions got no gradient, and neither carried zero or one inflation. A collapsed GP or areal field refuses the families and inflation its inner Laplace is not written for (gamma-gamma, lognormal, beta-binomial; negative binomial-gamma for the GP). The gradient sweep now runs every hand-coded family and inflation structure through these paths.
An intrinsic CAR or BYM2 field on a disconnected map (#141) is centred on each connected component rather than over the whole graph, an island gets a unit-variance prior instead of none, and BYM2 carries tulpa’s per-component scaling (Freni-Sterrantino, Ventrucci and Rue 2018). HMC, ESS, SGHMC, SGLD, VI and the Laplace backend fit it; the Gibbs and Polya-Gamma samplers, which centre over the whole graph, and the collapsed parameterization refuse it by name, and auto mode sends it to HMC.
ESS, SGHMC, SGLD and VI fit BYM2 again (#145). Their parameter names are now written at the indices the layout assigns; before, a BYM2 field stopped with “named 23 of 22 parameters”. ESS also updates the unstructured BYM2 component, which it had left at its starting value.
coef()andvcov()of a single-process Laplace fit (binomial, beta-binomial) return the fixed effects (#144). Its draws were named by design column rather thanbeta_num[j], so both came back empty.A Gibbs fit whose numerator has no dispersion parameter (e.g.,
ratiod_poisson_gamma()) returns its draws; it stopped on the missing numerator dispersion name, which broke the BYM2 chunk of the spatial-temporal vignette.The BYM2 field has one scale on every backend (#140): the structured component is multiplied by tulpa’s Riebler et al. (2016) scale,
1 / sqrt(s)withsthe geometric mean of the ICAR marginal variances. The HMC-family backends computed their own factor from eigenvalues and the Gibbs, Polya-Gamma and Laplace backends multiplied by the eigenvalues’ geometric mean itself, sosigmaandrhowere different parameters on each: on a 5 x 5 grid HMC reported sigma 8.1 and Gibbs 0.89 for the same field.The Gibbs sampler fits BYM2 again (#133). The fixed effects and both field components move as one Gaussian-approximation block, and (sigma, rho) jointly with them (Knorr-Held and Rue 2002); on the 25-site test field Gibbs and HMC now agree on every hyperparameter to within 0.1 posterior SD. Auto mode sends BYM2 models to Gibbs, as it does ICAR ones.
spatiotemporal(parameterization = "noncentered")reaches the GP interaction types (#132):z ~ N(0, I)through the nearest-neighbour GP autoregression,delta_i = m_i + sqrt(d_i) z_i, sampled with the arena gradient. Stored draws are the interaction itself. The centred coordinate stays the default: on an 8 x 4 grid it mixed as well or better and ran faster, for an interaction SD of 0.5 and of 0.05 alike.Families take the links their likelihoods now implement (#117):
"probit"and"cloglog"beside"logit"for a success, zero-inflation, one-inflation or hurdle probability, and"identity"and"inverse"beside"log"for a gamma mean. One inverse-link helper carries every link through the log posterior, and a model with a non-canonical link takes the arena gradient of it.fitted(),predict(),ratio(),loo()/waic(),posterior_predict()andprior_predict()apply the family’s links. The Gibbs, Polya-Gamma and Laplace samplers and the collapsed spatial fields keep the canonical links and name the restriction.A known exposure (#118):
ratiod_zipois(),ratiod_zinegbin()and the hurdle families takedenom_family = "fixed", andratiod_lognormal()denom_fixed = TRUE. The exposure scales the numerator mean, has no coefficients, and the ratio is the rate.ratiod_zipois()andratiod_hurdle_pois()also takedenom_family = "negbin", a Poisson numerator over a negative binomial denominator.ESS, SGHMC and SGLD no longer fit every family outside binomial, negbin-negbin and Poisson-gamma with the negbin-negbin likelihood, and VI no longer refuses them; a gamma-gamma or log-normal model with a GP field no longer reads its continuous numerator out of bounds (#139). Every sampler entry point reads the model type, links and responses through one builder and names its parameters off the shared layout.
latent_factors()andspatiotemporal_effects()are tulpa’s verbs, re-exported, instead of tulpaRatio copies that masked tulpa’s on attach (#100). The spatiotemporal summary and long formats now label regionsand timetcorrectly when the numbers of regions and times differ: the old copy read the s-fastest draw layout t-fastest.format = "summary"returns atulpa_st_summary(withprint()and aplot()that addstype = "spatial_map"), and the array and long formats are a plain array and data frame. Requires tulpa >= 0.7.5.spatial_gp()fits no longer run away. Outside the range prior’s bounds the hand-coded GP and multi-scale GP gradients returned a zero gradient without reporting the log-posterior, so NUTS read an out-of-support point as carrying the previous point’s density; once the range crossed its upper bound the chain random-walked tobetanear 1e6 withphi_gp = Inf(#102).NNGP fits are several times faster: the centred gradient computes the field’s density in the same pass and the fused log-posterior reuses it, the non-centred density no longer rebuilds a field it does not read, and every neighbour covariance is filled once per pair on the cached distances. A 60-location
spatial_gp()fit drops from 69 to 13 s. The SVC terms use the spatial GP’s NNGP density and gradient instead of their own copies, and the block-diagonal warmup metric shrinks its covariance as the diagonal one does (#101).predict()builds new-data design matrices from the training terms, factor levels and contrasts, so a factor predictor, or new data holding only some of its levels, predicts (#103).predict(coords.0 = )krigsspatial_gp()fits from the stored field draws and evaluatesspatial_hsgp()fits on the fit’s own sine eigenbasis, both on the training coordinate scaling (#104).loo(),waic(),ratiod_compare()andratiod_average()evaluate each observation’s likelihood at the full linear predictor, random and structured effects included; non-HMC draw backends refuse models with such terms (#108).loo()andwaic()are re-exported.summary()returns asummary.ratiod_fitobject whoseprint()method lays it out;coef(summary(fit))is its parameter table.coef(),vcov(),nobs()andformula()methods are new for fits, andas.data.frame()returns the posterior draws again (#105).The HMC sampler records each iteration’s Hamiltonian, so
plot_energy()anddiagnostic_summary()report E-BFMI, per chain throughtulpa::energy_bfmi()(#107).diagnostic_summary()returns its object instead of printing it, without terminal colour codes.latent_factors()reads atratio()fit’s factor scores (#106), andvalidate_mode()accepts the backend a fit ran on ("hmc") as well as its tier.A
priors = ratiod_priors(...)argument reaches the samplers: every MCMC backend translates it through one function, passing Normal(0, sd) on fixed effects, Half-Cauchy on random-effect SDs and Gamma on the dispersion, and refusing other families by name instead of fitting under its own defaults as before (#109). A component left unspecified takes the default the samplers already used (priors_default()lists them per family), so default fits are unchanged;prior_predict()andsim_ratiod_sbc()now draw from those same defaults.ratiod_priors_legacy()is removed.summary()reports every main parameter; it stopped at the twelfth.print()on a fit is a five-line description.ratio_contrast()compares the levels of a factor, character or logical variable in level order and refuses a numeric covariate.(1 | a:b)groups by the interaction ofaandb, as the second term of(1 | a/b)does.tratio()is quiet by default:control = list(verbose = TRUE)prints the progress messages and sampler adaptation traces.Help pages: one Title Case style, print/summary/plot methods documented on the page of the function returning their class, and every exported topic with an example that runs.
The HMC entry points run their OpenMP regions on the threads
coresallows, so concurrent chains no longer each start a full team; a zero-inflated test fit drops from 87 to 1 s (#110).Correlated random slopes take their correlation through canonical partial correlations with an LKJ(2) prior and its Jacobian, and draws unpack with three or more correlated coefficients. Correlated-slope posteriors change (#111).
Inline transforms such as
log(x)andI(x^2)combine with random-effect terms: the random part is removed from the formula as a parse tree, not as text (#112).loo(),waic()andposterior_predict()support every zero-inflated, one-inflated, zero-and-one-inflated and hurdle family; the zero-and-one inflated binomial is the extended mixture its density in the sampler uses (#113).Hurdle fits no longer gain mass where a small mean makes P(Y = 0) round to one: the zero-truncation uses a stable log(1 - exp(x)) instead of a
-1e10sentinel the truncation subtracted (#114).mode = "ess"targets the posterior (the block’s reference density was counted twice and the slice threshold was stale), runs the requestedchains, and returns natural-scale draws thatfitted(),predict()andloo()read as they read an HMC fit’s. Its reference starts at a Laplace approximation and is refitted from warmup draws as a multivariate t, as in generalised elliptical slice sampling; on a random-intercept model its bulk ESS now exceeds HMC’s at the same length (#115). ESS refuses random slopes and several grouping factors instead of dropping them.Families accept only the links and denominators the samplers fit; other links,
denominator_known = FALSE,denom_fixed = TRUEand severaldenom_familyvalues used to be stored and silently fitted as the default model.denominator_knownanddenom_fixedare removed;ratiod_zinegbin()andratiod_hurdle_negbin()gaindenom_family = "gamma"(#116).Fixed-effect draws are named
num:<term>anddenom:<term>incoef(),vcov()and the printedsummary()tables (#127); the unusedlatent_mean[k]draws are gone.The Gibbs sampler fits ICAR models only. Its BYM2 sweep shifted the structured field’s level against the intercept, off the field’s sum-to-zero constraint, so the intercept wandered under its own prior (#119); without that move it does not reach the posterior HMC samples, so a BYM2 model in
mode = "gibbs"is refused andmode = "auto"fits it with HMC (#133). Gibbs fits report natural-scale draws under the HMC names and work withratio(),fitted()andloo()(#120).The PC prior on the space-time interaction’s precision was the density of a variance, which made it improper toward large precision; with little interaction in the data
tau_stran to 1e9 (#130). Space-time results change. Non-centred Type IV fits rebuild the interaction from its scale for fitted values, ratios andloo()(#131).Collapsed ICAR, BYM2 and GP fields reach the fit for every chain count and entry point; multi-chain collapsed fits lost them, and their fitted values, ratios and
loo()missed the spatial term (#125).Correlated random slopes report their correlations,
cor_re[t,a:b], instead of the sampler’s raw coordinates (#122).The pre-sampling gradient check compares against a five-point finite difference. The two-point difference was too coarse for a covariate on a large scale, so the check fired and the fit switched to slow finite-difference gradients (#124).
Divergent transitions raise a warning of class
tulpaRatio_divergencewhether or notverboseis set (#123), andprint(summary(fit))flags parameters with R-hat above 1.01 or bulk ESS below 100 per chain (#126).spatiotemporal(parameterization = "noncentered")covers Knorr-Held Types I to III as well as IV: the interaction is sampled aszwithdelta = z / sqrt(tau), and its sum-to-zero penalty acts onz, so a weak interaction no longer leavestauin a funnel (#132).The centred space-time interaction’s normalizer counts only the directions its sum-to-zero constraints leave; Types I to III and the GP types counted the constrained directions as well and overstated
tau(Type I by about two posterior SDs on a 25 x 6 grid). Space-time results change (#138).A NUTS leaf whose step leaves the posterior’s support counts as a divergence with acceptance zero;
std::min(1, exp(-NaN))had reported it as perfect acceptance, so the step size adaptation could grow epsilon to its cap and freeze a chain, most often under longer warmups (#137).ratio(newdata = )works (#121);print()lists every grouping factor (#128); trace plots drop overlapping axis labels (#129).spatial_svc()samples each NNGP term non-centred by default (#96). The newparameterizationargument takes"noncentered"(default) or"centered". Non-centred, each term’s block holdsz ~ N(0, I)and the field isw = L(sigma2, phi) zthrough the NNGP autoregression the spatial GP already uses, so the variance reaches the field through the transform. Centred, the field’s level is identified by the NNGP prior alone and wanders withsigma2: on the 80-row recovery fixture, five chain seeds at 4 x 3000 gave intercept R-hat up to 1.117 and asigma2bulk ESS of 12, against at most 1.020 and at least 167 non-centred. Both coordinates place the same prior onw; a spec built before the argument existed runs centred.An NNGP SVC range no longer flags most transitions divergent (#99). The range carried its uniform prior as a hard bound on
log(phi), so a proposal past either end ofrangemet a log density of-Inf, which the sampler records as a divergence; with the posterior near the upper end, 442 of 500 transitions were flagged on an 80-row fixture. The range is now sampled on the logit scale of its interval, which places the same uniform prior with no edge in the sampled space: 2 of 500 centred and 0 of 500 non-centred on the same fixture and seed. The draws still reportphi_svcitself.A stored SVC draw is the field the likelihood reads (#98). eta reads each term centred over locations, but the sampler stored
wuncentred, sosvc()and the fit’s eta carried a level the likelihood removes: 0.73 on average on an 80-row fixture with a true slope of 0.3. The draw store now writes the centred field, in either coordinate.spatial_rsr()is carried, or refused, by every backend (#92). HMC received the restriction only on its centred NNGP branch, where it projected the field and then evaluated the NNGP prior on the projected values, with an N x N projection applied to a field held at the unique locations; on an areal field the entry point cleared the flag, sospatial_rsr(spatial_car(...))gave draws bit-identical to the unrestricted fit. The log posterior now builds the spatial field’s value at each observation once (areal, NNGP, HSGP or multi-scale GP), and under RSR the predictors read(I - Q Q') fwithQthe orthonormal basis of the restricted covariates; every field prior readsf. The gradient of a restricted model is the arena gradient of that density, andfitted()applies the same projection. A collapsed parameterization is refused under RSR. The PG sampler drew the field from the unrestricted ICAR conditional and read a corner of the projection; it now draws it from its exact Gaussian conditional, with precisiontau Q + A' Omega AforA = (I - Q Q') S, and its R wrapper no longer errors before sampling. ESS, Gibbs, SGHMC, SGLD and VI, which never read the restriction, refuse it, and PG refuses it on anything but an intrinsic CAR field.predict()refuses a restricted field at new rows.-
predict()on the fitted rows reproducesfitted()(#93, #94, #95). Three separate defects pulled the two apart:- The HMC areal lookup, the PG areal lookup and the PG random-effect lookup numbered a grouping variable’s units in order of first appearance, where the fit numbers them by factor level, so whenever the two orders differed each row read another unit’s effect (#93).
- An HMC binomial prediction with a spatial field added the field to
log(p)and tookplogis()of that, which is not the predictor; the field now enters the linear predictors and the response is taken once throughhmc_response_draws(), asfitted()takes it (#94). - Every Polya-Gamma sampler stored the predictor computed at the top of an iteration beside the parameters updated during it, so saved draw
spaired the parameters of iterationswith the predictor of iterations - 1; the predictor is refreshed at the save (#95). The field-free and random-effect-only fits run tulpa’s own sampler, which stores the saved draw’s predictor from gcol33/tulpa#941 on.
The log posterior no longer depends on the thread count on Windows arm64 (#83). On aarch64-w64-mingw (Rtools45 clang 19) an OpenMP
reduction(+:double)over a 4-thread team returned the serial value in 0 of 500 repeats of a plain Poisson sum on a 4-core runner, while 1 and 2 threads and a hand-rolled per-thread sum were exact. The observation sum in the density, the Laplace log-likelihood and the test kernels now go throughratiod_omp::sum_range()(src/omp_sum.h): each thread sums one chunk into its own slot and the slots are added in thread order. The HMC gradient’s observation loop summed the zero- and one-inflation coefficients, the temporal and spatial fields and the random slopes withomp atomic, and on the same platform the analyticbeta_zigradient missed finite differences by up to a factor of four; those sums now share the loop’s per-thread slots with the other blocks.-
The Laplace tier runs on the nested-Laplace engine (#85, #84). The in-tree mode-finder (
laplace_core.cpp) picked one value per hyperparameter and conditioned the fixed effects on it, so intervals omitted hyperparameter uncertainty. The tier now hands the model totulpa::tulpa_nested_laplace_joint(): one likelihood arm per process, one latent block per random-effect term, areal field (ICAR, BYM2, proper CAR), Gaussian-process field (single and two scale, on a Hilbert-space basis) and multi-scale temporal component. The engine integrates every block hyperparameter and every dispersion on its outer grid.summary()reads the fixed effects off the grid mixture (mean, covariance, skew-aware interval), so the rows no longer move with a draw seed; hyperparameters are reported as posteriors instead of constant columns; the stored draws are drawn from the mixture.Consequences of the move:
- Two-process families (
poisson_gamma,negbin_gamma,negbin_negbin,gamma_gamma) are fitted as two arms with their own fixed effects and dispersions, shared latent blocks entering both. The old tier fitted the numerator alone, soratio()of such a fit was a numerator rate (#89). - Every random-effect term is fitted; only the first was (#88).
-
predict()works for fits without a spatial or temporal field (it always errored, #87), andfitted()reads each random effect at the group each row belongs to (#90). - A Gaussian-process field uses the Hilbert-space basis, not the nearest- neighbour approximation, and takes the squared-exponential kernel.
- Spatial and multi-scale temporal terms can share a Laplace fit; proper CAR and
spatial_hsgp()are fitted. - Restricted spatial regression reaches the engine as a projected areal block (the field enters each arm as
(I - Q Q') S z) or as a projected Hilbert-space basis; the projected effect is orthogonal to the restricted covariates. Spatially varying coefficients have no block on the engine and are refused withmode = "hmc"as the way out. - A model with no random effect and no field is fitted on the dispersion axes alone.
-
summary()lists every fixed effect of a one-process fit; it listed the intercept only (#91).
- Two-process families (
The SoftAbs divergence retry is replaced by a WALNUTS transition (#86).
control$riemannianused to resolve to an automatic setting that switched the retry on for BYM2 and ICAR fits with a dense mass matrix. The retry replaced a divergent NUTS trajectory with a second one under a Hessian-based metric, so which kernel moved the chain depended on the current state and no acceptance correction covered the choice. On the engine’s copy of the scheme (gcol33/tulpa#937, Neal’s funnel) a seed settled in the neck with 451 divergent transitions against 6 without it. The retry, its finite-difference Hessian andcontrol$riemannianare removed.control = list(walnuts = TRUE)runs each trajectory as the engine’s WALNUTS transition (<tulpa/walnuts.h>, the code tulpa’s own sampler runs): every macro step takes the coarsest leapfrog subdivision that keeps the joint log density within tolerance, and only if the reversed step picks the same one, so the kernel is exact. On a BYM2 field over 12 units with one observation each atadapt_delta = 0.5, NUTS diverged 2 to 122 times over 5 seeds and WALNUTS never, and both sat within 0.06 reference SDs of a longadapt_delta = 0.95NUTS run on every fixed effect and hyperparameter, with SD ratios 0.93 to 1.05 under WALNUTS. NUTS stays the default, and its draws are bit-identical to those before the change. Needs tulpa 0.7.1 or later, the first version that ships<tulpa/walnuts.h>(gcol33/tulpa@7fddf507).mcmc_diagnostics()is gone; usediagnostics()(#82). It was an exportedlifecycle::deprecate_warn()shim forwarding todiagnostics(), and the engine’s shim of the same name was deleted in gcol33/tulpa#773, so two packages exported the name and whichever attached second masked the other. The package is pre-release, where the convention is to delete the old name rather than carry a shim.lifecyclewas reachable from nothing else and leavesImportswith it.
tulpaRatio 1.7.2
-
The standalone intrinsic walks take the same coordinate (#79).
temporal_rw1()andtemporal_rw2()takeparameterization, reading the same transform the multi-scale arms do.temporal_effects()andtemporal_gmrf_prior_grad()are the two places it lives – the one chokepoint every eta assembly reads and the one all eight gradient functions reach – so the coordinate is a branch in each rather than a change to any caller.Measured on a 500-observation fixture with no temporal signal, centred -> non-centred: an RW1’s bulk ESS for
tau_temporalgoes 409 to 805 at 0 and 1 divergences, and an RW2 picks up 23 divergences of 2000 for no ESS gain. Both return the same posterior, the intercept and slope agreeing to four decimals. Non-centred helps where the variance is shrunk toward zero and can cost where the data pin the field, which is why it is an argument rather than a default. -
The non-centred walks take
group_var(#81). The prior augments ONE global constant whatever the number of groups, so G - 1 group contrasts stay improper: a per-group level shifts eta only for that group’s observations and the likelihood identifies it. Running the single-group transform per group would augment each group’s own constant and shrink those contrasts, which is a different model, so the transform re-mixes instead. Each group’s coordinate splits into its increments and one sum coordinate; the G sum coordinates split in turn into the global direction the augmentation fills and G - 1 contrasts, which stay unscaled because they are flat and scaling them would couple the group levels totau. The cyclic branch reads its mean coordinate the same way, so one map covers RW1, RW2 and the seasonal walk.cpp_ms_temporal_nc_coordinate_spread()now runs over groups, andcpp_ms_temporal_nc_group_contrast_flat()moves the contrast directions alone: it asks that the prior stay where it is and that group g’s field move by exactly the unscaled offset. A finite difference cannot separate a transform that augments per group – that density is self-consistent and its gradient matches its own difference quotient – and neither can a flatness check on its own, which a transform that dropped the contrasts would pass. Every gradient fixture is checked to build the structure its name claims.
make_model()selects a structure by a chain of flags, each naming the fields it covers, so a name added toKNOWN_FIELDSbut to no flag falls through every branch and yields a model with no structured field: the gradient check then passes on a two-parameter regression while reporting the structure’s name.rw1_ncdid exactly that, coming back with 2 parameters against the 13 its siblings carry.test-gradient-correctness.Rnow asserts every field builds more than the structureless model.-
A non-centred coordinate for the multi-scale temporal arms (#79).
temporal_multiscale()takesparameterization, and"noncentered"samples the trend and seasonal arms asz ~ N(0, I), reaching the effects throughsigma * A^-1 z.Astacks the arm’s difference operator on1'/sqrt(n), which is exactly the augmented precision’s factorQ + 11'/n = A'A, so the arm’s quadratic form and the transform’s Jacobian cancel term for term and what is left isN(0, I)with the variance reaching the linear predictor through the transform alone.A^-1is a cumulative sum, once for RW1 and twice for RW2, so the transform and its adjoint are O(n).A non-cyclic RW2 annihilates a linear ramp as well as the constant and only the constant is augmented, so one coordinate stays a free direction under the flat prior it already had. Any right inverse of
Areproduces the density, but not any right inverse is well conditioned: the double integral’s own particular solution carries a large ramp that the free coordinate can undo, which measured 406 divergences of 2000 before the ramp was projected out and 3 after.Measured on the multiscale RW2 recovery fixture, whose true trend is zero so the whole posterior sits in the funnel’s neck, centred -> non-centred: the divergence count goes 30 of 2000 to 3, and by decile of
log sigma2_trendthe neck goes from 12.5% divergent to 0%. The bulk ESS ofsigma2_trendgoes 21 to 310 and its rhat 1.126 to 1.013, and the intercept’s ESS 596 to 1776. The centred run reports a posterior median of 1.6e-04 against 4.2e-05, biased upward by the part of the neck it never reaches; only the non-centred run has converged on that parameter. The intercept and slope are unmoved.test-gradient-correctness.Rcarries the three walks in either coordinate across all four gradient modes, andcpp_ms_temporal_nc_coordinate_spread()checks the invariant a finite difference cannot see: a transform that builds the wrong field leaves the density self-consistent and its gradient still matching its own difference quotient, but at fixed hyperparameters the two coordinates’ densities have to differ by one number for everyz. Measured spread 1e-13. -
A proper CAR field’s mean is removed from the linear predictor (#80).
Q(rho) = D - rho*Wis full rank atrho < 1, so the prior identifies the field’s mean, and the field was left uncentred on that reasoning. The likelihood cannot see that mean apart from the intercept though:etareadsbeta_0 + phi_s, and moving the intercept bycagainst everyphiby-cleavesetawhere it was. Measured on a 36-unit fixture, the two correlated at -0.977, each carried a posterior sd near 0.06 while their sum carried 0.013, and the intercept’s bulk ESS was 71 of 2000 at rhat 1.053. Longer warmup made it worse, not better: 1.053 -> 1.112 and ESS 71 -> 32 going from 1500 to 3500, because adaptation tunes for the bulk and the ridge is not in it.spatial_car()now takescenter, TRUE by default, and an SVC term’s treatment is what it applies:etareads the centred field and the prior still reads the raw one. SinceQ(rho)does not annihilate the constant, the two are different quadratic forms, and the density and every gradient now take the prior’s view of the field separately frometa’s.The identified posterior does not move. On that fixture the level comes back at 0.5200 against 0.5204, the slope at 0.3003 against 0.3004, with rho and tau inside their own uncertainty. What changes is that the level is the intercept: ESS 71 -> 1953 and rhat 1.053 -> 1.001.
center = FALSEkeeps the previous parameterization and requiresproper = TRUE; an intrinsic field’s mean is unidentified until it is removed, so there is no model on the other side of that switch.car_proper_rawcovers it intest-gradient-correctness.R. -
A proper CAR field reaches only gradients written for it.
GF_AREALcovered a proper CAR field as much as an intrinsic one, sospatial_car(proper = TRUE)alongsidetemporal_multiscale()or a spatiotemporal interaction selectedcompute_gradient_ms_temporal_handcodedorcompute_gradient_spatiotemporal_handcoded. Neither writes proper CAR’srhogradient or its log-determinant, and both centred a field the log posterior leaves alone, so the gradient they returned was the gradient of a different density. Measured against central differences of the log posterior on a 400-observation, 25-unit fixture: 210 on the regression coefficients, where every field in the sweep agrees to better than 1e-04 relative.Proper CAR now carries
GF_AREAL_PROPER, so a function whosecovers()mask does not name it falls through to the composite – the pathspatial_car(proper = TRUE)on its own already took, and the one that carries therhoblock. The same combinations now agree with finite differences to 2e-07 and 3e-06.car_proper_msandcar_proper_stjointest-gradient-correctness.R, which is what nothing reached before. One predicate decides whether the spatial block is centred. The rule was written out at five sites, and three spellings had drifted from the log posterior’s.
spatial_block_is_centred()(hmc_sampler.h) is now the only one, so a gradient and the density it is evaluated against cannot disagree about which field the likelihood saw.-
Recovery assertions read the quantity their model identifies. The SVC recovery test compared a single 80-observation realization’s posterior mean against the value the data were simulated from, where the maximum likelihood slope carries a standard error near 0.05: seed 11’s sample implies 0.2533 and seed 22’s implies 0.2062, so the assertion failed on sampling noise. It now runs the three seeds the RW1 test runs, asserts the posterior mean against the same sample’s unpenalized MLE – measured agreement within 0.002, an order of magnitude sharper than a tolerance against 0.3 can be – and keeps recovery of the simulated values as interval coverage across seeds.
The proper CAR test asserted
rhat < 1.05on the intercept, which that model does not identify on its own: the intercept and the field’s mean correlate at -0.977 and their sum carries a fifth of either one’s posterior sd. It now asserts on the sum, which converges (ESS 1970 of 2000, rhat 1.002), and on the intercept’s interval coverage. The multiscale RW2 fixture’s divergences are pinned where they belong. They sit in the neck of
sigma2_trend, whose true value on that fixture is zero: 12.5% of draws in the lowest decile oflog sigma2_trenddiverge and none above the third, and the divergent draws sit 1.96 posterior sd below the clean ones.test-recovery-constrained-fields.Rasserts that location rather than a bare count, which is what separates the known centred-parameterisation funnel from a fault elsewhere. The trend arm’s non-centred coordinate is #79.-
The SVC range prior a spec declares is the one the fit runs (#78).
prepare_svc_for_hmc()wrotephi_prior_lower = 0.3,phi_prior_upper = 30as literals into each of its three branches, so every SVC fit ran on that interval and nothing a caller set could change it.spatial_svc()carried no field for the bounds at all, and the%||% 0.01/%||% 10.0fallbacks infit_hmc()– the numbers?spatial_svcand theModelDatadefaults both named – were unreachable, becausesvc_infoalways supplied a value for the%||%to pass through.spatial_svc()now takesrange = c(lower, upper), validated as0 < lower < upperthe wayspatial_multiscale()validatesrange_localandrange_regional, andprepare_svc_for_hmc()reads it off the spec.svc_default_range()is the one place the default lives; theModelDatadefaults carry the same pair, and the print method reports the interval in force. Fits that set nothing are unchanged: the default is the[0.3, 30]those fits already ran under, and the draws are bit-identical.The bounds apply to
approx = "nngp". The HSGP arm parameterises a lengthscale under LogNormal(0, 1), which this uniform does not set, so arangepassed alongside it is an error rather than a value nothing reads.ess_sampler.cppandvi_sampler.cppeach wrote a third and fourth pair of bounds into a block they declare unsupported (has_svc = false); both are gone. -
Intrinsic fields identified by augment-and-centre (#22). The temporal random walks and the multi-scale trend and seasonal arms were identified by a soft penalty on the field’s sum,
-0.5 * lambda * (sum phi)^2attulpa::s2z_precision(n). That pins the constant direction without removing it: the direction stays in the linear predictor at precision `1 / (kappa*n)^2- n
, five orders of magnitude above thetau` the field’s own directions carry, and a step size that holds it stable is the one the whole field then has to move at.
These fields now use the engine’s construction (
tulpa/sum_to_zero.h, gcol33/tulpa#241): the precision is augmented,Q -> Q + 11'/n, so the constant carries the field’s owntau, and the effects are centred on their way intoeta, so the likelihood never sees it. The normalizer takes the augmented rank – one more pinned direction, except that a non-cyclic RW2 keeps its linear null direction and stays deficient by one. Measured on a 500-observation binomial fixture, the adapted step size rises from 0.101 to 0.351 fortemporal_rw1()and from 0.105 to 0.255 fortemporal_rw2(), and the leapfrog steps per iteration fall from 35 to 12 and from 42 to 20; the multi-scale arms move from 0.066 to 0.124 and from 0.040 to 0.056.The reported field changes with it.
fitted(),ratio()andpredict()add the stored field back intoeta, so an intrinsic field’s draws are now stored centred – the series the likelihood saw, rather than one differing from it by a level.temporal_ar1(),temporal_iid()and the temporal GP are proper, identify their own level, and are unchanged. - n
-
spatial_multiscale(parameterization = "noncentered")(#75). The multi-scale field was the one continuous field in the package with a single coordinate:spatial_gp()offers"centered","noncentered"and"collapsed", andtemporal_gp()andtemporal_ar1()each offer a second coordinate, while the multi-scale block carried two variance parameters over the same locations with no alternative to the effects themselves. #36 removed the name that selected nothing; it now selects a coordinate.Non-centred, each scale’s block holds
z ~ N(0, I)and its field isw = L(sigma2, phi) zthrough the NNGP autoregression the single-scale field already uses, so all four hyperparameters reach the linear predictor through the transforms as well as through the priors. Stored draws are the effects in either coordinate.approx = "hsgp"ignores the argument: its basis coefficients are already that coordinate.MultiscaleGPFieldTis the reader andMultiscaleGPView::accumulatethe adjoint, one of each rather than a branch at each of the six sites that read the field – the templated density, the three gradient functions that write the block, and the sample-storage transform. Both scales enter the linear predictor through the same sum, so the likelihood’s derivative with respect to either is the one vector, and the adjoint carries it onto whichever coordinate is being sampled.multiscale_gp_log_lik_t()built its own copy of the twoGPDataviews besidemake_msgp_gp_views(); it now calls it.msgp_ncjoins the finite-difference sweep in all four gradient modes. A finite difference cannot see a transform that builds the wrong field – the density would stay self-consistent and its gradient would still match its own difference quotient – socpp_msgp_nc_coordinate_spread()reads the invariant that does: at fixed hyperparameters the two densities differ bylog|det L|, one number for everyz. It measures 2e-13. -
A collapsed workspace no longer reads a previous model’s structure (#76). The collapsed GP’s per-thread workspace caches the NNGP coefficients for the last
(sigma2, phi), the location-to-observation map, and a Newton mode the next call warm-starts from. Its cache key was the number of GP locations, so a thread that had evaluated one model and was then handed another with the same number of locations kept the first model’s coefficients and map. Two collapsed-GP fits in one session over a field of the same size was enough: the second fit’s chain threads started from the first fit’s structure, and the same call with the same seed depended on what had run before it.CollapsedICARWorkspacewas keyed the same way on(S, bym2, N).Both workspaces now carry the id of the
ModelDatatheir structure was built for and drop every cached quantity when handed a different one. The Newton warm start goes with the rest: the mode of the previous leapfrog step is worth starting from, another model’s mode is not, and the search is capped at 20 iterations, so a bad enough start is not guaranteed to recover.cpp_gradient_race()gainsprime_other_model, which hands the calling thread a second model of the same dimensions at the same point before the race, and clears the Newton warm start before every evaluation so a collapsed gradient is a function of the point alone. The probe read 4.40e-01 forgp_collapsedand 8.49e-01 forgp_collapsed_st4against the old key and reads 0 against the new one; the eight collapsed fields are now swept bytest-omp-gradient-race.Ralongside every other field. -
compute_log_postiscompute_log_post_impl<double>(#28). The two densities returned the same number on every structure the templated one expressed, so keeping them apart bought nothing and cost the standing risk every bug in #11, #14 and #18 came from: a term in one copy and not the other, invisible because the runtime gradient check differences whichever density the active mode differentiates. The analytic body is deleted; what used to separate the two now sits inside the template.The structures no autodiff scalar can express – the collapsed ICAR/BYM2 and collapsed GP marginals, which locate a mode with Newton and add a Laplace correction, and proper CAR’s dense log-determinant – are
if constexprarms, solog_post_impl_gap()names exactly the blocks the other instantiations leave out, as before. The observation loop is one lambda, summed by an OpenMP reduction in thedoubleinstantiation and serially in the others, so the H path keeps its parallel likelihood without a second copy of the 250-line body.skip_obs_loopand the temporal GP’s precomputed block prior become aLogPostOptionsstruct defaulted to the full density;precomputed_st_log_priorgoes, having never had a caller.Writing the collapsed fields into the template found the guard they needed: the spatial hyperparameter priors were gated on the field having a parameter slot, so a marginalized field would have dropped the prior on its own scale. Nothing reached that – the gap kept collapsed models out of the templated density entirely – but the merge could not have kept it.
The double twins the deleted body was the last caller of go with it:
gp_nngp_log_lik,multiscale_gp_log_lik,log_prior_sigma2_pc,log_prior_phi_uniformandgp_gradient_winhmc_gp.h,hsgp_evaluateandhsgp_log_prior_betainhmc_hsgp.h,ar1_nc_log_priorinhmc_temporal.handlog_prior_temporal_sigma2_pcinhmc_temporal_gp.h, each of which had a templated counterpart that is now the only definition. So docompute_gradient_gp_autodiff,compute_gradient_msgp_autodiffandcompute_gradient_gp_temporal_autodiff, three hand-writtenad::Vartranscriptions of the density thatresolve_gradient_fnnever returned and nothing else named – a copy no test could reach and no assertion could hold to the original. 2260 lines net.What the harness asserts changes with it: comparing
compute_log_posttocompute_log_post_impl<double>is now comparing a function with itself, so that check is replaced by two that still have content. The density must not depend on how many threads sum it, which is what the newn_threadsargument tocpp_gradient_checkreaches; and the value each mode reports must be the density its gradient describes, which holds the fusedskip_obs_looppath and thearena::Varinstantiation to thedoubleone.Across twenty-two structures every entry of the analytic gradient is bit-identical to before, and the density moves by at most 3.7e-16 relative – one to two units in the last place, from summing the same terms in the template’s order. Wall-clock on a 4000-row binomial fit, 400 iterations, before and after: plain GLM 0.59 s / 0.60 s, ICAR + RW1 16.5 s / 15.7 s, collapsed ICAR + RW1 320 s / 281 s, crossed random effects 46.7 s / 44.1 s.
test-temporal.R’s AR1 fit asserts a correlation its fixture identifies. It readmean(rho_samples) > 0on ten time points, where the posterior for rho is nearly its prior: sd 0.32 around a mean of 0.07, with a 90% interval of [-0.54, +0.69] even at 8000 iterations. The sign of the mean over 100 retained draws sat inside its own Monte Carlo error, and across five seeds it came out anywhere from -0.03 to +0.40, so what the assertion turned on was which trajectory the sampler happened to take. The fixture now carries eighty time points, where the posterior mean is +0.47 or better across eight seeds against 0 for a chain that left rho at its prior, and the threshold says so. The strengthened fixture passes on the code either side of #28.-
The templated log posterior expresses the HSGP multi-scale GP and the non-centred GP (#26). These were the two structures
log_post_impl_gap()named as expressible but unwritten, and both were reachable from R:spatial_multiscale(approx = "hsgp")andspatial_gp(parameterization = "noncentered"), the latter the default coordinate for a GP fit. Declared rather than written, they cost theA_r/A/A_tgradient modes those two models, since the front door refuses a mode whose density is missing a block. The multi-scale case was a missing block of the kind #18 fixed for the plain HSGP field – the parameters would carry no prior and contribute nothing to the linear predictor – and the non-centred case was worse than missing: the templated density read the parameter slot aswwhen it holdsz, so it evaluated a different model rather than an incomplete one.Both are now written against the same primitives the analytic density uses, rather than transcribed beside them.
hsgp_evaluate_t()is one spectral expansion for every scalar type, with the Eigen matvec as itsdoublearm, and the plain HSGP field, the SVC terms and both multi-scale scales now read it instead of carrying three inline copies off = Phi (sqrt(S) . beta).NNGPNCWorkspaceandnngp_nc_forward()are templated, so thez -> wrecursion has one definition rather than one per scalar type, and the conditional-variance floor isratiod_cov::nngp_floor_cond_varon both. Measured against central differences ofcompute_log_post, all three autodiff modes now agree to 5.7e-09 relative ongp_ncand 2.0e-09 onmsgp_hsgp, with no identically-zero gradient entry, and the two densities agree to 5e-13. Both fields move from the gap list into the harness’s kernel sweep, where they are checked underhandcodedandarenaand included in the density-equality and fused-value assertions. temporal_iid()reaches the standalone temporal field, andtemporal_ar1(parameterization = "noncentered")reaches the AR1 one (#40).TemporalType::IIDhad a branch intemporal_log_prior()and in its templated twin, but no R constructor and no"iid"case in the C++ parser, so nothing could select it; the H-mode and A/A_r branches had no IID case at all and would have contributed zero. A complete non-centred AR1 reparameterization –ar1_nc_forward,ar1_nc_log_prior,ar1_nc_gradient– sat inhmc_temporal.hwith no callers anywhere. Both are now wired:temporal_effects()reconstructs the effects wherever a density or a gradient reads the block, andtemporal_gmrf_prior_grad()writes the gradient in whichever coordinate the sampler moves in. Wiringar1_nc_gradientup showed it was wrong: itslog_tauandlogit_rhosums paired the backward adjointadj[t], which carries the AR1 recursion backward, with a forward derivativedphi[t]/dthetathat already carries the same recursion, counting it twice. Against central differences of the log posterior the two hyperparameters were off by 12% and 8% while all ten field entries matched to 4e-10; the correct pairing is the direct partialgrad_phi_lik[t], which the function’s ownT == 1branch already used. All four gradient modes now agree with finite differences ontemporal_iidandtemporal_ar1_nc, both added totest_gradient_check.cpp.-
spatial_multiscale()undermode = "laplace"fits instead of reporting a missinggroup_var(#39).cpp_laplace_fit_multiscale_gpwas a registered export with no caller:fit_laplace_spatial()had no"multiscale"branch, so the model fell through to the areal path and failed on a message about areal grouping.spatial_gp()failed the same way – the areal preparation ran ahead of the GP dispatch – and would have failed again onresult$hessian, whichcpp_laplace_fit_gpnever returned. The two coordinate-based fields now dispatch before the areal preparation, and both run through one mode-finder:laplace_mode_nngp()takes a list of NNGP blocks, so a single-scale GP passes one and a multi-scale GP passes two, replacing two near-identical Newton loops. Three defects went with the duplication: the multi-scale copy ignoredcov_typeand hard-coded an exponential covariance, and wrote a conditional mean from raw covariances rather than the Kriging weights; both copies assembled only the diagonal of the NNGP precision, so Newton converged somewhere other than the mode the reported log-marginal describes; and both mapped observationito locationi, dropping the spatial term entirely for every observation past the last unique location.obs_to_locis now passed in, the Hessian is assembled at the converged point and returned, andhsgpandsvcname themselves inBACKEND_STRUCTURE_SUPPORTrather than reaching a dispatch that would fit the model without the field.The Laplace NNGP path also read R’s 1-based
nn_orderas 0-based, writing one past the end of the field block on the last location. Nothing reached it before; it now converts at the call site, as the sampler entry does, andadd_nngp_block_laplace()refuses an ordering outside0..n-1rather than corrupting the heap. Multi-scale HSGP is reachable (#38).
data.msgp_is_hsgpselected a complete C++ branch that swapped the two neighbour sets for one shared Hilbert-space basis, and no R function could set it –spatial_multiscale()had noapproxargument, and the internal validator the dispatch resolved to raised an error saying so.spatial_multiscale(approx = "hsgp", m =, c_boundary =)now selects it, the neighbour computation is skipped on that path, andmsgp_hsgpjoins the finite-difference harness. On 90 observations withm = 8it fits in 3.2 s against 14.3 s for the neighbour sets. The templated log posterior still declares the gap it declared before, and now also skips the block rather than reading neighbour sets sized for the locations against a parameter block sized for the basis.-
spatial_multiscale(sampler =)offers the two strategies that exist (#36). Five of its seven names –"noncentered","centered","interweaved","adaptive","riemannian"– produced byte-identical sampling: the field is centred at every site that reads it for the density or the gradient, and the only enumerator read anywhere selects L-BFGS warmup adaptation. The argument now takes"auto"and"lbfgs", which is what it selects between, and the enum’s documented default no longer claims a parameterization the code does not carry. A non-centred coordinate for the multi-scale field is filed separately.make_msgp_gp_views()existed twice, and the copy feeding the gradients leftnn_neighbor_distempty. Its current callers do not read that field, but the non-centred NNGP helpers do, so the two are now one builder that fills every fieldGPDatadeclares. spatial_gp(solver =, cg_tol =, cg_maxiter =)are gone (#37). The CG / PCG / GPU dispatch inhmc_gp_cg.hwas complete and had no callers – the header was not#included anywhere, so it was never compiled. Routing the sampler through it would have made the package slower: the systems it solves are thek x kneighbour covariances, and CG needs aboutkiterations on a dense covariance rather than converging early. Measured against the Cholesky already running, on exponential-covariance neighbourhoods in the unit square, 10,000 solves each: k=5 3.1x slower, k=10 2.3x, k=15 (thenndefault) 3.1x, k=30 (thenn_regionaldefault) 2.4x, k=50 2.8x, returning a 1e-6 solution rather than a machine-accurate one. The N-scaling of an NNGP isO(N k^3)and the inner solver does not touch N, so"cg"’s documented case – “better for large N (> 2000)” – does not hold."gpu"had no path either: nothing in the package builds CUDA, and its own caller commented the GPU branch out. The three arguments and the dead header are removed;gpu_available()andgpu_info()are unaffected.An areal spatial field paired with
temporal_multiscale()now gets its multi-scale block (#74).cpp_hmc_fitsethas_multiscale_temporalfrom the temporal bundle and then cleared it about a hundred lines later, among the feature flags that belong tocpp_hmc_fit_gp.compute_param_layout()reads that field to allocate the trend, seasonal and short-term blocks, so it allocated none of them while R went on naming their columns. The combination is reachable becauseuse_gp_samplerisis_gp_spatial(spatial) || (is_multiscale_temporal(temporal) && !has_areal_spatial): a multi-scale temporal term with an areal spatial field is the one case that routes tocpp_hmc_fit. Measured onnum | denom ~ xwithspatial_car(level = "group")andtemporal_multiscale("year", trend = "rw1", short_term = "ar1"), 6 units by 12 times, poisson_gamma, 100 iterations: R named 39 columns, 27 of them the multi-scale block’s, and those 27 were not a posterior –sigma2_trendandsigma2_shortspanned[0, Inf]with a standard deviation ofNaN,rho_shortreached exactly -1 and 1 though it is logit-mapped onto the open interval, and the field columns had standard deviations in the thousands. All 27 now respect their own priors.test-temporal-multiscale.Rchecks the block against its prior support the waytest-gp-sampler-blocks.Rdoes, and fails 29 assertions without the fix.The GP sampler entry carries the random-effect block it was handed (#72).
cpp_hmc_fit_gpsetn_re_terms = 0andhas_re_slopes = falseunconditionally, and the R backend handed itre_group/n_re_groupsalone rather than there_paramsbundlecpp_hmc_fitreceives.prepare_hmc_data()andhmc_param_layout()above it parsed and named the slope and multi-term structure regardless, so the two layouts disagreed on the size of the RE block and every column after it was read at the wrong offset. Measured onnum | denom ~ x + (1 + x | g)withspatial_gp(~ lon + lat, nn = 3): R named 28 columns, the sampler wrote the legacy block instead, and the column labelledphi_gpran to 2.37e+149 against a uniform prior on(0.01, 100). It now reports 4.08 to 98.10. Affects random slopes (correlated and uncorrelated) and crossed / nested multi-term random effects onspatial_gp(),spatial_multiscale(),spatial_hsgp(), and on a multiscale temporal model with no areal spatial term.The AR1 temporal rho prior reaches the GP sampler entry (#73). That entry’s temporal block read
type,time_idx,group_idx,n_times,n_groups,n_params,cyclic,shared,tau_shapeandtau_rateand stopped there, so therho_prior_a/rho_prior_bthe R backend put on the list crossed the.Callboundary and were never assigned; the fit ran onModelData’s default anchors. Measured onnum | denom ~ xwithtemporal_ar1("year"), 6 groups x 10 times: the posterior mean ofrho_ar1underprior_beta(2, 2)andprior_beta(50, 1)was bit-identical on a GP main effect and moved from -0.071 to 0.972 on an areal one. The GP arm now moves -0.103 to 0.933.Both bundles are assembled by one builder each entry point calls (
apply_re_params/apply_temporal_params,src/hmc_model_data_blocks.h), the way #70 did for the spatiotemporal, latent, TVC and SVC bundles, and the R backend builds them once above the branch. A field read by one extraction and not the other is what both of these were.The random-effect gradient is written once (#72). With the block reaching the sampler,
resolve_gradient_fn()has to keep such a model off the specialized functions, which read the legacy single-term block: crossed and nested terms now raise their own feature bit (GF_RE_MULTI) alongsideGF_RE_SLOPES, and both fall through tocompute_gradient_composite. That function carried a partial copy of the block – no Cholesky factor, no LKJ prior, nore = diag(sigma) L ztransform – so a correlated slope beside a GP field reached it with the slope scale’s gradient at zero against a finite difference of 0.409, and the runtime gradient check fell back to numerical. The prior phase and the chain rule are nowre_blocks_prior_grad()/re_blocks_writeback()(src/hmc_re_blocks_grad.h), called bycompute_gradient_analytical()and by the composite alike, withre_blocks_eta()/re_blocks_scatter()for the observation loop. Three new finite-difference fixtures cover what could not be built before:gp_slopes_corr,gp_crossed, andgp_slopesat the composite it now dispatches to.
tulpaRatio 1.7.1
A GP spatial main effect carries the blocks beside it (#70).
is_gp_spatial()routes the whole model tocpp_hmc_fit_gp_v2, which was handedgp_params,ms_gp_params,ms_temporal_params,rsr_params,temporal_paramsand the ZI arguments and nothing else. A spatiotemporal interaction, a latent factor, a TVC or an SVC paired withspatial_gp(),spatial_multiscale()orspatial_hsgp()was dropped from theModelDatathe sampler ran on, whileinitialize_hmc_params_full()andhmc_param_layout()above it still allocated and named that block’s parameters: every column from the dropped block onward was read at the wrong offset and reported under the wrong name. Measured on 6 units x 5 times withspatial_gp(~ lon + lat)andspatiotemporal(type = "separable"),phi_st_spacecame back on(22.7, 43380.7)against a uniform prior on(0.01, 10)whose density is-Infoutside those bounds. The four bundles are built once, above the sampler branch, and both entry points assemble their blocks through one set of builders (src/hmc_model_data_blocks.h) rather than each writing its own extraction.spatiotemporal()no longer refuses a GP main effect.A specialized gradient declares the blocks it writes (#71).
resolve_gradient_fn()selected a hand-coded gradient by a chain of negations, one per feature per function, and several of them omittedhas_spatiotemporal, so a model carrying an interaction reached a function that never writes it. Each entry now names the blocks it handles and the model’s own blocks are one mask, so a model carrying anything outside that set falls through to the composite; adding a block toGradFeatureis a block every entry that does not name it falls through on. Three combinations the negations could not express fall out of it: a GP margin and a GMRF margin are different temporal blocks, a multiscale temporal term is not an SVC term, and no specialized function writes the random-slope block.The composite gradient is a catch-all for every sampled field. It had no NNGP arm at all – neither the GP main effect, the multi-scale GP, nor the NNGP approximation of an SVC – so a model routed to it left those blocks at zero while
compute_log_post()carried them into eta. All three are written from the sameratiod_gp/ratiod_svcentry points the dedicated kernels use. A collapsed field alongside a second block routes to the numerical gradient of the density, its marginal being an inner Laplace at a mode that moves with every other block in eta.Twelve
cpp_gradient_check()fields cover the combinations, each held to central differences at 1e-8 to 1e-7, andcpp_gradient_dispatch()reports the function a model resolves to so the mask is asserted directly rather than inferred from whether a gradient happens to come out right.test-gp-sampler-blocks.Rfits three GP-plus-block models end to end and reads the draws against the prior support the densities refuse outside of.
tulpaRatio 1.7.0
The GP spatiotemporal interaction exists (#68, #69).
type = "separable"reached the sampler and segfaulted on the first log-posterior evaluation, in every gradient mode.SpatiotemporalData’s NNGP members –nn,nn_idx,nn_order,nn_dist_space,nn_dist_time,coords,time_values,nonsep_type,cov_space,cov_time– were never assigned by anything:st_paramsdid not carry them and the sampler entry did not write them, sost_gp_nngp_log_lik()readnn_order[0]on an empty vector, and the four scalars among them were indeterminate rather than unset. Both types are now built end to end. The interaction carries its owncoords, so an areal main effect pairs with a continuous interaction over the same units;nonsep_type,cov_space,cov_timeandnnare its own too, andtype = "nonsep_gp"is reachable from the front door for the first time.That interaction now has a gradient (#68).
compute_gradient_compositeandcompute_gradient_spatiotemporal_handcodedboth skipped the GP types entirely, eta included, so a fit would have moved the block under nothing but its sum-to-zero penalty and computed every other block’s gradient at a linear predictor missing the interaction.st_gp_nngp_grad()(src/st_prior_grad.h) writes the field, its precision and both ranges, sharing the conditional decomposition with the density throughst_gp_nngp_scan()so the two cannot disagree about what the prior is.sigma2factors out of the neighbour block, which collapses its gradient to a closed form with no extra solve; the two ranges take one further pair of triangular solves against the factor the scan already built. Checked against central differences at1e-7to1e-5on four newcpp_gradient_check()fields (stgp,stgp_matern,stgp_gneiting,stgp_latent), on both the analytic and the templated density.The interaction’s covariance is one definition (#68). The GP branch carried its own Cholesky, its own two triangular solves and its own conditional-variance floor, a fifth copy of what
ratiod_cov(src/hmc_cov.h) holds for every other NNGP path; it now reads that one. Each arm is written as a correlation withr(0, 0) = 1and scaled bysigma2once, so the marginal variance is what the PC prior is a prior on and the neighbour block’s diagonal is the kernel’s own value rather than a constant written beside it.cpp_test_st_gp_log_lik()drives the shipped density against a dense multivariate normal built from the kernel formulas, atnn = n - 1where the NNGP is exact.Two non-separability names are gone, for different reasons (#68). Cressie-Huang (1999) had no branch of its own and was evaluated as the product covariance. The additive kernel
C_s(h) + C_t(u)cannot be an interaction covariance on a complete grid: its rank there isS + T - 1ofS * T, and every direction it drops is an interaction – measured on a 6 x 5 grid, rank 10 of 30 and smallest eigenvalue -1.6e-15, against condition numbers of 8.4 and 10.8 for the product and Gneiting arms.spatiotemporal_gp()is withdrawn (#68). It emittedtype = "st_gp", a string no other R file read and the sampler entry rejected, and its observation-level index set had no relationship to theS x Tsum-to-zero penalty the density applies.spatiotemporal(type = "nonsep_gp", nonsep_type =, coords =)is the spelling that works, and carries the same knobs.A spatial GP indexed an interaction by row (#68).
build_st_index()sets_idx <- seq_len(N)for a GP spatial component whilen_spatialcounted unique locations, sost_flatran past theS x Tgrid it indexes the moment two observations shared a location. It readsobs_to_loc.compute_gradient_latent_handcodedwas selected for models it cannot write (#71). Its guard checked for no spatial and no temporal main effect and not for an interaction, so a latent factor alongside one returned the latent factors at -2.34 against a finite difference of 0.114. The remaining specialized guards omitting the same check are listed in #71.
tulpaRatio 1.6.0
The spatiotemporal interaction’s AR1 time margin exists (#66).
spatiotemporal(temporal = temporal_ar1(...))allocated a correlation and put a Beta prior on it, and nothing else read it:spatiotemporal_log_prior()opened with(void)rho,type_ii_log_prior()carried a// AR1 would need rho parametercomment and no branch, andtype_iv_log_prior()fell through both stencils, so its Kronecker quadratic form was exactly zero and itsrank_timereadrw2_rank. The field had no temporal prior curvature at all, and the density and its gradient disagreed on the normalizer. The AR1 precisionR(rho)is now the interaction’s time margin on Type II, Type IV and HSGP-ST – the quadratic form and the rho-dependentlog|R| = log(1 - rho^2), differentiated together – andratiod_ar1::ar1_precision_apply()/ar1_quadratic_form()/ar1_log_prior()(src/ar1_shared.h) are the one matrix the scalar AR1 density and the Kronecker margin both read. Measured at the gradient check’s own draw,logit_rho_stwent from an analytic0.000000against a finite-difference-0.299363– the prior alone, since the density had no other dependence – to-9.133676against-9.133675.One spatiotemporal interaction gradient (#66). The two hand-coded gradients that reach an interaction each carried their own copy of every type. They had drifted:
compute_gradient_composite()had no Type II RW2 branch at all and normalized that arm at rankTinstead ofT - 2, no AR1 branch anywhere, and neither wrotelogit_rho_st.st_interaction_prior_grad()(src/st_prior_grad.h) is the single implementation both now call, so a term added to the density reaches both gradients or neither.st_time_rank()is the matching single rank, read by the density, the non-centered rank correction and both gradients.Type I and Type III no longer allocate a correlation. Neither reads a time margin – Type I is iid over the whole grid, Type III’s time margin is unstructured – so the parameter the layout used to allocate for them moved under its own prior and nothing else. A time margin an interaction reads but cannot express (a GP margin under Type IV) is now refused at the door rather than silently run as RW1.
temporal_ar1()’s correlation lives on (-1, 1) (#67). It was mapped onto(0, 1), so a negative autocorrelation was not reachable from that block at all, while the TVC arm, the multi-scale short-term arm and the spatiotemporal interaction all usedrho = 2u - 1.?temporal_ar1documented Uniform(-1, 1) and?priors_defaultdocumented Beta(2, 2), and the code did neither. The mapping is now2u - 1with the shared Beta(2, 2) onu = (rho + 1) / 2, which changes the posterior of every existingtemporal_ar1()fit.ratiod_ar1::rho_from_logit()anddrho_dlogit()are the one map and the one chain rule; the eight hand-written copies of each are gone.temporal_ar1(rho_prior = )is wired (#67). It was stored on the spec and never read again, as wasratiod_priors(rho_temporal = ). Both now reach the density and every gradient as the Beta anchors onu = (rho + 1) / 2, the block’s own prior taking precedence; aspatiotemporal()interaction’s AR1 margin reads its own temporal spec’s prior the same way. Arho_priorthat is not aprior_beta()is refused at the door – which includestemporal_ar1(rho = 0.8), whererhoused to partial-matchrho_priorand store a bare number nothing read.
tulpaRatio 1.5.3
One AR1 correlation prior, read by every density and every gradient (#58). The TVC rho carried a Uniform(-1, 1) prior in
compute_log_postand a Beta(2, 2) one incompute_log_post_impl, exactly onelog u + log(1 - u)apart, so the autodiff modes sampled a different posterior from the handcoded one – 1.408954 in the density and the whole gap in the gradient atlogit_rho_tvc. The prior is Beta(2, 2) onu = (rho + 1) / 2everywhere it applies now, which is whatpriors_default()documents for a temporal correlation and what the multi-scale short-term arm already used; the spatiotemporal interaction’s rho moves onto it from Uniform(-1, 1) as well, so a fit carrying one shifts by that term.ratiod_ar1::log_prior_logit_rho()and its gradient (src/ar1_shared.h) are the single expression all four correlations read – the two densities, both handcoded gradient functions and the multi-scale arm – with each site naming its own(a, b), so the Uniform(0, 1) the plaintemporal_ar1()block carries is the same helper at(1, 1)rather than a second spelling. Four unreachable copies of the prior inhmc_tvc.h,hmc_tvc_autodiff.handhmc_temporal_multiscale.hare gone.tvc_ar1joins the structures intest-gradient-correctness.R, whose density-equality assertion it now answers.One floored
1 - rho^2(#59). It was clamped at1e-10in five places, clamped at1e-12in three, added as+ 1e-10in five more, and left unfloored in eight – including the AR1 marginal variance, its templated twin, the temporal GP’s two conditional variances and the Laplace path.+ 1e-10andmax(., 1e-10)are different functions, so paths that must agree parted company wherever either bound: at a correlation near the edge of its range the two densities were 3.14 apart ontemporal_ar1, 27.2 ontvc_ar1and 1.9e-03 onms_temporal, and one step further outcompute_log_postreturned-Infwith a non-finite handcoded gradient while the templated density stayed finite.ratiod_ar1::one_minus_rho2()is the one clamp every site reads.cpp_gradient_check(near_unit_rho = TRUE)places each sampled correlation where the floor binds, which nothing in the sweep reached before.Two defects found while testing the above are filed rather than folded in: no gradient function writes
logit_rho_st, so a Type IV interaction with an AR1 time margin moves its rho under a flat gradient and fuses a log posterior missing the prior (#66); andtemporal_ar1()’s rho lives on (0, 1) while its documentation describes (-1, 1), withrho_priorinert (#67).
tulpaRatio 1.5.2
PG GP fits carry their spatial field, so they can predict one (#63).
convert_pg_gp_to_ratiod_fit()read the field draws off"w_gp"while the sampler returns them under"gp", and[[on a missing name isNULL, so.internal$w_gpwasNULLon every such fit andpredict_spatial_gp_pg()returned nothing without saying so.fitted()was unaffected – it reads the linear predictor the sampler stores directly. The(sigma2, phi)draws that go with the field were also read from the first chain alone while the field spanned every chain, so a multi-chain fit paired half its draws with nothing; both are stacked in the same chain order now and the kriging entry refuses a pair that is not row-aligned.A GP prediction is made in the units its fit was made in.
spatial_gp(scale_coords = TRUE), the default, centres and scales the coordinates before the neighbour structure is built, andscale()puts the centre and scale on an attribute that subsetting the matrix drops. Nothing recorded them, socoords.0was read raw against a scaled training set on every backend: kriging the training locations of a fit back onto themselves recovered its own field at correlation -0.01.gp_scale_new_coords()is the conversion, and the same fixture now reads 0.95.One kriging implementation, and one covariance family behind it.
kriging_predict()andcov_function()were an R triple loop over (draw, location, neighbour pair) and an R copy of the covariance family that knew three of the four kernels, resolving"spherical"to an exponential one – the same defect fixed in the compiled family at 267f2cb, in the other copy.cpp_kriging_predict()replaces both: it readshmc_cov.h, the family every fitting path reads, and takes the whole draw matrix at once, so the neighbour sets are found once rather than per draw. The HMC and PG prediction paths both go through it.cov_type_code()is the one name-to-code mapping. Three copies of it in R each ended in a bare0L, so a covariance name none of them knew ran exponential; one of the three was missing"spherical"outright. The C++ side meets it atratiod_cov::cov_type_from_int()incov_type_code.h, which the PG entries now share, and both ends refuse a value they do not know.
tulpaRatio 1.5.1
The PG binomial GP sampler now pairs observations with locations by coordinate rather than by row position (#60).
spatial_gp()reduces the data to unique locations and builds the NNGP on those, butcpp_pg_binomial_gibbs_gp()was never given the map back: it aggregated the per-location Polya-Gamma likelihood over row position and discarded every row pastn_spatial, and it wrote the field into only the firstn_spatiallinear predictors. On the four-observations-per-location design used to measure it, 75 of 100 observations reached no location at all, the 25 that did were credited to locations by row order, and the field never entered three quarters of eta.obs_to_locis now passed and read on both sides. The coordinates handed to the sampler were also the observation-order matrix rather than the unique locations the neighbour structure indexes, so the neighbour blocks were assembled from the wrong points;unique_coordsis what goes in now. Recovery of the true field over three seeds: correlation 0.95, 0.98, 0.98.sigma2_gpandphi_gpare drawn from the NNGP density the field was sampled under (#62). Thesigma2acceptance ratio was built from an independentN(0, sigma2)quadratic form carrying no log-determinant, so raisingsigma2only ever reduced the penalty and the ratio pointed one way at every value;phi’s ratio was the log-scale Jacobian alone, sophiwas drawn from its prior rather than its posterior. Both now differencesum_k log N(w_k; mu_k, d_k)over the sampler’s own conditioning order, evaluated by the same conditional the field sweep draws from. Thesigma2prior’s change of variables was also carrying a fulllog(sigma2)where the PC prior onsigma = sqrt(sigma2)gives+0.5 log(sigma2)once the walk’s Jacobian is included. Against a truesigma2of 0.7, posterior medians over three seeds are 0.36, 0.60 and 0.65, where the issue measured[94, 1746]and[24.9, 11568].spatial_multiscale(cov = )is read by the multiscale PG sampler (#61).cov_typewas accepted bycpp_pg_binomial_gibbs_multiscale_gp()and never mentioned again; an exponential kernel was written out inline at six sites, so three of the four documented choices silently ran the fourth. Both scales now go through the samepg_nngp_conditional()the single-scale entry uses. That also replaces what stood beside the kernel: the conditional mean was a raw covariance-weighted sum of the neighbours in place of the NNGP weightsC^-1 c, the conditional precision was1 / sigma2in place of the conditional variance,nn_idxwas indexed by location and its values read as locations where both are positions in the conditioning order, and each scale’ssigma2was a conjugate draw under an independentN(0, sigma2)form that read the PC prior’s upper bound as an inverse-gamma rate and its tail probability not at all.One convention, checked at the entry.
PgNngpGraphcarries the neighbour structure the two entries share,pg_check_nngp_graph()refuses ann_orderthat is not a 0-based permutation of the locations, ann_idxentry that is not an earlier position in the order, and a coordinate matrix that is not one row per location, andpg_check_obs_to_loc()does the same for the observation map. The 1-basednn_orderfixed in 267f2cb was a silent out-of-bounds read; it is now an error naming the argument.
tulpaRatio 1.5.0
One walk of the sampler’s parameter vector now feeds
fitted(),predict(),ratio(), the draws matrix and the per-structure extractors (#44). There were four copies of that walk. Three of them were thinner than the fourth: on the default non-centred parameterization they added the rawzto the linear predictor instead ofsigma_re * z, so every fitted value and every prediction on a model with random effects was off by the posterior draw ofsigma_re; they recognised one intercept-only random-effect term, so a crossed or slope-carrying model read parameters that were not random effects at all; they recognisedicarandbym2among the spatial types and dropped every other field from the predictor without saying so; and they applied the logistic link to every family, reporting a number in (0, 1) where a count ratio belongs.fitted(fit, component = "ratio")andratio(fit)now return the same draws, and so doespredict(fit, newdata = fit$data).The block order the R side reads is the order the sampler writes (#44).
hmc_param_layout()(R/hmc_unpack.R) mirrorscompute_param_layout()insrc/hmc_sampler.cppblock for block, and is the only place an offset is computed. The previous walk placed SVC before the areal field, the GP before the temporal block, TVC before zero-inflation and HSGP among the spatial types, where the sampler lays them out in a different order; a fit combining two of those structures had its columns named after the wrong parameters from the first mismatch onward. It also walked a temporal block for a multiscale temporal model, which the sampler allocates none for. Every fit now checks that its layout accounts for exactly the columns the sampler returned, and errors rather than mislabelling them.svc(),tvc(),temporal()andspatiotemporal_effects()return their draws instead of erroring on every fit (#46). No backend stored the draws they read. The converter now stores each structure’s own draws, shaped the way its extractor indexes them, and thesvc()/tvc()error text names the argument that routes them (spatial = spatial_svc(...),temporal = temporal_tvc(...)) rather than an argumenttratio()does not have. The four\dontrun{}examples that showed the wrong spelling are fixed.tvc()gained agroupargument, since the draws carry one trajectory per grouping level.pp_check()plots a fit (#48). It readobject$draws$y_num_rep, andobject$drawsis a matrix on every backend, so the call raised$ operator is invalid for atomic vectorson its second executable line and never reached the guard written to explain the missing draws. The replicates are now drawn rather than looked up:posterior_predict()simulates a numerator and a denominator response from each posterior draw’s fitted means and that draw’s dispersion, through the same family machineryprior_predict()uses.as_draws(),pp_check()andposterior_predict()are methods on the generics that own them, not new generics (#52). They were bareUseMethod()calls, so attaching tulpaRatio after posterior or bayesplot masked their generic and a user’sas_draws()on adraws_matrixorpp_check(y, yrep)on plain vectors dispatched into this package and found no method.posterior::as_draws,bayesplot::pp_checkandtulpa::posterior_predictare imported and re-exported instead; bayesplot moves from Suggests to Imports.spread_draws(),gather_draws()andpoint_interval()are renamed todraws_wide(),draws_long()anddraws_interval()(#52). tidybayes owns those three names and takes different arguments under them (variable[index]specifications rather than bare parameter names), so a method on its generic would not have honoured the contract. The old names are removed.The offline gradient check can build a model for every kernel the dispatcher can select (#45).
cpp_gradient_checkcovered the areal fields, the random-effect layouts, HSGP and TVC; it could not build a GP, multi-scale GP, SVC, temporal-GP, multiscale-temporal or latent-factor model, so the handcoded gradients those run on under the default mode had no finite-difference case.make_model()now buildsgp,gp_collapsed,gp_temporal,msgp,msgp_temporal,svc,svc_hsgp,temporal_gp,ms_temporal,latent,tvc_ar1andtemporal_ar1, and an unrecognised field name is an error rather than a model with no structured field in it, which would pass while testing nothing. The temporal block’s AR1 arm carries a rho that its RW1 and RW2 siblings have no counterpart for, so the two cases that reached it before covered none of the terms that rho enters.The runtime gradient check differences away from the origin (#45). Every structured block starts at exactly zero, where a quadratic-form prior contributes nothing to any gradient: a wrong precision matrix, a sign error on it and an absent sum-to-zero penalty are all invisible there. The check now moves each field block off zero by a small deterministic offset first. Both call sites share one function, so a mismatch raises an R-level warning from the multi-chain path as well as the single-chain one.
The PG binomial GP sampler indexed one past the end of every per-location array, and could take R down with it.
backend_pg.Rpassednn_order1-based while the C++ uses its values directly as indices intow,coordsand the per-location likelihood accumulators, all sizedn_spatial, so each could reachn_spatialitself.backend_hmc.Rconverts the same vector with- 1L; the PG path did not. The symptom wasSTATUS_HEAP_CORRUPTION(0xC0000374) killing the R process, reached at some parameter states and not others – 20 locations crashed atnn = 5andnn = 6while 25 locations survived – and, where it did not crash, the GP effects were attached to the wrong locations.spatial_gp(cov = )runs the kernel you asked for undermode = "pg"(#35). The selector testedcov_type0 and 1 and sent everything else to an unconditionalelsecomputing Matern 2.5, so"gaussian"and"spherical"– both documented andmatch.arg-validated – ran a kernel the user did not choose. It now routes through the sharedratiod_covfamily introduced above, and an unrecognised code is an error rather than whichever kernel a fallthrough reaches. The neighbour block goes through the shared factorization at the same time, replacing a seventh distinct regularization (a1e-10value substituted into the pivot). Measured on a 12-location fixture, against an NNGP conditional written out independently in R: all four kernels now agree to machine precision, and at the probed location the conditional mean moves by 0.11 for"gaussian"and 0.34 for"spherical"against the Matern 2.5 both used to return.tratio(mode = "pg")honourscontrol$seedandcontrol$chains.fit_pg_binomial()accepts both and threadsseeddown to a per-chainset.seed(seed + chain - 1), buttratio()called it without either, so PG fits ran off whatever RNG state they inherited – two runs of the same call differed by 1.23 on the intercept and 3.19 onsigma_re– and always used the backend default of 4 chains whateverchainssaid. Runs at a fixed seed are now bit-identical.-
spatial_gp(parameterization = "noncentered")gets its handcoded gradient back (#57). Two defects made it disagree with the density it reports, andresolve_gradient_fnsends every GP model at the default gradient mode tocompute_gradient_gp_handcoded, so this was the ordinary path for such a fit rather than an opt-in kernel.nngp_nc_backwardread aC_matit never filled – the comment beside it said it deliberately did not rebuild one – which made everydC/dphientry zero and so dropped both the-dC * alphaterm fromdalphaand thealpha' dC alphaterm fromdd_dphi; and the caller subtractedzafternngp_nc_backwardhad already seededgrad_zwith-z, applying theN(0, 1)prior twice. Measured on the harness: worst relative deviation4.610e-01atlog_phi_gp, flat across difference steps from 1e-3 to 1e-7, and the residual on every one of the 25zentries equal to-zto 1.1e-07 with a fitted slope of 0.99999997. Both now measure0.000e+00with a V-shaped sweep.The fits themselves were slow rather than wrong: the runtime gradient check introduced in this release catches the
log_phimismatch at its probe point and falls back to numerical gradients, which is what a non-centred GP fit had been running on. That fallback no longer fires. Before this release the check differenced at the origin, wherez = 0makesw = 0and both defects contribute exactly nothing, so it saw neither. One covariance family and one neighbour-block factorization behind every NNGP path (#42, #55, #56). The GP path, the SVC path and their two templated twins each carried a copy of the four kernels and of the small dense Cholesky over a neighbour set, and the copies had drifted apart on four separate numbers: the Gaussian kernel was written with and without the 0.5 in its exponent, Matern 3/2 carried
sqrt(3)exactly in two of them and rounded to1.732050808in the third, one had no spherical case and fell through to exponential, and the neighbour block was regularized four different ways (1e-8always,1e-8only once a pivot had already gone non-positive, a1e-6floor substituted into the pivot,1e-4always) with the conditional variance floored three more (1e-10,1e-6, and a1e-4blend keeping 1% of the gradient). The analytic gradient paths factorized the block a third time again, with no ridge and a pivot replaced by1e-5. So the value a fit reported and the gradient it moved on came from different copies. Measured before: the two densities were 30 nats apart on the NNGP SVC and 0.086 apart on a Matern GP, with the deviation flat across difference steps from 1e-3 to 1e-7. Everything now routes throughsrc/hmc_cov.h, templated once for the double evaluation and thead::Var/fwd::Dual/arena::Vargradients alike; acrossgp,gp_matern,gp_gaussian,gp_spherical,gp_temporal,msgp,msgp_temporal,svcandsvc_hsgpthe two densities now agree to the printed digit and every gradient matches central differences of both to 2e-06 or better.cov = "gaussian"isexp(-0.5 (d / phi)^2)on every path. The analytic path evaluatedexp(-(d / phi)^2)while taking itsphiderivative from the other form, so it was running on half the derivative of the density it reported; the templated path already used the 0.5. The 0.5 is what makesphia lengthscale here as it is for the other three kernels – it is the standard squared-exponential and thenu -> infinitylimit of Matern at a fixedphi– so the shared kernel keeps it. Aphiestimated by a previouscov = "gaussian"fit under a handcoded gradient mode corresponds tophi / sqrt(2)now; a fit underA,A_rorA_tis unchanged.cov = "spherical"is differentiated rather than silently run as exponential (#42). Neither analytic derivative had a spherical case and the templated dispatcher had no spherical kernel, so both fell through to the exponential arm.dk/dphi = 1.5 sigma2 (r - r^3) / phiis written out, which is whydcov_dphinow takessigma2– it is the one case not expressible through the covariance value. An unrecognised covariance name is an error at both string parsers, where the GP one used to map anything it did not recognise to spherical and the SVC one to exponential.A TVC AR1 field’s
log taugradient had the wrong sign on its stationary normalizer (#43). The AR1 prior parameterizes the stationary variance as1 / (tau (1 - rho^2)), so-0.5 log(2 pi var)differentiates to+0.5inlog tau;ar1_grad_log_tauseeded the sum at-0.5, leaving the reported gradient short by exactly 1 at every value of every parameter (4.46185 against a finite difference of 5.46185 on the harness fixture). The three AR1 gradient functions,rw1_grad_wandrw2_grad_walso read past their field on a block too short to carry an increment or a second difference; those priors are flat there, and they now return that instead.The fused log posterior of the multi-scale GP and latent-factor gradients carries the binomial coefficient.
compute_obs_ll()dropped it whilecompute_log_post()adds it back, so those two kernels reported a value 7156 nats away from the density their gradient describes on a binomial fit. NUTS consumes that value aslog_prob.Prediction maps a new data set onto the fit’s own grouping levels through the same construction the formula parser used (
as.factor(), orinteraction()for a nested term). It previously matched againstunique()order, which differs from the factor’s level order whenever the levels are not in order of first appearance, and attached the wrong group’s effect.
tulpaRatio 1.4.3
Zero-inflation and hurdle terms on count responses now reach the templated log posterior, so
mode = "ess"and theA/A_r/A_tgradient modes stop silently fitting a plain Poisson/NegBin (#34).compute_log_post_implreaddata.zi_typeonly inside itsModelType::BINOMIALarm. The three count-response arms –NEGBIN_NEGBIN,POISSON_GAMMAandNEGBIN_GAMMA– called the plainlog_lik_negbin/log_lik_poissonwhateverzi_typesaid, while still allocating and sampling a ZI coefficient against a likelihood that did not contain it. That density is the one ESS evaluates and the one the three autodiff gradient modes differentiate, sotratio(..., family = ratiod_zinegbin(), mode = "ess")(andratiod_zipois(),ratiod_hurdle_negbin(),ratiod_hurdle_pois(), or an explicitzi = zi_negbin()and friends) completed with no error and returned a zero-inflation logit drawn from its prior. Measured on the finite-difference harness, the templated density returned the same number forzi_poisson,hurdle_poissonand no zero-inflation at all, and disagreed with the analytic density by between 1.7 and 46 log units across the six family/structure combinations; both now agree to 1e-11, and the analytic ZI gradient – which nothing had ever checked on a count response – matches central differences to machine precision in thehandcodedandarenamodes alike.hmc_zi.h’s count-response densities are templated on the scalar type rather than duplicated, socompute_log_postandcompute_log_post_impl<T>evaluate one implementation.make_model()intest_gradient_check.cppgained aziargument and thenegbin_negbinandnegbin_gammafamilies, since it previously hardcodedZIType::NONEfor every call site and no test combinedmode = "ess"with any ZI family.temporal_tvc(structure = ...):"iid"unblocked,"gp"now errors instead of silently downgrading to RW1, and the Gibbs backend now rejects anything but"rw1"instead of silently ignoring the argument (#32).TemporalType::IIDhad a complete log-prior and gradient in both the H and autodiff paths (tvc_term_log_prior,tvc_prior_gradients_ws, and the templated density viahmc_tvc_autodiff.h’susingdeclarations), and the C++ string parser already recognized"iid"– buttemporal_tvc()’smatch.argonly offered"rw1"/"rw2"/"ar1"/"gp", so no R call could ever reach it.structure = "iid"is now accepted, verified againsttest_gradient_check.cpp’s finite-difference harness in both H and A_r modes (0 deviation), and recovers a simulated intercept and slope intest-recovery-constrained-fields.R. Separately,"gp"was accepted and documented as a first-class option, butTVCDatahas no GP fields at all (no lengthscale, no covariance branch) – the C++ parser silently mapped any unrecognized string,"gp"included, to RW1, sotemporal_tvc(..., structure = "gp")ran to completion and silently fit an RW1 varying coefficient.temporal_tvc()now errors immediately ifstructure = "gp"is requested, rather than reaching the sampler at all. Independently, the Gibbs backend’s TVC update (# ---- 5. Update TVC coefficients via univariate MH with RW1 conditional proposal ----) is hardcoded to RW1 and never read thetvc_structurefield R was already sending it, somode = "gibbs"silently ran RW1 regardless of whatstructureasked for; the Gibbs backend now rejects any TVC structure but"rw1"with a clear error pointing atmode = "hmc". The C++ side’s own duplicated string-to-enum parser inhmc_sampler.cppwas replaced with a call to the existingratiod_tvc::parse_tvc_structure(), which had no callers before this.spatial_car(..., proper = TRUE)now fits a proper CAR instead of silently downgrading to ICAR (#31). The R object already computed rho bounds and printed “Proper CAR”; a complete C++ implementation (hmc_car_proper.h) already existed too. Neither was wired to the other – every backend collapsedtype = "car_proper"to"icar"before it reached C++ (# Handle all ICAR-type models (car, car_proper both use ICAR parameterization)), sorhostayed fixed at 1 and the roxygen example’s claim thatsummary(fit_car)shows arho_spatialparameter was false. The HMC backend now threadsrhothrough as its own parameter (Q(rho) = D - rho*W,Beta(1,1)prior by default, logit-transformed), added toSpatialType,ParamLayout, and the sharedspatial_gmrf_prior_grad/compute_log_postmachinery thatcompute_gradient_compositeand every H gradient mode already read from.phi’s gradient is exact;rho’s log-determinant term has no cheap closed form (it needstrace(Q^-1 W)), so it is central-differenced on that one scalar and chain-ruled to logit scale, verified againsttest_gradient_check.cpp’s finite-difference harness (0 deviation across all 29 parameters on thecar_properfield). Proper CAR is full rank forrho < 1, so unlike ICAR/BYM2 it is not hard-centered. Autodiff gradient modes (A,A_r,A_t) refuse at the front door with a clear error rather than differentiate a density missing the field’s prior entirely, since the log-determinant is not yet expressed in the templated density;mode = "hmc"with the defaultgradient_mode = "auto"(or"H") is unaffected. Gibbs already rejectedcar_proper(its type whitelist never included it); Laplace, PG, VI, SGHMC, and ESS now reject it explicitly too, instead of silently fitting an ICAR model or – for the three backends that callcompute_log_post_impl<double>directly – silently dropping the spatial prior’s contribution to the posterior altogether. Fixing this also surfaced a second, independent bug inpredict()’s areal-prediction path, which readphi_spatialstarting one column early for any type it treated as “ICAR-shaped” oncecar_propergenuinely had an extrarhoparameter in front of it.A spatiotemporal Type IV interaction has a non-centered parameterization, which fixes chains freezing on data with little or no true interaction (#24).
spatiotemporal(..., type = "IV")reachedrhat1.14 at iter=1000/warmup=500 on data simulated with no space-time signal, and a 4x longer run moved that to 2.05 while every chain’s own posterior sd shrank. The interaction’s precisiontauhas no likelihood signal pulling it away from the boundary in that case, so its posterior drifts to very large values (tau ~ 4.3e8 was observed at warmup’s end); under the centered parameterization the field’s conditional scale shrinks as1/sqrt(tau), so exploring both regions needs step sizes that differ by orders of magnitude, and NUTS answered by pinning the trajectory length atmax_treedepthon essentially every iteration and collapsing the step size toward its floor – the signature of a funnel between a variance component and the field it scales, not of a chain that merely needs more draws. A finite-difference check ofcompute_gradient_spatiotemporal_handcoded’s Type IV (Kronecker) block, absent until now, confirmed the analytic gradient was not the cause.spatiotemporal(..., parameterization = "noncentered")samples a tau-freezand reconstructs the interaction asz / sqrt(tau), decoupling the two:rhaton the same repro drops to 1.00 at iter=1000 and stays at 1.00 at iter=4000, with stable per-chain sd and a step size about 10x larger. The non-centered path already existed in the gradient code but was never reachable from R and was itself unchecked against finite differences; the warmup-end precision-informed mass-matrix warm-start specific to Type IV (which reads the raw parameter as the centered field directly) is now skipped under the non-centered parameterization, which it does not describe.parameterizationstill defaults to"centered", since data that do carry real space-time structure movetauaway from the boundary and may not need it.The Gibbs BYM2 sampler no longer leaks the spatial field’s level into the intercept unchecked (#15). The per-site update held the structured field (
phi) to sum zero by moving its mean into the intercept every sweep, unconditionally: no acceptance test weighed that move against the intercept’s own prior.dev_notes/gibbs_prior_recovery.R(nowtests/testthat/test-gibbs-prior-recovery.R) fits with zero binomial trials, where the likelihood is flat and the posterior must equal the prior exactly; under aN(0, 0.5^2)intercept prior the sampled sd came out at 1049 instead of 0.5, a ~2000x inflation. The move now competes for acceptance like every other update that touches the intercept, and gained a dedicated block-level move (mirroring the one the unstructured component already had) so the now-resisted level direction still mixes: BYM2 intercept ESS is 749 at iter=2000/warmup=1000 where it used to need the same bar ICAR clears at 1000/500. The Gibbs priors already matchedcompute_log_post’s (Half-Cauchysigma_total, Uniform(0,1)rho, Gammatau, threaded throughfit_gibbs()viapriors) from prior work on this issue; this closes out the remaining checklist items, including removing the unusedpc_lambdaleft over from an earlier tau update.A per-fit
coresbudget can now reach within-chain work (#17). For a single-chain HMC fit,coresused to be capped atchainsbefore the user ever saw it (min(chains, cpp_get_max_threads())), so a one-chain fit always got exactly one thread for its gradient and likelihood loops regardless of machine size, and the regions that do carry anum_threads()clause (compute_gradient_analytical’s fallback path, thelinalg_fast.hkernels) had no way to reach more than one.coresis now a genuine budget split two ways:concurrent_chains = min(cores, chains)chains run at once, each withcores %/% concurrent_chainsthreads of its own. Reaching that width when chains also run concurrently needed OpenMP nesting enabled (omp_set_max_active_levels(2)): a region inside a chain body is a nested one, and OpenMP pins nested teams to one thread by default. Most common model configurations (plain Poisson-gamma/binomial/negbin with simple random effects) take a vectorized Eigen gradient path that never reaches these OMP regions at all; the scalar fallback that does carry them serves zero- inflation, correlated random slopes, and GP/multiscale spatial models.-
The two log posteriors are now the same function (#18).
compute_log_postand the templatedcompute_log_post_impl<double>that theA_r/A/A_tgradient modes differentiate returned different numbers on every model and omitted different structures. For those modes the runtime gradient check differences that same templated density, so a structure missing from it was invisible to both the gradient and its own numerical reference: they agreed on the same wrong value. Differencing the two densities against each other instead, over every structure the parameter layout allocates for, is what found the following.structure before after HSGP spatial field absent: all 11 parameters gradient exactly 0, worst relative deviation 1.00 1.3e-7 random slopes, uncorrelated slope variance absent, 1.00 1.2e-9 random slopes, correlated Cholesky factor and variance absent, 1.08 1.0e-8 crossed random intercepts second term absent: 5 effects and its variance 0, 1.33 1.2e-9 TVC precision a different prior on each side, 0.376 4.2e-10 collapsed ICAR / BYM2 read params[-1], access violationrefused The residual constant that remained on every model was the binomial coefficient: the eta-form likelihood drops it as constant in eta, which is right for a Laplace inner solve and wrong for a log posterior that is reported and compared.
lp - lp_implwas a flat -7156.27 across all fields and is now 0 to floating-point noise. A collapsed spatial field refuses an autodiff gradient mode instead of crashing. The collapsed parameterizations marginalize the field out by locating its mode with Newton and adding a Laplace correction, which is not a closed-form function of the parameters, so the templated density cannot express them and the layout allocates no slot for the field. Reading that absent slot was an access violation.
gradient_modein"A_r","A","A_t"now errors at the front door naming the parameterization;"H"and"N"carry the analytic density and are unaffected. The same declaration covers the HSGP multi-scale GP and the non-centred GP, which are expressible but not written yet (#26).The TVC precision takes the prior it is configured with.
tvc_tau_shapeandtvc_tau_ratereach the sampler frompriorsand defaulted toGamma(2, 0.5), but two of the three implementations ignored them for a hard-coded PC prior withP(sigma > 1) = 0.01. The density, the specialized gradient and the composite gradient now all read the configured hyperparameters, through one derivative helper.The random-effect prior is one implementation.
hmc_re.hcomputes the prior and the effective effects for every layout the sampler allocates – one or more crossed terms, intercept-only or with slopes, correlated or not, centred or non-centred – and both densities call it, so a term cannot be added to one and not the other. The TVC quadratic forms, per-term prior and eta assembly move the same way: templated inhmc_tvc.hand re-exported toratiod_tvc_ad, replacing copies that had already drifted on the cyclic wrap-around edge. The RW1/RW2/AR1 templates take a pointer, so a caller holding a strided slice passes an offset rather than copying into a vector.cpp_gradient_check()reaches the structures it could not build before (hsgp,tvc,re,re_crossed,re_slopes,re_slopes_corr,icar_collapsed,bym2_collapsed), differences either density viareference =, and reportslog_post_implandimpl_gapalongside the gradient.test-gradient-correctness.Rasserts across every field that the two densities return the same number, that no block has an identically-zero gradient under any autodiff mode, and that a collapsed model refuses one. Its tolerance now allows an absolute1e-6before the relative comparison: a central difference of a log posterior of magnitude 1e3-1e4 resolves a gradient entry to about 1e-7, and the HSGP basis coefficients run to 6e-4, where 1e-4 relative asked for more than the difference can deliver.
tulpaRatio 1.4.2
Every intrinsic field is now identified at the engine’s constant (#12).
s2z_precision(n)returns1 / (kappa * n)^2fromsd(sum phi) = kappa * n, so callers pass a PRECISION. The spatiotemporal and TVC penalties took that constant as an argument and their call sites filled it with a bare0.001– which iskappaitself, weaker by ~2.5e6 atT = 20, pinning the field’s sum at sd 31.6 instead of 0.02. Each penalty now derives the constant from the length of the sum it pins, so no call site can supply one. The interaction margins take their own constants rather than sharing one: a space margin sumsSterms and a time marginT.SVC keeps its own constant, deliberately. Its penalty is rewritten on the sum rather than the mean – the two forms are the same penalty, related by a factor of
n_obs– and both it and its gradient now read onesvc_sum_to_zero_ridge(). That constant is NOTs2z_precision(n_obs): an NNGP field’s prior is proper, so its mean is identified and what is wanted is a ridge, not the pin a rank-deficient field needs. Substituting the intrinsic constant was tried and reverted – atN = 80it is 156.25 against the ridge’s 0.0125, and at that stiffness 4 chains x 1000 iterations return in 9 seconds with per-chain posterior SD 0, Rhat 15.1 and the slope at 0.095 against a truth of 0.3, where the ridge samples for minutes. Deriving the right constant for a proper field on its own terms is #25.-
The multiscale trend and seasonal arms are pinned (#12). Neither carried a sum-to-zero term on any path. Both are intrinsic and both enter the same linear predictor, so each had a constant null direction aliased with the intercept and with the other – a two-dimensional ridge the fitted series hides, because only the level moves. Both are now pinned in the log posterior and in the analytic gradient; the proper short-term arm (AR1/IID) identifies its own level and is left alone. On a 500-observation binomial fit against a known intercept of 0.5, over 4 chains:
trend + seasonal intercept posterior SD Rhat slope (truth 0.3) RW1 + cyclic seasonal, before 2.6101 7.1840 1.589 0.2886 RW1 + cyclic seasonal, after 0.5054 0.0170 1.012 0.2878 RW2, before 3.7200 1.1098 2.163 0.2926 RW2, after 0.4868 0.0167 1.000 0.2907 The slope is right either way, which is why nothing downstream flagged it.
The spatiotemporal interaction’s margins are identified (#12). Same fit with a Knorr-Held Type IV interaction over 9 units x 5 times: the intercept posterior SD moves from 0.1106 to 0.0109 and Rhat from 3.43 to 1.14. It does not reach 1.05, and a 4x longer run makes it worse rather than better; that residual is tracked separately as #24.
The cyclic RW rank is no longer overcounted (#12). RW1 and RW2 ranks were computed inline as
cyclic ? T : T-1/T-2at 16 sites. A cycle-graph Laplacian still annihilates the constant, so a cyclic RW1 has rankT-1, and a cyclic RW2T-1as well (a linear ramp is not periodic). Every site now callstulpa::rw1_rank()/rw2_rank()from the engine, which cannot drift from the engine’s own normalizers.The spatiotemporal analytic gradient matches the penalty it differentiates. Three further
0.001literals sat in the ST gradient paths, including the one feeding the fused log-posterior reconstruction. With the old constant the resulting mismatch was too small to see; at the correct precisiontest-gradient-correctness.Rfails onicar_standbym2_stuntil the gradient carries the same per-margin constants.hmc_sampler.h’sbuild_and_factorize()keeps its0.001, renameddiag_ridge: it is a Tikhonov ridgelambda*Ithat makes a Kronecker of two intrinsic precisions factorize, not the rank-1-per-margin sum-to-zero penalty.The multiscale, TVC and SVC sum-to-zero terms are written once. The double and autodiff twins of the multiscale kernels,
tvc_sum_to_zero_penaltyandsvc_sum_to_zero_penaltyare collapsed into one body templated over the scalar type. The missing multiscale pin survived a previous migration pass precisely because it had to be found in six places; there is now one.src/soft_sum_to_zero.his gone in favour of the engine’stulpa/soft_sum_to_zero.h.Recovery tests cover the fields that were unconstrained.
test-recovery-constrained-fields.Rgains multiscale (RW1 trend + cyclic seasonal, and RW2 trend), TVC and spatiotemporal cases. Shape and class assertions pass just as happily when a field’s level is free, which is how all three items reached a release. The TVC and spatiotemporal cases assert recovery but notRhat: neither model mixes to 1.05, which reproduces on the released build for TVC (#23) and is a separate residual for the interaction (#24). Both comments say so and name the issue to restore them under.
tulpaRatio 1.4.1
-
The spec path’s ICAR / BYM2 field is identified (#19). The bridge that builds the engine’s
ModelDataassigned the CSR adjacency without the component partition the sum-to-zero augmentation iterates over, so the field’s constant direction carried no precision and its level random-walked:mean(phi_spatial)reached +200.9, -342.8 and +423.1 at seeds 42, 43 and 44 where the legacy backend holds it within 0.02 of zero, andtau_spatialcame out at 16.89 against the legacy 11.90. The field’s shape and the fixed effects were unaffected, which is why it read as a healthy fit. The bridge now callsModelData::set_spatial_adjacency(), which derives the partition and the component count from the adjacency (tulpa 0.0.108). The two B1d spec-vs-legacy parity tests run again, having been skipped since the defect was filed.
tulpaRatio 1.4.0
tratio()replacesratiod()as the package’s front door, joining thetulpa*family of fitting verbs alongsidetulpa::tulpa()andtulpaObs::tobs(). Theratiod_*family constructors, theratiod_fitclass, and every otherratiod_-prefixed name are unchanged.tratio()carries statistical arguments in its signature and every perf, numerical, and tuning knob in a singlecontrol = list(), matching the convention the engine and tulpaObs already follow. Moved intocontrol:chains,iter,warmup,thin,cores,seed,verbose,adapt_delta,max_treedepth,metric,riemannian,gradient_mode,re_param,vi_variant, and the stochastic-gradient knobs (batch_size,epsilon,alpha,schedule_*,use_schedule) that previously arrived through.... An unrecognised knob is now an error rather than a silent no-op, and awarmupthat would leave no post-warmup draws is rejected.refreshis removed. It was a documented argument that no backend ever read, so passing it changed nothing.
Bug fixes
Spatiotemporal models fitted under an autodiff gradient mode now sample the density they are meant to (#14). The log posterior is written twice, once as
compute_log_postand once as the templatedcompute_log_post_implthat thearena,forward, andtapemodes differentiate, and the spatiotemporal interaction was present only in the first. The interaction parameters therefore carried no prior and contributed nothing to either linear predictor, so their gradient was exactly zero and every other parameter was differentiated against a linear predictor short one effect. The interaction now enters the templated log posterior as well, covering the Knorr-Held types I to IV, the separable and non-separable GP forms, the HSGP basis form, and the non-centred Type IV parameterization. The interaction math insrc/hmc_spatiotemporal.his templated in place rather than copied into a second autodiff header, so both paths call one implementation.gradient_mode = "handcoded"was correct throughout and is unchanged, to the last bit.The samplers no longer hold their per-thread scratch in the shape that corrupts the heap on Windows (#16). Every workspace in
hmc_sampler.cppwas astatic thread_localobject owning heap buffers. Under the mingw toolchain such an object gets emutls-backed storage and a destructor registered through__cxa_thread_atexit, and the two are released by thread-exit hooks whose relative order is unspecified, so the destructor can free through storage that is already gone and the process dies at some later unrelated free. A worker thread exits only when libgomp narrows a team, which is why the fault appeared when a fit asked for fewer chains than the one before it. The 42 declarations now go throughRATIOD_TLS_WORKSPACE(src/tls_workspace.h), which keeps the workspace behind a constant-initialized pointer: no initialization guard, no destructor registration, nothing for a dying worker to free. No object in the package registers a thread-exit destructor any more.tratio()no longer fails against a tulpa version the requirement allows. It callstulpa::tulpa_check_control(), exported from tulpa 0.0.99, while DESCRIPTION asked only fortulpa (>= 0.0.95).
tulpaRatio 1.3.1
-
diagnostics()replacesmcmc_diagnostics(), following the engine rename in tulpa 0.0.95. It delegates totulpa::diagnostics(), which selects chain or approximation diagnostics from the fit’s draws provenance rather than from the function’s name.mcmc_diagnostics()is deprecated and still returns the same value.
Bug fixes
A fit no longer decides how many threads later fits get (#16). The Laplace and Polya-Gamma backends take a per-fit
cores, which they applied by moving the process-wide OpenMP thread count and leaving it moved, and it defaults to one thread. Every region in the session that read that value afterwards was therefore sized by whichever of those fits ran last, in fits it knew nothing about. Each backend now restores the previous value when it returns, on the interrupt path as well as the normal one.Successive fits in one session no longer corrupt the heap on Windows (#8). The OpenMP team for chain-parallel work was sized per fit from
cores, so a fit asking for fewer cores than the one before it shrank the team; libgomp then destroyed the surplus workers and ran theirthread_localdestructors while later work was still in flight, and the damage surfaced asSTATUS_HEAP_CORRUPTIONat an unrelatedfree(). Four chains followed by two was enough to trigger it. The team now only ever grows, sized by the widest core budget any fit has asked for, andcoresbounds how many chains are in flight instead, so it keeps its meaning. Growing a team was always safe; only shrinking faulted. Its size never comes fromomp_get_max_threads(): the Laplace and Polya-Gamma backends move that value throughomp_set_num_threads()and leave it moved, so reading it let a Laplace fit ahead of the first chain fit pin the team at one thread and serialize the chains of every fit after it.The Gibbs spatial backend runs its chains in parallel under OpenMP (#7). It looped over chains in R, so a 4-chain fit cost close to four times a 1-chain fit; on a 40-site binomial ICAR model that ratio drops from 3.7x to 0.94x.
coresnow reaches this backend, bounded by the chain count and the machine. Chain seeds are still derived in R, so a fit with a given seed returns the same draws as before, and the parallel and serial paths agree bitwise.The handcoded gradient no longer disagrees with its own log posterior when a temporal effect is not shared between numerator and denominator. The vectorized and fused kernels added the temporal effect to the denominator’s linear predictor and fed the denominator residual back into the temporal gradient, both unconditionally, while
compute_log_post()applied the effect to the numerator alone. NUTS therefore sampled a density whose gradient pointed elsewhere, silently, for every family with a real denominator (poisson_gamma,negbin_negbin,negbin_gamma,gamma_gamma,lognormal). Binomial models were unaffected. The gradient checks now cover a continuous-denominator family withshared = FALSE, which is the only configuration that can see this.ModelData’s scalar members are initialised at declaration. The constructor set onlyunique_id, so roughly thirty members held indeterminate values until assigned; the GP entry point never assignedre_parameterizationand read stack residue to choose between the centred and non-centred parameterisation.The scalar gradient fallback reduces its per-thread partial sums in thread-index order rather than in a critical section, whose summation order varies from run to run.
mcmc_diagnostics()now reports per-parameter Rhat and ESS (#4). It handed a multi-parameter draws array to the single-variableposterior::rhat()/ess_bulk()/ess_tail(), collapsing every parameter to one scalar that was then recycled across the returned rows: a well-mixed four-chain fit reportedrhat = 2.124,ess_bulk = 1. Diagnostics now delegate totulpa::mcmc_diagnostics(), the engine’s per-parameter implementation, which removes the local re-derivation (compute_diagnostics_basic(),compute_split_rhat(),compute_ess_basic()).summary(),plot_rhat(),plot_ess(),diagnostic_summary()andcheck_diagnostics()all inherit the correction, and a converged fit no longer prints a false non-convergence warning.print()on aratiod_diagnostic_summaryno longer errors when a fit has a parameter with Rhat > 1.01 or ESS < 400. The worst-Rhat and worst-ESS tables are column extracts of the diagnostics table and dispatched onprint.ratiod_diagnostics(), which rounded columns the extract does not carry.
Internal
B1a PoC: plain binomial fits can route through tulpa’s
LikelihoodSpecpath (tulpa::get_nuts_fn()) behind a feature flag (options(tulpaRatio.use_specs = TRUE)orTULPARATIO_USE_SPECS=1). Off by default; legacy backend remains the production path until full parity ships in B5. Posterior means agree with the legacy backend within Monte Carlo noise.B1b: the same feature flag now covers all 7 ratio families (binomial, poisson_gamma, negbin_gamma, negbin_negbin, gamma_gamma, lognormal, beta_binomial) for the simplest non-ZI / no-spatial / no-RE / single-chain configuration. Autodiff only — H-kernel port is deferred to B2.
B1c: zero-inflation, hurdle, and one-inflation variants now route through the
LikelihoodSpecpath. Each family’s templated likelihood dispatches ondata.zi_typeand calls a shared mixture helper insrc/lik_specs/lik_helpers.h; per-family allowlist lives insrc/lik_dispatch.cppandR/backend_hmc.R::SPEC_ZI_COMPAT. Posterior parity with the legacy backend matches within Monte Carlo noise for every binomial × {ZI, hurdle, OI, ZOIB} combination; for count families (poisson_gamma, negbin_*) the legacy A_r path was already silently ignoring ZI, so the spec path here gives the mathematically correct posterior — recorded as a behaviour change, not a parity mismatch (seedev_notes/B1c_zi_surface.md). Autodiff only; ZI variants for the hand-coded H-kernel remain deferred.The tulpa ABI test no longer pins a hardcoded version number. It compared
cpp_tulpa_abi_version()against a literal32L, a second copy of a constant tulpa already owns, so every tulpa ABI bump broke the test with a stale-literal failure that said nothing about compatibility. Two unguarded accessors (cpp_tulpa_compiled_abi_version(), the value baked in from theLinkingToheaders, andcpp_tulpa_runtime_abi_version(), the value in the loaded tulpa DLL) let the test assert the invariant that matters — that the two agree — and report both numbers when they do not. tulpa remains the single source of truth for the version.Documentation is regenerated under roxygen2 8.0.0, matching the rest of the packages that link against tulpa.
RoxygenNotemigrates toConfig/roxygen2/version. Two external links now resolve to a topic alias rather than an Rd filename (posterior::as_draws_df(),loo::stacking_weights()), which clears the “Non-topic package-anchored link(s)” note fromR CMD check;INFERENCE_TIERSdrops the\docType{data}and\format{}entries roxygen2 8.0.0 no longer emits for documented values; and the package page lists authors alongside the maintainer.