Changelog
Source:NEWS.md
tulpa 0.3.0
Closes every open engine issue. 0.2.14 closed seven of eleven; the remaining four are below.
The CCD curvature-reuse path is measured, and removed
control$ccd_stencil_reuseshipped in 0.2.14 opt-in and unmeasured, and the measurement does not support it (gcol33/tulpa#662). It replaced the full1 + 2d + 4*C(d,2)finite-difference stencil on most mode-find rounds with an axial one plus an SR1 secant update of the off-diagonal block. Over 16 paired joint fits – same data, same seed, the knob the only difference – it spent no fewer inner solves (evals ratio 1.046 atd = 3, winning 0 of 8 pairs; 1.000 atd = 4, 2 of 8), because the secant model produces worse steps and the walk runs 1.6x longer, spending the per-round saving again on rounds. That refutes the projection it shipped on, which assumed the same number of rounds in both arms.It also centred the design somewhere worse, which is the failure mode the issue named: the best inner log-marginal on the design fell by up to 7.5 nats at
d = 3and 16.3 atd = 4, moving the reported fixed effects by up to 1.6e-02, their standard errors by up to 1.1e-02, and the grid’s quadrature ESS from 8.35 to 6.92. The closing stencil is always a full one, so the design’s SCALE is measured either way and only its CENTRE moves – which is why nothing inside the placement could see it.control$ccd_stencil_reuseandcontrol$ccd_refresh_everyare removed and refused at the door rather than ignored. The mode-find measures a full stencil every round, which is what every shipped fit already did.
One dispersion convention, in the fixtures too
657f179moved every door to the residual variance and four fixtures were left describing the SD (gcol33/tulpa#661, gcol33/tulpa#659). The recovery suite’s own convention pin fired, as its header says it exists to (“so a change on the door’s side fails loudly here instead of silently rescaling every gaussian fixture”), and the three failures beside it were that rescaling: a seed-specific measurement taken at residual variance 0.5 and re-read at 0.7071, and two joint alpha gates handedsd_poswhere the door readssd_pos^2– a 41% over-statement of the residual scale, which the ICAR block’s own comment names as what makes alpha unidentifiable. The engine has crossed this seam twice in opposite directions: gcol33/tulpa#332 was the same crossing the other way, costing a slope interval a factor of 1.4079 againstsqrt(2)and coverage 146/150 -> 120/150.The SBC harness was crossed the same way and its
phi_crossedNEGATIVE CONTROL had swapped roles with its main arm;test-sbc-crps.Rreads 8 failures against the unconverted helper and none against the converted one.The simulators now read the conversion from the engine (
.phi_to_kernel()) rather than restating it, so a further move of the convention moves every fixture’s draw with it instead of rescaling it silently. The fixture constants stay in the door’s own convention.
One axis marginal, three measures
A boundary-truncation label was weakened by the very thing it was asked about (gcol33/tulpa#660).
53a2ef9folded the outer cells’ quadrature weights into the axis marginal for every caller at once; #657 put the rail back on the unweighted read and the other two callers were never re-asked..nl_axis_edge_mass()names an axis holding material weight on a boundary node, and the weights include.hyper_domain_clamp(), which SHORTENS the outermost cell when the naive half-step mirror would reach past the axis’s declared support – so a bounded axis read weakest exactly at the boundary the label is about. Measured over 192 BYM2(sigma, rho)configurations: the shipped lift runs at median 0.7826 of the unweighted read and as low as 0.5100, thirteen axes lost the label and none gained one. The label now reads a third measure,"span", which keeps the widths a node’s own spacing gives it – an outer node owning a wide cell holds mass its node count does not see – and drops only the domain closure..nl_axis_marginal_w(measure =)names which of the three each caller wants; the rail keeps"inner"and every reported mean, interval and spread keeps"posterior".The
"resolve"placement trigger was checked against the same folding and is unchanged: its threshold was calibrated twenty days before it, and over 240 axis-configurations the two reads flip no fire decision (ratio 0.777 to 1.341, and equal to the bit on any single-axis log-spaced grid).outer_grid_h_over_sdon a fit and.nl_axis_h_over_sd()are two different quantities, and the comment describing the first was written about the second. They agree on none of twelve measured axes and sit six orders apart on a collapsed one. Both are kept and both now say what they are.
tulpa 0.2.14
The outer grid says what it did
n_threads_outerwas clamped toomp_get_max_threads()with no signal, so a 10x reduction in the parallelism a caller asked for left no trace (gcol33/tulpa#651). The clamp is correct and stays –NlCellCachesizes its slot array fromomp_get_max_threads(), so an unclampednum_threads(n)would map excess workers onto slot 0 and corrupt the shared CHOLMOD factor. What was missing was the report: a clamped-to-1 run and a serial-by-design run printed byte-identical progress lines. A fit whose realised width is below the requested one now says so once, naming the binding cap and how to lift it, and recordsn_threads_outer_requested/n_threads_outer_realisedso a timing going into a report can state what it actually ran at. Measured motivation: a 25 kmoccu_coverfit launched atn_threads_outer = 10underOMP_NUM_THREADS=1ran 32.5 h at 99% of one core and produced no checkpoint.The CCD mode-find ran for hours printing nothing, so a working fit and a hung process were indistinguishable (gcol33/tulpa#652). The progress reporter belongs to the outer-grid loop, which the mode-find runs before, and at
d = 4one round is 33 full inner solves. The seed pass, each calibration round and each mode-find round now emit a heartbeat on the fit’s ownprogressswitch, carrying the spend against the ceiling, the step size, the log-posterior and per-eval timing; a configuredprogress_filecarries the outer reporter’s own four-number format, so a detached placement phase is no longer an empty file.
Placing a design is now budgeted, and screening can no longer hide a collapse
CCD placement cost was unbounded by the integration it replaces (gcol33/tulpa#653). Every reason in
.CCD_DECLINE_REASONSwas about whether a Gaussian design is definable; none was about whether placing it is worth it, so an expensive model could spend up to ~1976 inner solves atd = 4placing a 25-node design.control$ccd_budget(default 1) caps the placement at the tensor grid it would otherwise integrate: break-even, so a successful placement never costs more than the integration it replaces and a failed one caps the waste at the same number. It declines withintegration_declined = "placement_budget"up front when even the cheapest placement cannot fit, and mid-placement when the spend reaches the ceiling. The cost is reported asccd_modefind_evals/_budget/_rounds/_seconds.control$ccd_stencil_reuse(defaultFALSE, and unmeasured) trades a stencil’s mixed corners for an SR1 secant update, keeping the diagonal measured and the design’s closing curvature a genuine finite difference; it is off by default because its value has not been measured on a real fit, which is the remaining open half of the issue.-
control$prune = TRUEcould return a displaced posterior and report a grid boundary as an estimate, tripping neither safety-gate trigger (gcol33/tulpa#656). Three separate defects, all fixed:- The screen kept so few cells that the placement pass had no curvature to read, declined
no_usable_curvature, and left the axis on its default span with the mass on the top node – a reportedsigmaof exactly 3.000 that was the top of an un-recentred axis rather than an estimate. The screen now floors the kept set at.NL_SCREEN$min_keep(5): a central second difference needs 3 collinear nodes, so 5 is the smallest floor under which no axis can be a point mass. -
prune_tolis a normalised weight and loses resolution on a steep surface.control$prune_log_gapstates the cut in nats instead, and a fit reports the realised cut (prune_log_gap_cut) against the spread of the whole screened surface (prune_cheap_lm_spread) – a cut of 6.9 nats against a spread of 98000 is the pathology, now readable off the fit. - The gate’s gap threshold was computed from the spread of the cells that survived, so a trigger meant to bound the error from the discarded set was scaled by the set it was validating. It now reads the screen’s own cut: an error exceeding the margin cells were discarded by is large enough to have moved one across it. A screened fit whose placement declines for want of curvature now warns and refits on the full grid rather than reporting the boundary.
?tulpa_nested_laplaceclaimed a silently-wrong pruned posterior was impossible. It was not, and that sentence is gone: the gate bounds a mis-ranking among the cells that were solved, and a cell it discarded and never solved is outside what it can see. - The screen kept so few cells that the placement pass had no curvature to read, declined
A bounded axis has a domain, and a stated axis is a bound
-
.hyper_axis_support()returned 1.010 as the support of a spatial correlation (gcol33/tulpa#657). The half-node-step rule extrapolated past the end of the parameter’s own domain:rho_car’s default nodesc(0.5, 0.8, 0.95, 0.99)closed at 1.01, and a sampler taking that support as its flat prior reaches a region where the proper-CAR loading is numerically intrinsic. The node spacing is not what was wrong –.hyper_axis_level_weights()exists so an unevenly placed node set integrates the declared measure, and flat-in-logit would be improper on a bounded domain and put as much mass on the last percent below 1 as on the whole middle. The cell closure was wrong. One rule now: an outer cell owns half the distance to whatever comes next on that side, the next node or the boundary. A domain registry (.NL_AXIS_DOMAIN) makes the class unreachable rather than fixed once.This moves outer weights on any fit carrying a correlation axis whose top cell reached past 1:
rho_car’s outermost prior weight falls 0.0606 to 0.0388, BYM2rho’s 0.00944 to 0.00527. Point estimates and modes are unaffected; the grid-marginalised posterior moves. An evenly spaced grid whose top node crowds the boundary no longer gets equal weights, which is deliberate – its top cell cannot extend past 1.Also fixed here:
rhonames four different parameters (BYM2 mixing, proper-CAR, AR1, and the multi-output cross-field correlation), so the name-keyedc(0, 1)domain was a false claim that made the shippedhsgp_modefault grid unconstructible. Axis bounds now resolve through the one registry. -
A caller-stated outer-grid axis was extended anyway, so “these are the nodes” could not be expressed (gcol33/tulpa#658). Refinability was decided by axis NAME, so a stated copy axis opted into the adaptive-grid passes whether or not the caller wrote its nodes down: a user stating
c(0.2, 0.5)got a fit integrating[0.020, 4.94], about 100x the range they asked for, reported nowhere. A stated axis is now a bound. It is still refined – more finely, inside the range given – and a mode outside it shows up as mass at the edge instead of moving the range.control$axis_refine = c(alpha = "none" | "densify" | "extend")sets it per axis;auto_grid()on the nodes marks an axis a wrapper package computed as engine-placed, so it keeps extending. A fit reportsaxis_spanper axis, separating the refinement term from the exact half-node-step term (k / (k - 1)of the stated range: 2x at two nodes, 1.125x at nine).This narrows some existing fits. Any fit that stated an axis and relied on
adaptive_grid = TRUEextending it now integrates the stated range. It also reaches the var-of-means consistency pass, which runs by default on every joint fit and not only underadaptive_grid = TRUE; and since the engine has no default forphi_grid, everyphi_*axis counts as stated. Restore the old behaviour per axis withaxis_refine = c(alpha = "extend").
Hyperpriors reach every block they name
-
prior_alphaandprior_sigmaon a multi-block joint grid regularized only the first block carrying the axis, silently (gcol33/tulpa#655). A fit copying two blocks got block 1 shrunk and block 2 flat with no message. One spec now reaches every block carrying that axis, and a per-block list (list(list(block = 1, prior = ...), ...), keyed the way the multi-block copy spec already keys its amplitude side) regularizes blocks differently. Naming a block that carries no such axis is an error listing the blocks that do. The fold needed no new machinery – it is per-axis-spec by construction, so N blocks is N independent folds; under the old code block 2’s atom was never reached at all. A fit records what was applied inhyperprior_axes.This changes results for a direct
tulpa_nested_laplace_joint()caller who passed one spec to a grid carrying that axis on several blocks. Broadcast rather than refusal is what keeps the engine’s own.joint_multi_sigma_grid_rescue()coherent: it sets a scalarprior_sigmaon a multi-block grid and refits, and fires exactly on the multi-copy-block collapsed-edge fits this issue is about.
Fixed in passing
Rail detection was riding on prior mass placed outside the parameter’s domain.
.nl_axis_rail()’s marginal folds the outer prior’s cell masses, and the domain closure above shortens exactly the cell at the boundary being tested, which moved the argmax to an interior node and stopped a railed BYM2 mixing weight being detected at all. The rail now reads the inner marginal, which carries no cell widths and is invariant to both the domain closure and to refinement subdividing cells; the two clauses, thelift = m * w[k]form and the threshold of 2 are unchanged. This was latent before this release:#375calibrated the guard at 2.511 on the 100-region BYM2rhofixture, and a later change foldinglog_quadinto the same marginal moved the measured quantity to 2.099 without re-tuning the threshold, leaving 5% of headroom. The detector now reads 2.5106 with the domain closure on or off.Axis
boundson a multi-block joint grid resolved on the block-prefixed name while the scale and point-mass metadata resolved on the bare one, sob1.sigmaandb1.rhocarried no domain at all.
tulpa 0.2.13
-
logLik()resolveddfto 0 whenevern_fixedwas unset, soAIC()andBIC()came back identical. The count was picked with a%||%chain overlength()calls –n_fixed %||% length(mode) %||% length(means)– andlength(NULL)is0, notNULL, so the chain accepted the zero at its middle term and themeansfallback was unreachable. Both information criteria then carried no parameter penalty and reduced to-2 logLik, which is finite and plausible and ranks nothing. Resolved from the first candidate that yields a positive count instead; the priority (n_fixed, thenmode, thenmeans) is unchanged (#654).
tulpa 0.2.12
-
A refined grid’s per-cell lists were sized from cell 1, which a cheap-pass screen can leave empty.
.joint_glue_extras_to_res()decided whether a joint fit carries per-cell modes, iteration counts, precision triplets and covariance blocks by testingextras[[1]]alone. Which side data a fit holds is a property of the FIT (store_Q, the kernel’s own outputs), but which CELLS hold it is not:prune = TRUEnever solves a screened-out cell, so that cell has no mode, no precision and no covariance block whatever the fit stored. When the screen dropped the grid’s first corner, the test read a pruned cell, the rewrite was skipped, and the lists stayed at their pre-refinement length while the grid grew under adaptive refinement.tulpa_posterior_draws()builds its mixture from those per-cell precisions and refuses a list that does not index the grid, so prediction stopped on a fit that had otherwise converged. Each list is now sized from the first cell that actually carries the field; a pruned cell keeps its empty slot and is skipped by the mixture rather than filled in.
tulpa 0.2.11
Every R-level
phiis the residual VARIANCE forgaussian/lognormal(gcol33/tulpa#650). It was the variance attulpa(),tulpa_laplace(),tulpa_ep()andbuild_glmm_logpost()and the residual SD attulpa_nested_laplace(),fit_st_nested(),fit_spde(), thetulpa_nested_laplace_joint()arms and theirphi_grid,tulpa_sample_glmm(), thelaplace_*_at()helpers and the rational-SPDE helpers, so one model wasphiat one door andsqrt(phi)at another..phi_to_kernel()is now called once per function, adjacent to the kernel call that wants the SD; the joint tier converts at its single C++ boundary, covering the arm scalar, the per-armphi_gridoverride and the[n_arms x n_batch]phi_batch. The compiled surface is not uniformly SD, so the conversion is not blanket:cpp_glmm_oracle_makeandcpp_re_cov_gibbs_sweeptake the variance and are handedphiraw, andtruncated_gaussian,interval_gaussianandinverse_gaussianare their own families rather than variance families.fit_spde()stores$phiin that one convention instead of$phi_kernel. This changes what an existing call means: a script passing a residual SD to any of the flipped doors now states its square, and passesphi^2to describe the same model.-
A
phi_gridaxis is the residual variance too, and the fixtures that carry one were left on the SD scale (gcol33/tulpa#650, gcol33/tulpa#649). An entry inphi_gridoverrides the arm’s scalarphioutright, so squaring the scalar alone is a no-op on any fixture with an axis and the axis is the only thing the kernel reads. Seven test fixtures and four vignettes are restated on the variance scale, each describing the model it describes.test-nested-laplace-joint-phi-grid.Rwasskip_on_cran()-gated and reading 7 FAIL / 7 PASS: every recovery assertion in it compared a posterior mean over a variance axis against a true SD.test-recenter-pilot.R’s fixture had stopped collapsing its outer grid – ESS 1.53 -> 4.95 against a< 2trigger – so its placement never fired and theh / sdidentity block was measuring nothing.The engine is unchanged across this. Built clean at
66e5f60and at the fixed fixtures and run on the same outer grid, every cell whose coordinates match exactly returns a bit-identicallog_marginal; the cells that differ are the adaptive-refinement nodes, whose coordinates differ because the phi axis does.
tulpa 0.2.10
-
phimeans two different things on two sets of doors, and only some of them said so (gcol33/tulpa#645). The R family registry parameterizesgaussian/lognormalby the residual VARIANCE and the compiled kernels by the residual SD;.phi_to_kernel()converts between them at exactly two call sites,tulpa()andtulpa_laplace(). Every other door handsphito its consumer raw, so one model isphion those front doors andsqrt(phi)ontulpa_nested_laplace(),fit_st_nested()andfit_spde(), and a reader taking one for the other is out by a factor ofphi.tulpa_nested_laplace()documentedphias “Dispersion (negbin/gamma)”, naming the two families where the value is neither convention and staying silent on gaussian;fit_spde()and the internallaplace_spde_at()/laplace_gp_at()said “negbin only” / “negbin / gamma only” for aphithe shipped tests passsqrt(0.05)to on a gaussian fit;fit_st_nested(),tulpa_ep()andbuild_glmm_logpost()said only “passed to the family”. Each now names its convention, the per-family reading and the counterpart door.tulpa_ep()andbuild_glmm_logpost()evaluate the R registry, so theirphiis the variance; the rest are kernel doors and take the SD.tulpa_nested_laplace_joint(),tulpa_sample_glmm(),fit_spde_nuts()andtulpa_laplace()already said so and are unchanged. Documentation only: no argument, default or numerical behaviour moved.
tulpa 0.2.9
-
The #317 mixed-read gate scored its three arms under two different outer-edge policies, and its pins predated the flip that caused it (gcol33/tulpa#643, gcol33/tulpa#644).
cc3e8edmoved.NL_SUPPORT$mixed$outsidefrom"clamp"to"extend"so that a locally refined fit and the unrefined fit of the same model could not report intervals built two different ways. The gate intest-nested-laplace-joint-ccd-local.Rwas not re-measured with it – that commit’s own sweep ran atlocal_ccd = NULL, so the arm it scored was the density path and the arm that moved wasmixed– and itsmixedtotals rose from 3.18024 / 2.69889 to 3.46157 / 3.14443, taking:785red under the slow tier while staying invisible atTULPA_FAST=1.Its two comparators had meanwhile stayed on an inline
outside = "clamp", so the comparison varied the collapse construction and the edge policy together when it exists to isolate the first. All three arms now go through the one dispatch at the one within-cell construction. Scored on a common policy the gate’s verdict holds either way and the margin is wider under the shipped one (lo: mixed 3.14443 against 4.79910 and 4.48729, where underclampit is 2.69889 against 3.92249 and 3.83155), so what moved is the margin and not the finding. Bisected across 205 commits, and the pins are re-measured rather than regenerated: the comment records both the flip and the alignment.
tulpa 0.2.8
A single-block outer cell’s cost is the per-row predictive-variance loop, and it is now both cheaper and optional (gcol33/tulpa#638). The loop fills
fitted_eta_varwith one back-solve per observation per cell, and it is the whole of the path’s size dependence: with it switched off the per-cell cost is flat in N (0.225 to 0.254 s over N = 900 to 10800 on a 30x30 ICAR lattice, 11 cells, serial), with it on it is0.217 + 4.685e-4 N. Two changes. A row’s loading vector is(X row, RE group, per-block index and weight), none of which moves with the outer cell, so rows agreeing on all of it carry the same vector at every cell and the driver now solves one of them and hands the value to the rest – an intercept-only design with 900 distinct rows of 5400 costs 0.244 of what its N predicts and 1.047 of what its distinct count predicts, 4.07x. Andcontrol$fitted_var = FALSEskips the pass outright for a caller that reads onlyfitted_eta, 21x at N = 10800. The key is compared on exact bit patterns with every merge confirmed element-by-element, so a hash collision cannot fuse two rows.SparseCholeskySolveralso gained a workspace-reusingcholmod_solve2path, so the back-solves no longer allocate and free per call.Cheap-pass grid screening is reachable from
tulpa_nested_laplace()(gcol33/tulpa#639). Both entry runners hardcodedprune_tol = 0.0, so the eleven single-block entries could not screen even though the shared driver, theCheapEvalclosure and the safety gate were all built for them – and this is the path where a cell is expensive enough for screening to pay.control$prune,$prune_tol,$screen_itersand$fitted_varnow thread through to every entry, under the same.joint_prune_safety_gatethe joint front door applies, so a single-block fit cannot silently return a pruned answer either. The gate’s warning now names the front door the fit came in through.The cheap screen’s depth is a control knob with a measured default of 2, down from a hardcoded 5 (gcol33/tulpa#640). The screen runs on every cell and only has to RANK them, and each cell is warm-started from its already-screened lattice neighbour, so every step above what the ranking needs is paid on the whole grid including the cells it keeps. Over four fixtures at depths 1/2/3/5 the answer does not move – Spearman 1.000 against the full solve, argmax kept, at most 1.2e-3 of the true posterior mass dropped, fixed effects within 4.8e-5, safety gate never fired – while the cost does: on the fixture that isolates the screen’s own price, depth 5 is a net loss at 0.75x where depth 1 is 1.33x. The default is 2 rather than 1 because a small 8-cell ICAR fixture ranked at Spearman 0.976 on one step and 1.000 on two. Set it per fit with
control$screen_iters; pruning itself remains opt-in.
tulpa 0.2.7
The outer-grid placement pass no longer has to be paid for with a full grid (gcol33/tulpa#636). Placement reads an argmax cell and an FD curvature stencil, both off
log_marginal, and neither off the integration the detecting pass paid for – so a placement that fires discards every inner Newton solve of the grid it detected on.control$recenter_pilotontulpa_nested_laplace_joint()(defaultFALSE) detects on a THINNED grid over the same spans – both endpoints kept plus at most three nodes between them – and solves the full grid once, at the placed axes. The rescues are unchanged: each already detects on the fit it is handed and writes onto the prior it is handed. Measured over 48 paired seeds on a(sigma, alpha, phi)donor + copy fixture: 0.78x the cells solved where the placement fires on 10 of 12 seeds, 1.29x where it never fires, so the knob is off by default and pays exactly when the pilot’s cells cost less than the firing rate times the full grid.The pilot is a pre-screen, so it can only ADD a placement. A pilot whose detection places nothing is followed by the full fit, which then gets its own detection through the same rescue pair; a fit declining both is bit-identical to the same fit with the knob off. A coarse grid does fire somewhat more readily (the trigger is
ess_grid < 2, which falls with the cell count): over the same sweep the arms disagreed on 9 of 48 pairs, every one the pilot placing where the full grid did not, 7 of the 9 reaching a higher maximum inner log-marginal.outer_grid_pilotrecords the pilot’s resolution, cells, thinned and kept axes, and the regime / ESS / edge axes it DECIDED from – the reported grid is never the grid the trigger was read on.A recentred axis’s reported
h / sdis1.25 * sd_used / sd_realized, not 1.25. The node layout’s ratio is in PLACEMENT SDs;.nl_axis_h_over_sd()divides by the weighted posterior SD the placed grid realizes, so the two agree only where the placement SD is the one the weights realize. Wherever the mode-SD clamp SUBSTITUTED a bound they cannot: the floor exists to widen an axis sharper than it. Measured, floor binding on 18 of 18 placed fits,sd_rawmedian 0.0543 against the substituted 0.15 and a realized 0.0489, reported 3.83. The comment claiming 1.25 “by construction” is corrected, and the identity is pinned by test. An axis no rescue moves carries no such relation at all..nl_grid_ess()is the one read of a grid’s quadrature effective sample size, behind.joint_pareto_grid_regime()anddiagnostic_summary()alike.
tulpa 0.2.6
A NUTS chain that ends by an exception no longer leaves the progress reporter registered (gcol33/tulpa#635).
g_active_grid_progressis a process-global raw pointer to a reporter owned by a localunique_ptr, and both places that set it cleared it only on the normal exit path. Any exception in between – a model’sRcpp::stopfrom its own gradient, a non-centered field transform reportingQnot positive definite,bad_allocfrom a per-chain buffer – destroyed the reporter with the frame and left the global naming it. The next NUTS run in that process read the non-null pointer, took it for an already-active reporter, declined to build its own, and ticked the freed object once per iteration.ActiveGridProgressScopenow ties the global’s lifetime to the frame that owns the reporter, closing every exit at once. Measured before and after: unfixed, the pointer is still set once a chain has been killed by a planted gradient exception and the following chain is denied a reporter of its own; fixed, it is cleared and the following chain builds one.The outer-k candidate dispatch is unchanged at the default budget, verified on 3300 configurations (gcol33/tulpa#634). gcol33/tulpa#629’s synthetic sweep re-run on current main is BIT-IDENTICAL to its committed baseline across all 3300 rows and all 24 columns, adopted proposal source included – the same standard gcol33/tulpa#630 used, and the strongest available statement that the tail-rule change above does not move a default fit. Along the way the skew-normal rescue was measured rather than assumed: it is adopted on 7 of 3300 rows, scored on 34, improves the k-hat by a median 0.106 where adopted, and crosses a reported band on none of them. Every adopted row is a target whose true skewness is exactly 0, so the gate is firing on sample-skewness noise in a heavy tail rather than on asymmetry. Reading
pareto_k_proposal_source == "skew_normal"as “this fit needed a skew correction” over-reads it.One documentation claim corrected. The candidate dispatch was described as keeping the minimum over all four candidates, so that none could make a fit read worse. It keeps the better of each rung and its input, which is not the same: the mixture is compared to the GRID-MOMENT Gaussian rather than the moment-matched one, deliberately, so that a refined Gaussian which got under the mixture only by widening past the grid cannot mask a grid-width deficiency. A fit can therefore report a mixture at 1.161 with a moment-matched Gaussian of 0.889 in hand, which happens on 1 of the 3300 sweep cells.
The copy coefficient’s outer axis can be integrated more finely without restating its prior (gcol33/tulpa#633). It was the one outer axis a copy fit could not raise:
alpha_gridREPLACES the nodes, and the axis carries prior structure – the atom at 0 that gives the “no copy” base model posterior mass, plus a log-spaced slab – so a caller who only wanted a finer integration had to restate the prior to get one, and consumers close the grid off entirely undercopy()for that reason.alpha_n(field_coef$non the single-block path) re-reads the engine’s own axis at a higher RESOLUTION: same bounds, same atom, more nodes between them. Supplying both it andalpha_gridis an error rather than a silent ranking. Underneath,.nl_grid_axis(key, n =)re-reads any declared axis at a different resolution and refuses on one declared as explicit nodes, which has no resolution to vary.The saturation is in the placement, not the prune. Measured engine-side rather than through the consumer package, since the axis is the engine’s: raising the donor
sigma_gridfrom 13 to 29 nodes leaves the alpha axis at its declared 6 at every setting and grid ESS at 1.7 / 3.1 / 4.3 while the cell count more than doubles, andprune = TRUEreproduces the same node counts and the same ESS to the digit. With the resolution raised alongside, ESS runs 2.3 / 6.8 / 12.5. Whether the engine should instead solve for a declared ESS floor – the issue’s second open question – is not answered by this and is not implied by it.control$k_samplesis a precision knob again (gcol33/tulpa#631). It was documented as one and was not: under the published PSIS tail rulemin(S/5, 3 sqrt(S))the fitted tail FRACTION shrinks as3 / sqrt(S), so a larger budget described a DEEPER quantile of the weight distribution rather than the same one more precisely, and two fits of one model differing only in their budget could report different reliability bands. The four outer backends now resolve their tail size through.k_outer_tail_points(), which holds the fraction the default budget implies. The fraction is INHERITED from.nl_diag("k_samples")rather than chosen, so a default fit is bit-for-bit unchanged on every backend – the helper returnsNULLthere and the explicit-request path, with its 20% cap and cap warning, is never entered. Resolved once in.k_dispatch(), the single candidate loop gcol33/tulpa#630 put behind all four backends.tulpa_psis()’s own default stays the published rule, so theloo::psis()equivalence oracle is untouched.The held fraction is a FLOOR under the published rule, not a replacement. Below the reference budget the published rule is in its
S/5regime and is the more generous of the two – 40 tail points at 200 draws against the fraction’s 27 – so replacing it would have bought a stable estimand by making every cheap diagnostic noisier. Measured cost of getting that wrong: a per-arm k-hat crossing its reported band on the 200-draw fixture intest-joint-pareto-k-proposal.R. Under the floor no budget is fitted on fewer tail points than before, and the fitted fraction is confined to[13.5%, 20%]over a 500x range of budgets instead of collapsing to 1.3% at 50000 draws.k_tail_pointsnow exists on the backends whose documentation named it. It was a joint-path knob only, whiletulpa_re_cov_nested(),fit_spde()andtulpa_nested_laplace()documented it as the way to hold the estimand – so following that advice hard-errored on an unknown control knob. All four accept it and thread it to the shared PSIS core.A skewed hyperparameter marginal needs no rescue, and the fixture that said otherwise was reading its own budget.
test-outer-skew-rescue.Rasserted that a skewness-0.9 target reads unreliable on a symmetric proposal and is repaired by the skew-normal one. Measured across budgets, that target reads 0.017 at the shipped budget and climbs to 1.627 at 10000 draws under the published rule; the fixture reached the rescue only because it scores at 4000. This is gcol33/tulpa#629’s result – skewness does not inflate an outer k-hat, a heavy tail does – arriving from the other side. The test now asserts the measured property. That the rescue is consequently not adopted on any fixture in the repo is recorded as gcol33/tulpa#634.One outer-k draw budget across the four backends that report it (gcol33/tulpa#632).
R/settings.Ropens by naming copy-pasted defaults as the defect it exists to remove and listsk_samplesamong the five it consolidated, but the joint path never read it:500Lwas hardcoded at the front door, at the escalation’s fallback, and in four entry-point signatures, whiletulpa_nested_laplace(),fit_spde()andtulpa_re_cov_nested()read.nl_diag("k_samples"), which was200L. That is not cosmetic. Under gcol33/tulpa#631 the budget sets the PSIS tail FRACTION as well as the precision – 20.0% at 200, where theS/5cap binds, against 13.6% at 500 – and the k-hat is read against the FIXEDk_usableband, so the same hyperparameter posterior scored on two backends was characterised at two different quantiles of the weight distribution and could be banded differently for a reason the fit did not record.The one value is 500, which raises the grid, SPDE and RE-covariance default from 200. It is the joint path’s, and it is on the record rather than on preference: the joint path was raised from 200 to 500 at gcol33/tulpa#127 when outer scoring stopped being adaptive-batched and the single budget started carrying the whole estimate instead of a batch of it – a reason that covers all four backends now that they all score single-batch – and 13.6% is both the fraction gcol33/tulpa#631 measured the k-hat to be stable at and the one every shipped outer-k number, the whole gcol33/tulpa#629 and #630 corpus included, was read at. It costs those three paths 500 rather than 200 inner Laplace solves when
diagnose_kis on, since one draw is one off-grid re-solve;control$k_samplessets it per fit on every front door.k_samples_ok/k_samples_good(the budgets ak_qualitytarget starts from,800L/2000L) andk_bootstrap(1000L, unchanged in value) moved into the registry with it.The lint that missed it is re-keyed to the concept.
test-settings.Rsearched fork_samplesnext to the literal200; gcol33/tulpa#127 renamed the joint variable todiagnose_drawsand raised it to500in the same commit, so the drift walked out from under a rule keyed to one name and one number. The rule now rejects any budget in this family defaulted to a numeric literal outside the settings file, and a companion test evaluates every entry point’s default against the registry – a source lint cannot see a second registry-shaped default that reads a different key.
tulpa 0.2.5
control$k_samplesmoves the outer Pareto-k-hat, not just its interval (gcol33/tulpa#631). It was documented as the estimate’s precision knob. Under the automatic PSIS tail rulemin(S/5, 3 sqrt(S))the fitted tail FRACTION shrinks as3 / sqrt(S), so a larger budget describes a deeper quantile of the weight distribution: on a synthetic heavy-tailed outer target the reported shape runs 0.57 / 1.41 / 3.53 / 7.94 over 500 to 50000 draws. The estimator is not what moves –tulpa_psis()reproducesloo::psis()to 1e-13 at every one of those budgets, a closed-form Pareto control is flat across the same range, and holding the tail fraction gives 0.569 / 0.702 / 0.708 / 0.692 with the seed spread narrowing.The
k_qualityescalation’s precision rung no longer moves the estimand. That rung doublesk_sampleson a miss whose bootstrap CI straddles a band boundary, and it is the fallback precisely because it should only narrow the interval around whatever the k-hat already is. It now pins the GPD tail size to the fraction the fit’s own first pass used, so the extra draws sharpen the same number. An explicitcontrol$k_tail_pointsis left alone, and the pinned fraction is at most 1/5 by construction, so the 20% cap warning never fires.The
k_samplesdocumentation ontulpa_nested_laplace_joint(),tulpa_re_cov_nested()and the PSIS core is corrected to say what the knob does. The outer-k default remains the published tail rule rather than a fixed fraction;dev_notes/issue631/RESULTS631.mdrecords why, andtulpa_psis()’s own default is unchanged so theloo::psisequivalence oracle still holds.
tulpa 0.2.4
One proposal-candidate dispatch behind all four outer-k backends (gcol33/tulpa#630). An outer Pareto-k-hat scores a PROPOSAL, so what it reports depends on which proposal families were offered.
tulpa_nested_laplace_joint()scored four and kept the best;tulpa_nested_laplace(),fit_spde()andtulpa_re_cov_nested()each called the importance-sampling core once and reported the raw first pass. The candidate layer now lives inR/outer_pareto_candidates.Ron a backend-agnostic contract (.k_cand_spec()), and each backend supplies a spec rather than a scorer. A spec with no integration nodes –fit_spde(method = "ccd"),tulpa_re_cov_nested()– withholds the grid mixture rather than reading a design spacing as a grid resolution, and keeps its radius cap atInfas before. The joint path is unchanged bit for bit, asserted by re-running gcol33/tulpa#629’s 3300-configuration sweep against its committed baseline. Over that issue’s 165 synthetic cells the single-candidate read calls 53unreliablewhere the full dispatch calls 8, median k-hat 1.159 -> 0.736 -> 0.259.A small-group binary RE-covariance k-hat was mostly the proposal’s scale, not a skewed posterior. On
test-psis.R’s tiny-binary fixture (25 groups x 3 binary observations) the proposal as the mode-find places it scores 14.6 to 49.1 over five seeds; re-estimated from its own importance-weighted moments it scores 0.29 to 0.78 on four of them, with one genuinely beyond a Gaussian’s reach at 39.4. The package documented the high value as a correct signal; that note is corrected, and the arbiter test now asserts the repair and its limits instead of a separation that does not survive.New reported fields on every nested backend:
pareto_k_proposal_sourceandpareto_k_first_pass. The first names which proposal family produced the reported k-hat; the second is the k-hat of the proposal exactly as the backend placed it, before refinement. A large gap says the nodes are badly scaled around the hyperparameter posterior even where the verdict is fine – the “poor integration versus non-Gaussian posterior” distinction gcol33/tulpa#629 asked for.pareto_k_proposal_sourcealso reports which integrator actually ran: afit_spde(method = "ccd")fit that fell back to the grid path says so..k_dispatch()declines below the PSIS floor before any candidate runs, so a sub-floorcontrol$k_samplesno longer pays target evaluations to discover it cannot fit a GPD tail.
tulpa 0.2.3
-
What an outer Pareto-k-hat measures, measured (gcol33/tulpa#629). The
k_qualityladder was thought to be unable to reach a genuinely SKEWED hyperparameter posterior, with the proposal-side rescues (.joint_pareto_score_mixture/_skew) as the untouched lever for it. Neither half holds. Skewness is not what a k-hat measures – the importance ratio against a Gaussian proposal stays bounded on a skew-normal target, which reads a median grid-moment k-hat of 0.224 at true skewness 0.851 and -0.073 at 0.967 – so the skew-normal rescue is scored on 0-5.3% of rows and adopted on 0-1.7%, and where it is scored (on a heavy tail, the shape that DOES defeat a Gaussian proposal) it reads worse than the Gaussian it was asked to rescue. Over 165 synthetic configurations the largest gain any un-adopted candidate offers on a miss cell is 0.089, crossing no band boundary, so no proposal-side rung is added tok_refine; and every target reaches the good band on some grid, including one at true skewness 6.74 and excess kurtosis 141, so the escalation does not gain a decline rung either. One thing the sweep does say about the shipped"grid"rung: on a heavy tail, densifying a too-narrow grid moves the k-hat monotonically the wrong way while widening fixes it, so the boundary-extension half is what earns its place. No behaviour change; the verdict is pinned intests/testthat/test-outer-proposal-lever.Rand written up indev_notes/issue629/RESULTS629.md. Two findings from the same measurement are filed rather than folded in: three of the four outer-k backends score a single proposal candidate and read a worse band on 27% of configurations (gcol33/tulpa#630), and the reported k-hat moves withcontrol$k_samples, documented as a precision knob only (gcol33/tulpa#631).
tulpa 0.2.2
The
k_qualityescalation reads which lever its miss actually takes, and keeps chasing when refinement runs out. A miss has two causes: the outer Pareto-k-hat can sit CONFIDENTLY outside the requested band (the integration grid does not represent the hyperparameter posterior), or its bootstrap CI can STRADDLE a band boundary (the point estimate may already be inside the band and only the interval’s width prevents confirming it). The loop read onlyreached / not reachedand answered both by refining the grid. Refining is correct first on either – it lowers the k-hat itself rather than the noise around it, and can move an ambiguous k INTO the band, which is what the shipped fixture measures – but when refinement reports it added nothing, the two separate. A confidently-bad k-hat on a grid that cannot be refined ends the chase as before; an ambiguous one now spends its remaining rounds on the lever refinement never touches, doubling the importance-draw budget so the GPD tail is fitted on more actual tail ratios. Previously such a fit was abandoned with rounds still in its budget, its integration possibly already meeting the request..joint_attach_k_quality()classifies every miss ask_quality_miss = "resolution"/"precision"(NAwhen there is nothing to classify) and the loop reads that classification rather than re-deriving it.Two settings the escalation needed made explicit rather than implied. The refinement switches (
adaptive_grid,integration = "grid") are set inside the refinement branch, not once before the loop: set before it, a round meant to hold the grid still advanced it by the default one pass. The refinement level counts refinement rounds (passes), not loop rounds, so a draw round no longer burns a pass.k_precision_growth(2) joinsR/settings.Rwith its derivation – a GPD shape estimate’s standard error falls as1 / sqrt(S), so a constant factor is a constant proportional narrowing per round.A fit records the chase, and says which read produced its band.
k_quality_k_traceis the outer k-hat round by round, starting at the first fit’s, so a chase that did not descend monotonically is visible instead of leaving only the last round’s number. The LAST round is returned, not the lowest-k one: the refinement passes are nested, so each round’s grid is a strict superset of the last and each round’s proposal is rebuilt from the grid that round changed – returning a smaller grid because a differently-built proposal scored better would be optimising the diagnostic rather than the fit.k_quality_bestis documented as the band of the RETURNED fit accordingly.Three user-facing messages named a knob that hard-errors.
control$diagnose_drawswas renamed tocontrol$k_samplesand the joint front door stops on the old name, but the shared PSIS decline vocabulary (draws_too_few, surfaced throughpareto_k_declined/diagnostic_summary()on every nested-Laplace backend), thek_tail_pointstail-cap warning, and thek_qualityband-confidence reason each still told the reader to raise it. The roxygen also carried"the legacy k_samples name is accepted as an alias for k_samples", self-referential and false – there is no alias. All corrected tocontrol$k_samples, along with the internal comments that calleddiagnose_draws“the user-facing precision knob” and were what those messages were written from;fit$diagnose_drawsremains the returned field’s name. A source lint intest-settings.Rfails on an imperative aimed at a dead knob name.
tulpa 0.2.1
A declared hyperprior meets the outer grid in one coordinate, and a declared point mass keeps the prior probability it declares. Three ways the same rule was stated twice. The joint driver’s
prior_sigma/prior_alpha/prior_phifamilies are documented as densities on the axis’s natural scale and were added straight tolog_marginal, whose cell widths are measured on the axis’s integration coordinate, so the realised prior waspi(theta) / thetarather thanpi(theta): on a 9-node geometricsigmagrid apc.prec(U = 2, alpha = 0.05)put 22.7 % of its mass on the smallest node where the declared prior puts 4.3 %. The change of variables now happens in one place,.hyper_prior_carry(), shared by that fold, the generic driver’s, and the axis quadrature, and the joint path reproduces the closed- form cell probabilities of its own declared prior to quadrature error (max 0.0034, from 0.184). A density on an axis describes that axis’s CONTINUUM, so it is no longer read at the zero level of an axis carrying anatom_mass– where the change of variables had made itlog(0), giving the atom zero weight whatever the declared mass said, and where a density finite at the origin instead reweighted the atom against the continuum, so the declared split was not the split the fit integrated. The continuum’s declared density now sets its SHAPE and1 - atom_masssets its share, and.hyper_atom_fold_scale()weighs the atom on the continuum’s own scale where the density is folded intolog_marginalrather than carried in the weights. A declaredatom_massis therefore the prior probability the fit integrates, whatever the density is and however far the nodes reach (gcol33/tulpa#624, gcol33/tulpa#625, gcol33/tulpa#626).The recommended
prior_alphaon the copy scale moves fromc(U = 8.0, alpha = 0.01)toc(U = 4.0, alpha = 0.01), and the half-normal from scale 2 to 1. The old numbers were calibrated against a realised prior ofpi(a)/a, so correcting the coordinate moves the wholeUscale. What sets it is the measure the axis carries with no prior at all, and on the copy scale that is not flat: with noprior_alphathe axis takes the engine’s own exponential slab, whose rate is read off the declared grid by putting 5 % of the prior above its largest node. On this fixture’s alpha grid (largest node 2.0) that islambda = 1.50, the strength of a PC prior atU = 3.07. A userprior_alphaREPLACES that slab rather than adding to it, so it shrinks harder than the default only once its ownlambda = -log(alpha)/Ubeats 1.50 – which is why the bias crosses truth near the slab’s own strength and whyU = 8(lambda = 0.58) reads as anti-shrinking. Swept overUontest-nested-laplace-joint-sigma-pos-prior.R’s own fixture and 50 seeds (alpha truth 1.0, coupled sigma truth 0.6), the alpha geometric bias is monotone inUand crosses truth nearU ~ 3.4: flat +0.056,U = 8+0.116,U = 4+0.049,U = 2-0.071,U = 0.25-0.491.U = 8now shrinks LESS than the axis’s own no-prior measure, which is why it read as a regression rather than a stale constant. Nothing in tulpaObs sets these knobs:control$prior.sigma/.alpha/.phiare forwarded only when a caller supplies them, so no downstream default was calibrated against the old measure. The over-shrinking arms reproduce gcol33/tulpa#22’s cross-axis mechanism unchanged – pulling alpha below its truth lifts the coupled donorsigmaabove 0.6, to 0.94 atU = 0.25– so the recommendation’s REASONING stands and only its calibration moved. The test now also asserts what #22 actually claims, that the regularizer beats the flat axis, which no bound in it had been testing.A reported per-axis
theta_sdcomes from the estimator that axis’s own resolution calls for. It was the 3-point parabola at the modal node wherever that fit, which reads the curvature at the mode: a Gaussian summary, and one that moves with the spacing of the three nodes it reads. On the copy axis of a coupledoccu_coverfit it measured 0.150 against 0.294 on two grids over one data set, and sat four times below the spread it stood in for. The report is now the weighted spread of the axis marginal wherever the axis is resolved, and the parabola only where the marginal has collapsed onto too few nodes to have a spread – the case it was added for, where the weighted read is a floor at zero. The two regimes are separated by the axis’s own quadrature effective sample size, read off the weights, so the choice is not one estimator judging the other:.nl_diag("axis_sd_ess")carries the threshold and the ladder it was measured off. On a Gamma(1.5, 2) marginal the weighted read spans 2.26 to 2.30 across three grids where the parabola spans 1.16 to 1.88.theta_sd_source/theta_sd_ess/theta_sd_stencil_declinedtravel on the fit, so which estimator produced a reported SD is a property of the fit. The choice is made in.nl_posterior_moments(), so every nested path reports one rule: the single-block and spatiotemporal paths always reported the weighted read, a floor at zero on a collapsed axis, and the joint paths carried their own call. A design-weighted grid keeps the weighted read whatever its ESS, since a central-composite design’s nodes are not a per-axis lattice for a parabola to read; that is recorded asdesign_weightedrather than left implicit.The var-of-means consistency pass triggers on the axis ESS, not on one SD estimator compared against the other. Its old test was the weighted SD against the parabola, which with the weighted read now reported would have been the estimator against itself and the pass would never have fired. It asks the question it is actually about – how many nodes the marginal spreads over – and keeps the parabola as the SCALE its new points are placed at, which is the regime that estimator is right in.
control$var_of_means_ toleranceis replaced bycontrol$var_of_means_min_ess; the moved knob hard-errors.An outer axis holding weight on a boundary node is named, whether or not its mode sits there. Both producers of an edge label tested the MODE: the rail returns early unless the boundary node is the axis marginal’s argmax, and the grid regime returned
"spread"without inspecting an axis as soon asess_grid >= 2. So an axis with a third of its marginal on its last node and its mode one node in was reported clean, while truncating its own tail exactly as a railed one does.outer_grid_edge_mass_axes(and the regime’sedge_mass_axes/_sides, read in every regime) is the second label, in the rail’s own currency – the boundary node’s weight against what a flat marginal would put there, so the threshold means the same at any node count..nl_diag("edge_mass_lift")carries it:1is where the boundary node carries what a flat marginal would, and over 1400 arrangements of four marginal shapes it names none that leaves less than 0.7 % of its own marginal outside the span, at a false-alarm rate of 0.011 against “more than 5 % outside”.railedkeeps its own stronger statement, that the span does not contain the axis’s own mode.The axis marginal every one of those labels reads carries the grid’s measure.
.nl_axis_marginal_w()normalisedlog_marginalalone, so a rail, a resolution read and a reported mean were taken against three-quarters of the same object on a grid whose spacing is not uniform.Refinement may not move a declared support. The consistency pass’s slice points are clipped to
slab_boundsthe way the extension proposals already are, so a node outside the declared prior support is not solved for a cell that would carry zero weight. An axis whose posterior presses on that support reports it through the boundary-mass label above.A declared axis prior says which coordinate it is a density on. An outer axis prior could be declared two ways and the two were read on two coordinates:
slab_log_densityis carried across explicitly (+ log(x)on a log axis) while a spec’slog_priorenteredlog_marginal, which is weighted by cell widths measured on the integration coordinate – so a caller writingdexp(sigma)got a prior tilted by1 / sigma.hyper_axis_spec(log_prior_coord = )names the coordinate:"integration"(the default, and what every existing fit was taken under, since it is also what the flat default’s zero contribution is flat on) or"natural", which declares a density on the axis value and picks up the change of variables. Inert on a linear axis, where the two coincide (gcol33/tulpa#623).The outer hyperparameter grid is a quadrature rule for a declared prior, not the prior itself. Every node carried the same prior weight, so a grid that gained nodes after seeing the data carried a different measure than the one it started with. On a coupled
occu_coverfixture the copy axis went from 6 nodes to 11 and its span from[0, 3]to[0, 16.4], which moved the point mass atalpha = 0from a sixth of the prior to an eleventh and moved the posterior mean ofalphaby 18 %. Nodes now carry an integration weight built from their spacing (R/hyper_quadrature.R), so placing them differently changes the quadrature error and nothing else. On an evenly spaced grid the weights are equal, which is the rule the engine already applied, so an unrefined grid is unchanged.The copy scale carries a declared prior.
p(alpha) = w0 * delta_0 + (1 - w0) * Exponential(lambda), withw0 = 0.5andlambdaset to put 5 % of the prior above the largest node of the grid the caller declares, which isExponential(1)on the default grid. Both parts are fixed before the fit, so refinement may follow the posterior past the declared nodes and still be integrating the measure it started with.control$copy_atom_masssetsw0, and a caller-suppliedprior_alphareplaces the declared density rather than compounding with it.sigmaand the dispersion axes keep a flat measure over their declared span.fit$copy_atomreports the point mass atalpha = 0against the posterior mass left on it. A point mass competes with a continuum, so a posterior concentrated at zero can come from either the data orw0, and the two numbers together are what separate them. On a weakly identifying fixture the defaultw0 = 0.5leaves 0.81 of the posterior on the atom against a truealphaof 1; atcopy_atom_mass = 0.01the same likelihood locates the mode.Multi-block axes resolve their scale metadata on the bare axis name.
.joint_axis_specs()matchedsigmaandalphaexactly, so a multi-block grid’sb1.sigmafell through to the natural scale. This was invisible while every node weighed the same and became a difference in the weights once they tracked spacing.Every path that turns
log_marginalinto posterior weights carries the same measure. The single-block and multi-blocktulpa_nested_laplace()paths and both spatiotemporal paths formed their weights from the log-marginal alone, so the declared prior reached only the joint path. All of them now go through.nl_grid_log_quad(), and each fit records the span its quadrature integrated infit$axis_supportso a second engine can read it rather than rebuild it.An axis’s integration coordinate is declared where its grid is built.
.hyper_axis_scale()covers the axes the joint and single-block dispatchers name,.nl_st_axis_specs()declares the spatiotemporal ones beside.st_log_grid(), and an axis neither covers carries no quadrature weight at all, which leaves its nodes equally weighted. Reading the coordinate off the node spacing instead would let the data choose the measure, and treating the log-spacedtau_spatial/tau_temporalaxes as linear moved the spatiotemporal grid’s weights.The CCD designs a copy scale on the coordinate its measure lives on, and splits at the atom.
.joint_ccd_grid()took the copyalphafrom the outer Pareto-k registry, where it is carried as an unconstrained real, and placed an affine design on it. That coordinate has no positivity, so design nodes ran below zero: on a two-block coupled fixture, five of twenty-five cells were solved at a negative copy scale, a sign-flipped coupling outside the model’s support. Nor did any node land atalpha = 0, so “no coupling” carried no posterior weight on the CCD path at all while the tensor rule integrated it as a level. The continuum is now designed inlog alpha, and because a copy scale is a point mass at zero beside that continuum, the outer posterior overnsuch axes is a mixture of2^ncomponents: one design per subset of couplings switched off, combined by the prior mass each configuration declares (control$copy_atom_mass). The two-block fixture goes from 25 nodes to 64, against 144 for the tensor grid it replaces. A grid with no copy atom is a single component and is designed as before, on the same nodes. Past2^n = 8components the CCD declines to the tensor rule (copy_atom_components). A split design hands the outer Pareto-k no mode-Hessian proposal: its components are built inlog alphawhile the diagnostic unconstrains the same grid on the identity coordinate, the only one that represents thealpha = 0cells, and one Gaussian is not a mixture either way. The diagnostic keeps its grid-weighted proposal, which the split’s spread of components supports;pareto_k_proposal_sourcereadsgrid_momenton those fits.A grid column holding one value across every cell is a fixed setting, not an axis of the grid.
.joint_axis_specs_from_grid()built an integration axis for every column oftheta_grid, so a constant column was handed to the quadrature as a coordinate to integrate over. A constant contributes the same factor to every cell and cancels when the weights are normalised, and some such columns are not quantities to integrate at all: a Poisson count grid carries anr = Infnode to pin the negative-binomial size, which.nl_grid_log_quad()then rejected as a non-finite grid. Constant columns are dropped before a spec is built for them, so a pinned sentinel leaves the weights identical to the same grid without it, and a held-but-finite setting shifts every cell by one common constant.tulpa_nested_laplace()refuses a non-finite response instead of blaming itself for one..assert_finite_model_inputs()was wired into three doors by hand and not into this one, so an NA response was absorbed rather than rejected: every grid cell returnedconverged = FALSEwithlog_marginal = 0, the softmax over those weights was uniform, a fit came back, and the grid-Hessian retention then found noQon any cell and reported the user’s own missing data to them as “This is a tulpa bug; please report.” The guard now runs inside.validate_glm_design(), the one validator every(y, X, n_trials)door goes through, sotulpa_gibbs(),fit_st_nested()and there_covfitters are covered by the same fix and a door added later inherits it (gcol33/tulpa#613).The outer Pareto-k diagnostic no longer trips the caller-facing grid-size warning. The re-evaluation substitutes
control$k_samples(default 200) importance draws for the block’s grid axis and re-dispatches through the ordinary fitter, so a 7-node fit warned that its grid had 200 cells and advised reducing per-block grid sizes – advice about a number the caller did not choose and that remedy does not reach..nl_internal_batch()is the one predicate separating an internal batch from a grid the caller asked for, and the joint side reaches it through.joint_with_quiet_opts(), which already quiets the checkpoint and the progress bar for the same class of leak. The hard cell cap is deliberately not gated: it is a resource ceiling, and an internal batch costs exactly what a user one does (gcol33/tulpa#614).The Windows arm64 parallel failure is the OpenMP reduction losing a thread’s contribution, and no fit goes through it.
tulpa_parallel_sum()cuts a range into contiguous chunks by index arithmetic and adds the chunk totals in chunk order, precisely so nothing about a sum is left to the runtime, and every hot loop was moved onto it – but the two fixtures behindtest-parallel-equivalence.Rwere left on a rawreduction(+:)clause, so the failures they produced were on a construct that ships nowhere. Read off the r-universe R-release build’s own numbers:log_lik = -669.2against a true -898.6, and the missing 229.417 is, to the four figures the report printed, exactly the last chunk of a 4-thread static split (chunk sums -221.97 / -219.51 / -227.70 / -229.44; dropping the fourth gives -669.18). One private copy was not added in. The iterations ran – the same build passed the per-elementresultsidentity at every thread count – so what failed is the combination the runtime performs at the end of the region, which is the one steptulpa_parallel_sum()never asks it for. Both constructs are now computed over the same values in the same call, beside the per-observation results, the requested thread count, the resolved team size and the team the loop actually ran on; the reduction tolerance is the summation bound the values imply rather than a fixed constant (2.6e-11 and 1.0e-10 absolute here, against the 1e-10 relative that failed, so the shipped construct is held more strictly than before); andsum_shippedcarries the verdict whilesum_omp_redwarns with the numbers, since a red suite there would be a third-party runtime defect on a construct no fit can reach (gcol33/tulpa#610).parallel_block_reduce()no longer carries areduction(+:)clause. It was the last one in the tree outside the test fixtures, in the package’s own linear-algebra header, named for the jobtulpa_parallel_sum()does and carrying the construct that job exists to avoid. It has no caller, so nothing was non-reproducible; it is where the next caller would have picked it up (gcol33/tulpa#618).CI checks the arithmetic the package actually ships.
-ffp-contract=offpins a C++ result and the same expression in R to the same rounding, which is what makes a numeric assertion comparable across the matrix – but with it on every job, nothing in CI compiled the arithmetic CRAN and r-universe build. 0.2.0 was green on all four platforms and then failedchecking testson every aarch64 r-universe build.fp-contractis now an input on thebuild-envaction, and macOS arm64 – the only platform in the matrix where FMA is reachable, since it is baseline on aarch64 and absent from the generic x86-64 the others compile for – runs both ways. A failure the unpinned job alone shows is the contraction; one both show is the algorithm (gcol33/tulpa#611).plot.sbc()’s arguments are documented by the block attached to it. An internal helper had been inserted between the@paramtags and the function, so the#comment opening it ended the roxygen block and the tags attached to the helper, which carries no title and discards them without a warning.man/sbc.Rdstill listed the four arguments only because it predated the move: regenerating the documentation deleted them, and the nextR CMD checkafter that would have reportedUndocumented arguments in documentation object 'sbc'– a WARNING, on whichever unrelated commit happened to rundocument()(gcol33/tulpa#619).The calibration surface is documented.
sbc()and the predictive shapes it consumes shipped exported and tested, and appeared in no reference index, no vignette and no README section, so the only way to find them was to readNAMESPACE.vignette("sbc")is the new article: both experiments run end to end on a nested-Laplace fit, with the deliberately mis-scaled control arm the run has to reject, the guards each experiment’s premises are checked by, and the measured disagreement between this and the per-fit reliability band in both directions (gcol33/tulpa#339).api.mdgains the matching section, and the README aCalibration, validatedone.Every export is in the reference index.
_pkgdown.ymllisted 63 of 164 exports and swept the rest intointernalthrough thelacks_concepts()catch-all, sosbc(),tulpa_laplace(),tulpa_eb(),tulpa_ep(),tulpa_posterior_draws(), every prior builder, everyplot_*()and the wholeas_draws/ranef/tidy/glanceinterop surface were absent from the published index. Nothing user-facing is hidden now; the sections gained arePriors,Reading a fit,Calibration,Plots, andOuter-grid integration and utilities. The twohas_concept("spatial")/has_concept("temporal")selectors matched nothing – no Rd in the package carries a\concept{}– and the explicit lists that were doing the work are kept.A folded SBC panel reads the folded verdict.
plot.sbc(folded = TRUE)drew the folded ECDF and annotated it with the RAW p-value and the RAW band verdict. The fold exists to catch a symmetric dispersion error the raw ECDF cancels, so the one case it matters for is exactly the one the mislabelling broke: a panel visibly outside the band reportinginside. Both reads now come from one.sbc_panel_note()(gcol33/tulpa#612).api.mdnamedspatial_hsgp(),find_latent_terms(),no_latent_terms()andparse_bar_term()as public. The first does not exist – the Hilbert-space basis isspatial_gp(approx = "hsgp")– and the other three are internal. Thirty-eight exports it did not name are now listed, includingtulpa_re_aghq(),fit_st_nested(),tulpa_hyper_grid(),tulpa_multinomial()/tulpa_ordinal(),temporal_ar()/temporal_ar2(),VarCorr(),posterior_sample()/mcmc_draws(),diagnostics()andlaplace_diagnostics().The published changelog was an empty page. pkgdown reads a version heading as
# <package> <version>; this file used## <version>under a# tulpa NEWStitle, whichbuild_news()reports as no version headings found and renders as aChangelogheading with nothing under it. All 225 entries are now# tulpa <version>, which is also the formusethis::use_news_md()writes and R’s own NEWS.md reader parses (225 versions, none with anNAversion) (gcol33/tulpa#615).The site build refused a correct build once the changelog rendered.
pkgdown/build_site.Rasserts thatCLAUDE.md/AGENTS.md/api.md/todo.mdreach neitherdocs/nor the search index, and tested the second by searchingsearch.jsonfor the stringAGENTS.html. The release note describing that guard contains that string, so the guard failed on its own documentation. It now matches a search entry’spath(gcol33/tulpa#616).The README’s release pin and citation still read
0.1.0.
tulpa 0.2.0
-
A varying coefficient’s level is identified by centring on both SVC parameterizations (gcol33/tulpaRatio#25). A term contributes
eta_i += x_i w(s_i), sow -> w + cwithbeta -> beta - cleaves eta exactly unchanged and the alias has to be removed before the field reaches the likelihood. An NNGP field’s prior is PROPER, so its constant direction already carries a prior (precision1' Sigma^-1 1) and needs no penalty supplying one – it needs removing from the likelihood, which is what centring does. The non-centered path already centred; the centered one carried a soft penalty on the sum ats2z_precision(n_obs)instead. Both now go throughsvc_center_eta, and the stored draws are centred under either, sosvc_wsums to zero exactly rather than being pinned near zero.Measured, paired arms on the poisson SVC fixture differing only in the constraint, 4 chains x 400 iterations: the penalty was NOT misbehaving on the centered path.
beta_x1.661 (penalty) against 1.654 (centring) at n = 80 and 1.632 against 1.598 at n = 120, truths 1.650 and 1.647; Rhat 1.04 / 1.02 and 1.03 / 1.04; no divergences either way. The change is one construction for one alias, not a repair of a measured defect.s2z_centre_blocks()(tulpa/sum_to_zero.h) is the single centring the four sites that had hand-written the mean subtraction now share, and that header carries the proper-vs-intrinsic derivation, including why no constant makes a ridge right: matched to the field’s own prior on the sum it is a term the field prior already carries, and anything else is a second, unstated prior on the level. The unreachable plain-doublecompute_svc_etaandsvc_sum_to_zero_penaltyinhmc_svc.hare deleted. plot.sbc()keeps the uniformity p-value clear of the panel title. Thepline is drawn withmtext()under a title set atline = 1.9in a top margin widened to suit, so on a grid of panels the two no longer overprint. A panel whose ECDF leaves the band reads, outside bandrather thanOUTSIDE.outer_regime = "collapsed_interior"is documented by what the collapse costs. Thelaplace_diagnostics()return-value entry called an interior collapse “benign”. It now states that the fit is empirical Bayes at the mode: the point estimates are sound and hyperparameter uncertainty is not integrated. That is what the regime note printed with the fit already says, and what the Details section spells out; only the one-line summary was short enough to be read as an all-clear.svc(),tvc()andtemporal()can read the fitstulpa()produces (gcol33/tulpa#607, #608, #609). All three accessors looked for the field spec at$svc/$tvcand the posterior at$.internal$*_draws, and the front door #158 wired sets neither: it attaches the spatial spec at$spatialand puts the field in$drawsunder the name the sampler gave it. So every accessor errored on every fit the engine could make, and told the user to pass ansvc =/tvc =argument thattulpa()does not have.tulpa()now attaches the validated temporal spec beside the spatial one, and the accessors read the spec from either slot and the field out of$draws. The reshape follows the flat layout the eta assembly indexes – observation- fastest within a term for SVC, time-fastest within a (group, term) for TVC – so a coefficient lands on the unit it belongs to;test-varying-coef- accessors.Rchecks each element against the column the sampler wrote it to, which is the assertion the front-door tests were missing (they readcolnames(fit$draws)and never called the accessor). A grouped TVC field is refused rather than reported as its first group, sincetulpa_tvc_posteriorcarries no group axis.The compile cache is reportable without being created, and clearable.
tulpa_cache_dir()takescreate = FALSE(the default) and only reports the path; the directory appears where a compile needs it. Newtulpa_cache_clear(older_than =)removes cached builds – the cache holds build artefacts only, so an entry it drops is rebuilt by the nexttgmrf_cpp()call on the same source.Examples: thirteen help pages carried their example as commented-out code, eleven of them entirely, and now fit a small model and call the function. The SVC, TVC and multi-scale-temporal examples were written against argument names (
svc =,tvc =,iter =,chains =) that the front door does not take and would have errored if run; they use the current API and run. The labels calling SVC and TVC unsupported or experimental are gone.Language: en-USdeclared in DESCRIPTION.
tulpa 0.1.23
-
tulpa_re_aghq()returns the optimizer’s evaluation counts. The joint driver is onestats::optimcall, and nothing on the fit said how much work it took, so a consumer reporting that had onlyNAto report on a fit it had just declared converged (gcol33/tulpaObs#281).countsisoptim’s ownfunction/gradientvector, passed through verbatim: BFGS counts evaluations rather than iterations, and relabelling them would have made the number say something it does not.
tulpa 0.1.22
-
The AGHQ failure sentinel is refused as an optimum, and it is named once.
cpp_aghq_objective()reports-1e10where a group’s solve failed – a finite value, sincestats::optimneeds one to reject. Nothing checked whether the accepted optimum carried it, and it is finite, so it passed every downstream guard:log_marginalcame back as the sentinel (a consumer adds that to other terms and weights a grid with it),theta_covwas the finite-difference curvature of the penalty rather than of a likelihood, andreltol = 1e-9against|f| = 1e10is an absolute tolerance of 10 nats, so a run that started in the failure region declared convergence after one step of whatever ridge was in play.tulpa_re_aghq()now refuses such a run twice – at the starting parameters, where the groups behind it can still be named, and at the optimizer’s own stopping point – returningNULLwith a warning that names them, andagq_fit()errors instead of reporting the sentinel as a log-likelihood. The value itself iskAghqFailPenaltyinsrc/aghq_re_core.h, read across the language boundary throughcpp_aghq_fail_penalty(), so neither the C++ producers nor the R consumers write the literal (gcol33/tulpa#606).
tulpa 0.1.21
-
The per-group AGHQ solve status is on the fit.
tulpa_re_aghq()computedgroup_okfromcpp_aghq_blups(), raised a warning naming the first few failed groups, and then discarded it, so a caller could learn WHICH groups came backNAonly by trapping the warning and parsing indices out of its message text. It is now returned alongside the per-group quantities whoseNAentries it explains –blup,blup_var,blup_cov_g,blup_cross_g– as a logical of lengthn_groups, so a consumer conditions its reads on it. The detection itself is unchanged; only the reporting channel is new (gcol33/tulpa#605).
tulpa 0.1.20
-
The bound-reading families pass the compiled-impl gate.
builtin_family_spec()refuses any familyfamily_has_compiled_impl()does not recognise, and that predicate asks the mu-space ladder.interval_gaussianandtruncated_gaussianare not on it: their densities are functions of a per-observation bound rather than of mu, and the spec’s callbacks reach their closed forms through the response payload’s bound arrays instead. Both were refused at construction despite being fully compiled (log_lik_/grad_hess_interval_gaussianand their truncated counterparts), which took down every joint-arm fit declaring one – on the only route those families have.family_reads_response_bounds()names the pair and the gate consults it beside the ladder.The same branch has a second side: a payload declaring one of these families but carrying no bound fell past the callbacks’ bound branches into the ladder, which does not carry them and raises from inside an OpenMP reduction –
std::terminaterather than an R error.prepare()now checks the bound is present on the calling thread, beside the tweediephi2check that is there for the same reason. Tests:test-bound-family-joint-arm.R, covering both sides of the branch. rstantoolsis declared. The test suite reaches one borrowed generic from it; it was used without being named inDESCRIPTION.
tulpa 0.1.18
-
A bounded link builds its tail rather than recovering it (#602). Cauchit’s
linkinvwas0.5 + atan(eta) / pi, which in the lower tail subtracts two nearly-equal quantities and loses a digit per decade of|eta|: measured againstpcauchy, relative error5.5e-12ateta = -1e5rising to2.0e-07at-1e9.atan(x) + atan(1/x) = pi/2gives each tail directly –atan(-1/eta) / pibelow zero,1 - atan(1/eta) / piabove it – which is equal to the old expression in exact arithmetic and free of the cancellation.linkinvis one function behind the generic family route, so this reaches the density, the score, the Newton working weight and both curvature ladders forbinomial_cauchitandbeta_cauchitalike. The R-side registry (.LINKS$cauchit) now readsstats::pcauchy/stats::qcauchy, matching how probit and logit already readpnorm/plogis.mu_etaand the higher derivatives were stable and are unchanged.The convention this follows is stated once above
linkinv: a bounded link’s tail is built, never recovered. logit branches on the sign of eta, cloglog uses-expm1(#454), cauchit uses the arctangent identity. The stable form differs per link, so there is nothing to factor out – the rule is what is shared. Tests:test-family-link.R, reading the ladder againstpcauchyon relative tolerance and pinning that the cancelling form it replaces is wrong by orders of magnitude more, and worse the further out it is read. -
cpp_laplace_fit_multi_rechecks the length of every argument it indexes (#469).Rcpp::Vector::operator[],Matrix::operator()andList::operator[]are unchecked, and this entry sizes its loops fromy(or from the RE term count read offre_ngroups) and then indexesX,n,weights,offset,re_ncoefs,re_sigma_listandx_init, each supplied separately. A short one was read past the end of its allocation: nothing crashed, the solve converged, and the result carried no sign of which numbers came from the data – the same failure mode as the NNGP coordinate read in #389. All seven are now checked.cpp_laplace_fitalready reached its checks throughas_re_group_vecandbuild_spec_family_inputs.check_arg_lengthis the one place either entry raises that error, so the two no longer carry their own copies of the message. Tests:test-laplace-arg-lengths.R, which also pins that each argument is still accepted at its own length and that supplying all of them reproduces the base fit. The copy-spec checks are pinned at all three entry points (#465 item 1).
resolve_copy_arm_of_blockrestored the two checks its clones had lost, but nothing asserted they fire.test-joint-block-spec-guards.Rnow drives each of the three entries –cpp_nested_laplace_joint_multi, its batch sibling, andbuild_joint_layoutthroughcpp_test_joint_pattern– with an unequal pair, an out-of-range block, an out-of-range arm and a block claimed twice, and holds the no-copy sentinel to being no copy at all.Closed #468 with no code change: the sparse joint Newton loop’s factorization-failure fallback stopped diverging from the shared one at 12b641d. Both drivers call
newton_damped_fallbackandnewton_step_tail.
tulpa 0.1.17
-
The eleven nested-Laplace grid entries share one tail (#603, closing the last item of #465). Each entry used to build its own checkpoint, resolve its own skew-probe indices, unwrap its own debias / CILA options and write out its driver’s full argument block, so the argument order the three drivers expect was written eleven times and an argument added to one of them had to be threaded through every site by hand. That is how
force_sparsecame to be live at one spatio-temporal entry and discarded at two.NlEntryInputs(src/nl_entry_inputs.h) now carries the eighteen shared response, design and control arguments, and one runner per driver –nl_run_multi_block_entry,nl_run_joint_sparse_entryandrun_st_spatial_entry– owns the checkpoint, the unwraps, the driver call and the axis attachment. An entry supplies its field: the structural fingerprint, its blocks, its grid axes and the axes it reports back.Collecting the bundle is one argument-free macro rather than a per-site aggregate initializer. The package builds at C++17, where an aggregate initializer of sixteen members – several adjacent
ints, several adjacentdoubles – can be mis-ordered at one of eleven sites without the compiler seeing it, and the resulting fit would run at the wrong iteration budget or the wrong tolerance. Every entry declares these arguments under identical names, so one member-by-member token sequence binds them at all eleven sites and cannot bindmax_iterton_threads.Behaviour is unchanged: the entries’ existing test files are the gate, and
test-nl-entry-forwarding.Ris the new arbiter for the failure the duplication produced. It drives all eleven entries and asserts each shared argument is observable in the fit –max_itercaps the iteration count,x_initmoves a capped solve’s mode,store_Q/compute_skew/debias/cilaeach add their fields, and the twelve that enter the checkpoint fingerprint each make a resume against a written checkpoint refuse. A build with one bundle member dropped fails it at all eleven entries.
tulpa 0.1.16
A block’s per-row design weight is read on every contribution kind (#463).
LatentBlock::row_weight– thesvc_weightthat makes a field a varying coefficient – was evaluated in one branch of each block walker, the one-cell-per-row areal kind. A block reaching its latent through a projector (SPDE, INDEXED_MULTI) or a basis (HSGP, DENSE_BASIS) dropped it. Both walkers dropped it identically, so eta, the gradient and the Hessian stayed mutually consistent and no finite-difference check could see it: the fit was simply the unweighted model, returned with nothing on the object to say so.block_row_weight()now lives beside the block definition and every walker – the single-arm spec eta and scatter, the joint eta, the joint scatter, the cached indexed plan and the coupled-row collector – folds it into the block amplitude ahead of the kind split. Unset is 1.0, so an unweighted block is bit-identical.cpp_nested_laplace_multireadssvc_weightonce for every block type rather than inside theicarbranch, andtulpa_nested_laplace()accepts it on any block it builds; the weight’s length is checked against the observation count. Tests:test-block-row-weight.R, which pins the layer the weight enters at by fitting the same model with the projector pre-scaled by hand.One centering rule for latent blocks, recorded on the fit (#458). Whether a field is reported sum-to-zero (with the removed constant folded into an intercept) or at its own mode differed by block type and, within
car_proper, by whether the block was a copy. The rule is now the prior’s rank everywhere: an intrinsic prior (ICAR, BYM2’s structured component, RW1 / RW2) has a null direction the data cannot identify against the intercept and is centered; a full-rank one (proper CAR, AR1, IID) has none and keeps its mode. Centering a full-rank field reports a (field, intercept) pair whose joint posterior density is below the mode’s – the fold preserves eta, so the shift moves both priors away from their argmax.block_centeredon the returned list says which convention each block took, so comparing two fits of the same field does not silently compare two splits.log_marginal, the standard errors and eta are unchanged: every driver evaluates them at the pre-centering iterate.interval_gaussianno longer reports a floored plateau as a mode (#462). The interval probability was differenced on the natural scale and floored at1e-300. Once bothpnormtails underflow – about 38 sigma out, which a poorly scaled predictor or a bad warm start reaches transiently – the kernel returned a finite log-density, an exactly zero gradient and a floored positive curvature. The line search accepts a finite objective and the convergence test readsmax|grad| == 0as a mode, so the solve stopped there and built its log-marginal from the floor constants. The probability is now formed in log space (the difference taken in whichever tail keeps its exponent) and the gradient and curvature as ratios of logs, so a far interval returns a gradient of order(edge - eta) / sigma^2pointing back at it. An empty interval returns-Inf, which the line search backtracks off.Rmath is gone from the family parallel path (#461).
lgammafn,lchoose,digamma,trigamma,psigammaandpnormraise their domain and range paths through R’swarning()/error(), which touch R’s global error state and can longjmp – out of an OpenMP structured block, which isstd::terminate.tulpa/portable_math.hnow covers every routine the family log-likelihood and curvature ladders reach, and neither header calls into Rmath any more.test-portable-math.Rscores each replacement against R’s own over the argument ranges the families use, including across the recurrence switch where the asymptotic series is at its weakest, and fails if anR::call reappears in either header.An unregistered family, and tweedie without its variance power, are raised on the calling thread (#459). Both stops sit in per-observation code that runs inside a reduction. The family-enum entries hoisted theirs already; the
LikelihoodSpecroute now checks the tweedie variance power inBuiltinFamilyResponse::prepare(), which runs once, serially, before any solve and is the first point that sees the family andphi2together.The observed-curvature second derivative declines instead of guessing (#464).
obs_curvature_deta2_for_family()summed the working curvature’s second eta-derivative with the observed-minus-working delta’s. Where the delta’s second derivative is unregistered that term contributes zero and the sum is the working answer wearing the observed one’s name – finite and smooth, and wrong by exactly the delta, which is what the closed-form outer Hessian, the hyperparameter standard errors and the grid weights are then built from. It now returns NaN there, ascurvature3_obs_for_family()already did, andmixture_curvature_deriv2()declines in all five fields at once rather than leaving one finite field readable.cpp_family_obs_curvature_deta2exposes the value and the gate together.CheckpointLog’s readers take the mutex its writer takes (#460).save()locked;has()andget()did not, so the class as documented invited a reader on one worker to walk a bucket array another worker’semplacewas rehashing. The current consumer snapshots the log serially before its parallel region, so nothing raced; the lock is what lets the next one do otherwise.n_loaded()had no caller and is gone.Dead surface removed from the nested-Laplace entries (#466).
force_sparsewas an exported argument ofcpp_nested_laplace_st_hsgpandcpp_nested_laplace_st_nngpthat both discarded – an argument the generated R wrappers advertised with a default while three sibling entries with the same signature honoured it. Dropped from both.TULPA_COUPLING_FORCE_PARALLEL, which takes the chunked coupling reduce on every cell so a small grid exercises the parallel path, is documented at?tulpa-envvarsalongsideTULPA_GRID_WORKSTEALand tested:test-coupling-force-parallel.Rruns a coupled fit in two subprocesses and asserts the two answers are identical.Six pieces of the nested-Laplace kernels written twice or three times, now written once (#465). The copy-spec parse (three copies, two of which had lost validity checks), the unit-precision IID prior (three), the per-cell coupled arm views (two),
center_joint/log_prior_joint(two) and the proper-CAR block construction (two, plus the joint branch’s own copy / non-copy pair) each meet at one definition:resolve_copy_arm_of_block(),set_unit_precision_block_priors(),CoupledArmViews,center_joint_blocks()/log_prior_joint_blocks()andset_car_proper_block_priors(). The eleven grid entry-point tails are not collapsed – see the note in #465.src/comments describe the code, not the repository’s history (#467).src/ships in the tarball, so its comments are public. Every issue-tracker reference, planning-stage token (Stage 1.3,Change 2b,Layer B.1) and account of what the code used to do is rewritten to state the current rule and its reason. The stale claim that the cross-arm Hessian and the sparse coupled twin had not landed is gone; both are present.DEFAULT_SIGMA_BETAjoinsDEFAULT_TAU_BETAso the same weak fixed-effect prior has one definition in the two parameterizations the kernels read it in, tied by a static assertion.
tulpa 0.1.15
-
The next ten audit issues, and the one that was still a live drop (#445, #447, #448, #450, #452, #453, #454, #455, #456, #457). Nine had their
src/work already in the tree from earlier refactors and were missing the arbiter their checklist named; #448 was not fixed at all.#448 an unreadable difference is not a smaller skew.
cell_curvature3.hstates twice that a cell whose differenced Hessian comes back non-finite takes the whole contraction to NaN, so a broken difference can never read as an understated cubic term. The per-arm loop ten lines above the guard did the opposite: a false return fromblock_formsdropped that arm and let the rest of the cell sum to a finite number, and theisfinitecheck below it can only see the terms that SURVIVED.gamma_3feeds the skew correction, so understating it moves the correction toward zero – the direction that reads as “the Gaussian approximation was fine” – and the arm most likely to drop is the one where it was not. Bothblock_formsexits now return NaN.h <= 0had collapsed two meanings into one skip: an arm the probe direction does not move contributes exactly zero, which is a value, while a moved arm whose step could not be sized is unreadable, which is not; they are separated. The row-index reads that reachRcpp::NumericVector::operator[]are bounds-checked, since a cell-row table disagreeing with the eta length was a read past the allocation.#447 one jitter semantics across the two NNGP conditional-moment cores, pinned by a probe rather than by construction alone.
cpp_test_nngp_cond_coresdrivestulpa_nngp::cond_momentsandtulpa_linalg::nngp_conditional_momentson one input: same moments at every jitter, both declining a covariance that is not PD, and the jitter shown to be a NUGGET (it moves a well-conditioned answer, which a pivot floor at the same value would not) against R’s own solve ofC + jitter I.#454 the cloglog lower tail, read on relative tolerance.
cpp_link_ladderexposeslinkinv/mu_eta/mu_eta2/mu_eta3so the ladder can be held againststats::binomial("cloglog")from eta -50 up, where the cancelling1 - exp(-exp(eta))had no correct digits left and returned exactly 0 – out of support for every consumer of the link. An absolute tolerance passes on that value; a relative one does not.#453 a separated class reports its finite log-likelihood. Pinned at gaps of 600 to 5000 against
eta_c - logsumexp(0, eta)computed without forming a probability, plus theclsrange refusal in both directions.#450 the uniform centering fold lands on an all-ones intercept, and #456 the per-arm index and weight closures own their own length bound. Both are checked at block-build time, so
cpp_test_joint_patternreaches them with no Newton iteration. The factories now verify the per-arm vector against the arm’s own row count where the vector is taken, rather than resting on the R producer two files away.#445 (SMC evidence and the tempering floor), #452 (
not_convergedon the skew and debias probes), #455 (the joint arm-spec views) and #457 (the two function-local statics) were verified against the current tree and already carry their fix and, where one is expressible from R, their test.
tulpa 0.1.14
-
The next ten audit issues are the tests they asked for (#427, #428, #429, #430, #432, #433, #435, #436, #437, #440). The
src/work for all ten had landed with earlier refactors; what none of them had is the arbiter each checklist named, so nothing held the fix in place. Each is now pinned against something outside the function that produces it.- #430 one floor on
mu. The generic mu-space route floorsmubefore dividing by it, and the density and the score used to floor at 1e-15 and 1e-7: in the band between them the two are derivatives of different functions, andtulpa_laplace()line-searches on the first and steps along the second.test-family-mu-floor.Rfinite-differences the score against the density across that band forbinomial_probit/_cloglog/beta_*, straddles the analytic floor so the density’s clamp point and the score’s are shown to be one number, and pins the worked example: ateta = -6a probit binomial’s score isdnorm(-6) / pnorm(-6), which a 1e-7 floor misses by a factor of 101. Both curvature ladders are held to the weight by the same finite difference. - #435 the SPDE implicit-diff entry reports the Laplace marginal. Its shipped test finite-differences the gradient against its own value, so it passes with
0.5 log|Q(theta)|dropped from both.test-implicit-diff.Rnow binds the value to the same cell ofcpp_nested_laplace_spde(1e-14 over six cells) and the normalizer to0.5 log|Q|of the R-assembled precision, and records that the term spans 200 nats over that grid with an exactd / d log_sigmaof-n_mesh. - #440 the fractional rSPDE marginal.
test-spde-fractional-marginal.Rrebuilds the whole marginal in R – the determinant lemma onB, the quadratic form through thePlmatvec, the stable binomial kernel – and matches it on the ordinary, weighted, confident-etaand zero-mass-node branches, with the materialised-probability form kept as the negative control that returns-Infon the same fixture. - #433 one neighbour-count rule.
test-nngp-row-neighbours.Rdrives the SVC and GP density twins on a row with an interior zero and shows it is read as its leading run, that the count-every-positive reading is a different number on the same fixture, and that an out-of-range or negative entry ends the row rather than resolvingnn_order[-1]. - #437 the SPDE PC hyper prior at general
nu. The shipped reference hardcoded thenu = 1sigma map and ran only atnu = 1, so it passed against either.test-spde-nu-general.Rnow reads.spde_range_sigma()and runs atnuin {1, 2, 3}, withnu = 2reachable fromalpha = 3with no user action. - #427 the GP Gibbs sweep draws from the FULL conditional.
pg_nngp_field_conditional()is the moment pair extracted out of the sweep so the probe and the draw read one derivation, andtest-pg-nngp-conditional.Rasserts it is rowiofLambda = (I - A)' D^-1 (I - A)assembled densely in R. On the fixture 18 of 20 locations carry child terms, so the parent-only reading was the rule. - #429 a non-finite gradient is a divergence.
test-nuts-nan-gradient.Rdrives a chain throughLikelihoodSpec::gradient_fnwhose log-posterior is finite and whose gradient carries a NaN: every iteration is now reported divergent, against a control arm that samples and diverges on nothing. The two leaf predicates are driven directly. - #436 a failed factorization is reported.
test-log-det-signal.Rhands the dispatch and the dense core an indefinite and an exactly singular matrix on both backends and asserts thebool, and that the 1e-10 base ridge does not rescue an indefiniteH. - #428 the HSGP warm start reads its indices.
test-hsgp-warm-start-guard.Rbuilds theModelDatano fitted path can produce –spatial_type = HSGPwithhas_hsgp = FALSE– and pins that the flag and the indices disagree there while the diagonal comes back intact. - #432 the sparse joint export.
LaplaceResultgainspd_conditioned, recorded by both joint loops and emitted per cell, so a caller can tell an export taken from a conditioned factorization apart from one that never needed conditioning – the sum-to-zero path’shessian_pd_at_modereads the direct factor of the pinned matrix and cannot answer that.test-nested-laplace-joint-sparse-equivalence.Rnow compares the exported fixed-effect block, the retained per-cell precisions and the standard errors across the dense and sparse paths (icar / bym2 / car_proper, and withTULPA_S2Z_DENSIFY_MAX = 0forcing the rank-1 sum-to-zero storage), and showspd_conditionedis TRUE exactly at a conditioned factorization and that nothing conditioned reaches a coefficient table.
The checkpoint payload carries
pd_conditioned, soCheckpointLog::MAGICis bumped: a file written by the previous layout would be replayed field by field into the new one and mis-parsed. - #430 one floor on
tulpa 0.1.13
A tgmrf block’s symbolic frame is the union over grid points (#472). The
SparseHessianBuilderis initialized once per fit, and the frame was read off grid point 0 alone under an R-side convention that every grid point’s Q shares one pattern. An assembled Q loses entries at a parameter value that zeroes them – an AR1 Q atrho = 0is diagonal, and Matrix drops an assigned zero from the pattern – so grid point 0 can be the sparser one, and on the SPARSE pathSparseHessianBuilder::addthen discarded every off-diagonal the other grid points carried.add_prior_patternnow emits the union, so a grid point sparser than the frame writes into structural zeros and nothing is dropped. The dense path assembles directly into the matrix and was never affected.control$inner_factorizationdrives the dense joint inner Newton’s factorization backend (#471)."auto"(the default, the latent-dimension threshold),"sparse"(CHOLMOD) and"dense"select what factorizes the Hessian the dense joint driver has already assembled – the three states the single-arm loop’ssparse_overridecarries. It is independent ofcontrol$force_sparse, which chooses which driver ASSEMBLES the Hessian. The newtest-joint-inner-factorization.Rruns one joint problem through both backends and holds them to factorization noise. The control reaches the multi-block joint path; a single-block fit that sets it now errors rather than ignoring it.An outer-grid cell that fell back to the PD-enforced log-determinant is counted and reported (#601). On the sum-to-zero path the reported
-0.5 log|B|is read from a direct factor of the pinned matrixB = H + sum_k coef_k 1_k 1_k'; where that factor cannot be formed, both readers keep the PD-enforced value, which is a determinant ofH + lambda Iafter the LM escalation ladder. That value weights the outer hyperparameter grid, so a cell that fell back is reweighted against its neighbours – and nothing said so.LaplaceResultnow carriess2z_log_det_fallback, the grid carries it per cell, anddiagnostic_summary()reports the count once per fit and raises the status to WARN. The checkpoint format carries the flag, so its header magic is bumped toTLPACKP4and an older log is rejected rather than misread.The sampler and tgmrf block builders check the dimensions they index R arrays with (#472).
build_sampler_model_inputsreadnnfrom one spec field and then indexednn_idx/nn_distagainst it with no check that the tables carry that shape, and copiednn_neighbor_dist,nn_order,nn_order_inv,X_svc,X_tvc,time_index,group_indexandsvc_indiceswholesale before indexing them at a length taken from elsewhere; the random-effect branch readncoefs/correlated/idxper term against a count taken fromngroups.make_tgmrf_blockcheckedp_kagainstn_latentbut neveri_k/x_kagainstp_k[n_latent], nor that a row index is in range – and the dense prior scatter writes at that row index. All of them now error naming the field that disagrees, and an empty per-grid list is refused instead of being read at grid point 0.Every CUDA host-to-device copy is checked (#474). One of seven call sites in
gpu_cuda.hreadcopy_to_device’s return. A failed copy leaves the allocation holding whatever the driver last placed there: for the payload buffers that is a plausible wrong result returned as a success, and for the three device pointer arrays it is cuSOLVER and cuBLAS dereferencing that content as device pointers. The read-back path had the same split and is checked too.total_log_lik_specis threaded (#471). The spec path’s data log-likelihood ran serially inside the objective closure – once per line-search trial – while the eta assembly beside it and the family-enumcompute_total_log_likboth tookn_threads. It now reads the samen_threadsthroughtulpa_parallel_sum. At more than one thread the summation order differs from the serial one, which is the tolerance-level invarianttest-nested-laplace-joint-threading.Rstates.Maintenance across the Laplace shard (#470, #471): 78 lines of GP covariance helpers with no caller are deleted; the same-term random-effect cross block drops two branches that cannot be reached (a term reads one group per observation, so
t == tpcarriesg == gp);used_sparse_factoris computed once instead of twice; the tweedie parameter resolve, the Student-t degrees of freedom and the truncated families’(q, p)derivatives are each one helper instead of six, four and three copies; and the probability floor, the tweedie mean floor and series width, the censored-arm curvature floor, the precision denominator guard and the centering fold cutoff are named constants. The backtracking line search’s doc now states the acceptance test it actually applies.
tulpa 0.1.12
LikelihoodSpec::ll_fwdis removed, and the ABI is 42 (#493). The slot was assigned at six sites and read at none:resolve_gradient_fndispatches ongradient_fn, thenll_arena, then the numerical fallback, andAUTODIFF_FWDis an explicit alias for the arena path. Each assignment forced afwd::Dualinstantiation of a likelihood kernel that never ran, and an unexercised copy of a density is where a kernel falls silently out of step with its siblings. A model package assigning it now gets a compile error at the assignment site, which is the outcome the removal is for.The PC prior’s anchors are checked where they are set, and the density can no longer emit a NaN (#499).
lambda = -log(alpha) / Uexists forU > 0andalphain(0, 1)only: atalpha = 1the rate is 0 andlog(rate)is-Inf, so the prior is-Infat every value of the scale and takes the whole log-posterior with it; above 1 the rate is negative and the density is improper and increasing. One predicate,pc_anchors_valid, now answers it, and each door reports it in its own terms – the R front doors name the argument, the sampler entry names the spec, and the templated density, which runs inside gradient loops and OpenMP regions where a throw isstd::terminate, falls back to a flat prior on sigma the way the SPDE hyperprior already did.The HSGP, HSGP-ST and TVC scale priors take settable anchors (#506). All three hardcoded
P(sigma > 1) = 0.01inline.spatial_gp(approx = "hsgp")andtemporal_tvc()takesigma_prior_U/sigma_prior_alpha, defaulting to exactly those values, and the densities readModelDatafields rather than literals.A scale or precision reaching a logarithm is validated at the entry point (#522).
nl_check_positive/nl_grid_axes_positivereject a zero or negativesigma_re,tau_grid,sigma2_gridor lengthscale axis with a message naming it, instead of letting-Infor NaN reach the inner Newton solve as a cell whose marginal is simply not finite. The NNGP marginal fallback in the gradient path carrieskGpVarFloor, the floor the main path already passes intovecchia_cond_grad– the unfloored convention was the FAILURE path, wheresigma2is most likely to be extreme. The stored non-centered draw and the log-posterior’s own transform now take the same boundedsafe_exp, so the stored field is the same function ofqas the field the likelihood saw.The integrator selection can be scoped, and its substep count is bounded (#483).
with_tulpa_integrator(name, expr)restores the previous selection on error as well as on success; the selection is process-global, so a baretulpa_integrator()call leaves every later fit in the session on the new scheme.set_integrator_schemevalidates the name and the RESPA substep count before any of its five globals moves, so a rejected call leaves the process on the integrator it was already using rather than on a half-reset one.Both scatter index caches key on the builder’s pattern generation (#483). They hold flat offsets into
valuesresolved bylookup(row, col)and were keyed on(builder pointer, nnz), so two different patterns installed into one builder with equalnnzwould satisfy the key and hand back offsets pointing at other entries – a silently wrong Hessian the pattern guard’s drop counter cannot see, because the offset is valid, just not the one meant.TULPA_SCATTER_FORCE_PARALLELis read per call rather than once per process, soSys.setenv()between two fits takes effect.The AD likelihood ladder dispatches on an enum (#512). It compared the family code against up to twelve
std::strings per observation per reverse-mode sweep, on a quantity fixed for the whole fit;prepare()now resolves it once and the ladder is a switch. That classification IS the coverage listbuiltin_family_has_ad()reports, so the gate and the branches cannot fall out of step. The negative-binomial ICAR Gibbs kernel builds its adjacency into flat CSR once at entry, the form the binomial kernels already take, instead of constructing an Rcpp proxy per unit per sweep, and itsn_threadsargument now drives the per-row work the non-spatial kernel already parallelizes.One beta density, one ICAR centring signature, one RE dimension pass (#506, #507). The beta log-density was written three times (the generic family kernel, the beta sampler, the SPDE sampler) and is now
tulpa::math::log_lik_beta_logit; the multiscale-GP PC prior reimplementedlog_prior_log_sigma2_pcinline, which is also how it bypassed the anchor guard;icar_center_fieldno longer takes the component count it ignores, so the call site answers “one direction, by design” without opening the callee; andcompute_re_priorstores each term’s group and coefficient counts instead of recomputing the same two ternaries in the next loop.The centered and non-centered temporal GP are pinned to each other (#499).
cpp_test_temporal_gp_densitydrives the shipped density at both parameterizations, andtest-temporal-gp-parameterization.Rasserts they differ by exactly the forward transform’s log-determinant – including at a(sigma, phi, dt)where the1 - rho^2floor binds at every step, which is the configuration no single determinant reconciles if the two branches floor different quantities.
tulpa 0.1.11
-
The outer-grid measurement files state the within-cell read instead of inheriting it (#599).
test-nested-laplace-joint-box-mass.R,-barycentre.Rand-descriptor-plane.Reach carried a byte-identical copy of the same three-block gaussian fixture and letcontrol$within_cellfall through to the engine default, so flipping that default tobox_uniformre-targeted what their recorded numbers measure. All three were red underNOT_CRAN=true; the issue saw only one because the other two areskip_on_cran()and the run that found it wasTULPA_FAST=1.The fixture is now
ogd_fixture_sim()/ogd_fixture_fit()inhelper-outer-grid-dump.R, withwithin_cellan argument defaulting to the shipped read, andtest-outer-grid-dump.Rasserts that default still equals.NL_DIAG$within_cell– so the next flip fails there, naming the files to re-measure, rather than silently changing what they score.Re-measured under the shipped read, the finding that moves is which PART of the read a rule is shown to reach. The two constructions place the same mass in the same cells and differ by half a cell in where inside one they place it, which on these coarse grids is the scale the location is resolved at, so the floor narrows on the widths (0.2158 -> 0.0882 at five levels) and widens on the median (0.0024 -> 0.0184). The box-mass rule’s reach moves from the location to the spread; the barycentre placement’s five-level median margin falls from 22x the floor to 2.0x while its widths margin rises to 3.1x. The rules are unchanged – the box multiplier’s per-cell values are identical under both reads. Write-up:
dev_notes/issue599/RESULTS599.md.
tulpa 0.1.10
-
The Type-IV RW2 kernel’s site-specific linear trends are pinned (#600).
st_sum_to_zero_penalty()pinned the interaction along its row sums and its column sums,S + T - 1directions, which is exactlynull(Q_s (x) Q_t)under an RW1 temporal marginal. Under a non-cyclic RW2 one the kernel gains the linear ramp and isT + 2S - 2, so theS - 1site-specific time trends summing to zero across sites carried no prior curvature at all. The engine already held both numbers and they disagreed: the normalizer beside the penalty readsrank_space * rank_time, which is8 * 2 = 16againstST = 36on the 3x3 / T = 4 fixture, so it was written for a 20-dimensional kernel while the penalty pinned 12.The penalty now carries a third family, each site’s squared linear trend, at the precision the s2z contract implies:
s2z_precision(n)holds the field MEAN – the coefficient of the constant direction – atsd = kappa, ands2z_precision_weighted(u'u)holds the coefficient of an arbitrary directionu' phi / u' uat the same sd, reducing tos2z_precision(n)exactly atu = 1. One predicate,st_needs_trend_pin()(src/st_null_space.h), answers where it applies, and the density, the sparse matrix form and the precision-informed mass override all read it. TYPE_II takes the same term by the same derivation; TYPE_III’s kernel is already spanned by the column sums and TYPE_I is proper. A CYCLIC RW2 gets nothing – a ramp is not periodic, sorw2_rankreportsT - 1there – and an RW1 fit is unchanged bit for bit.Measured against the numerical Hessian of the engine’s own log posterior,
cond(M^-1 Q)undermass_matrix = "gmrf_margin"grew LINEARLY intauwithout bound (13.6 / 18.6 / 75.2 / 529 / 3900 overlog_tau0 to 6) where RW1 saturated at 17.4; it now saturates at 25.4. On the #598 harness the RW2 fixture’s divergences fall from 43.5 to 8.4 per fit and, per EFFECTIVE sample, from 5.48 to 0.627 against the adapted dense metric’s 0.691 – closing the one finding gcol33/tulpa#598 could not account for. What remains is thetaufunnel every arm shares.AUTOstill does not selectgmrf_margin.The low-rank mass storage (
hmc_mass_lowrank.h) is now generic over WEIGHTED group sums rather than indicator groups, since a trend group covers the same coordinates a row group does and differs only in its weights. Consumer-package paths only: nothing in tulpa setsModelData::has_spatiotemporal.
tulpa 0.1.9
A cyclic Type-IV interaction is fitted under the cyclic Q_t (#596).
st_kronecker_temporal_quad()passedcyclic = falseto the RW1 / RW2 kernels whateverSpatiotemporalData::temporal_cyclicsaid, while the GMRF normalizer beside it read the flag throughrw2_rank. A cyclic RW2 interaction therefore putrank_space * (T - 1)powers oftau_stin the target against a quadratic form of rankrank_space * (T - 2). The flag now reaches the diagonal and the cross term alike:rw1_cross_form/rw2_cross_formtake it and carry the wrap-around differences, and the quadratic forms are those cross forms ata == b, so the wrap edge has one definition instead of two.st_type_iv_precision()emits the matching wrap rows, each difference row being one outer product, so the mass override assembles the operator the density evaluates. Consumer-package paths only: nothing in tulpa setshas_spatiotemporal, so the Type-IV fixture is the entry point, and it now takestemporal_cyclic.gp()no longer offers a linear solver (#542).solver,cg_tolandcg_maxiterwere accepted, validated, stored on the spec and printed, and never reachedGPData::solver_config, so every fit ran the Cholesky path whatever was asked for. The choice is not worth wiring: it governs only thek x kneighbour system withk = nn(15 by default), where a Cholesky is both exact and faster than CG. The kernels and their equivalence test stay, reached throughGPSolverConfig.An MCAR cell whose Sigma cannot be formed is infeasible, not silent (#569). An extreme log-Cholesky diagonal made
exp(ld)infinite, the forward substitution returned1/Inf = 0, andSigma^-1came back a zero matrix: the MCAR prior contributed nothing to the gradient or the Hessian whilelog|Sigma|stayed finite.mcar_sigma_inv_from_logchol()now reports feasibility, onemcar_cell_sigma_inv()reader serves the four closures and a newblock.prep, and the driver skips the cell as it does for every other block factory.NlCellCacherefuses a thread it has no slot for (#566). An out-of-range thread number was clamped to slot 0, so two threads would claim and publish the same slot silently – the race the per-thread slots exist to prevent. It throws, matchingfind().One writing of nine duplicated computations (#523, #524, #536, #572). The Cholesky elimination and its forward/back substitution, the CG and PCG bodies and their copy-in branch, the adapted-diagonal clamp, the fixed-effect prior scatter, the non-centered transform dispatch, the options-request temporaries, the logistic derivative ladder and the Bell coefficients of
P0each had one definition again.compute_log_lik_only()takesskip_priorinstead of evaluating the log-posterior twice and subtracting, which also stops the prior cancelling in floating point.matvec_add,compute_linear_predictors,sparse_matvec_csrandmake_se_kernel_matvecroute throughtulpa_parallel_for, so a team of one skips libgomp asmatvecalready did.
tulpa 0.1.8
-
The non-centered SPDE transform assembles the same precision as the Laplace path (#590).
SpdeNcTransformbuiltQ = tau^2 K diag(1/C0) KwhileSpdeQBuilderbuilds the operator-chain expansiontau^2 (kappa^4 C0 + 2 kappa^2 G1 + G1 diag(1/C0) G1). Those are the same matrix only whereC0 diag(1/C0) = I, and the inverse mass is FLOORED to zero at a zero-mass (orphan) mesh vertex, where the product drops the cross termskappa^2 (C0 D G1 + G1 D C0). On such a mesh the non-centered NUTS path and the Laplace path described different fields, by exactlytau^2 * 2 kappa^2 G1[j, ]on the orphan’s row and column – 6.2% of the largest entry of Q, on 5 of 79 rows of the fixture – which is the disagreement the orphan ridge exists to prevent. The transform now assembles the expansion, and the two agree to 5.8e-16 relative on the orphan mesh and the healthy one alike.G1 diag(1/C0) G1carries neither kappa nor tau, soinit()builds it once and each proposal costs a scaled add in place of two sparse products. The rational path collapses the same way: its per-pole terms differ in two scalars only,sum_k w_k (kappa^2 + r_k)^2andsum_k w_k (kappa^2 + r_k). Both hyper derivatives become exact rather than resting onC0 D = I:d(kappa^4)/dlog_kappa = 4 kappa^4andd(2 kappa^2)/dlog_kappa = 4 kappa^2givedQ/dlog_kappa = 4 kappa^2 tau^2 Ktermwise, which is the closed formbackward()andforward_with_tangent()already applied. The SPDE boundary guards have a fixture (#590, splitting #528 / #530 / #534).
tests/testthat/test-spde-guards.Rreaches the orphan ridge on both assemblies and on a Laplace fit and a non-centered adjoint over the same mesh, and drives the four structural validators (spde_validate_csc/_fem/_projector/_operators) through both R-callable SPDE entries with a malformed length, column pointer and row index. Q is scored against an independent R re-derivation and againstSpdeQBuilderitself, with the product form carried as a negative control: a regression back to it fails on the orphan mesh and is silent on a healthy one. The indefinite-H guard in the implicit-diff gradient is recorded there as deliberately untested, since no valid input reaches it.
tulpa 0.1.7
-
A diagonal-plus-low-rank mass matrix, and the Type-IV interaction’s sum-to-zero margins carried in it (#597).
mass_matrix = "gmrf_margin"is the #585 precision-informed diagonal plus the block’s two soft sum-to-zero margins as an explicit rank-(S+T) term,M = D + lambda_row R'R + lambda_col C'C, with both precisions fixed by S and T alone. Those directions are1_S (x) aandb (x) 1_T– linear combinations, not coordinates – which is what #585 measured as the whole of the block’s stiffness and as the part no diagonal metric of any kind can rescale. Like"gmrf"it resolves to a diagonal metric plus the term before any leapfrog path reads it, andAUTOselects neither.The storage (
src/hmc_mass_lowrank.h) is generic over GROUP-SUM directions rather than over the Type-IV margins, so a single sum-to-zero on an ICAR / RW1 / RW2 block is the one-group case of the same term. The inverse is Woodbury on a k x k inner matrix, keeping the per-step cost O(n + nnz(U) + k^2) against a dense metric’s O(n^2), and the momentum draw is a sum of two independent Gaussians rather than a factorization ofM.Scored on the #585 paired design – 6 configurations x 8 seeds, arms sharing the data and the chain seed – at
adapt_delta = 0.95: leapfrog steps per effective sample come out at a pooled geometric-mean ratio of 0.040 against the adapted diagonal (46 of 48 pairs, sign test p = 8.4e-12), raw sampling leapfrog steps at 0.053 on 48 of 48 (p = 7.1e-15), withess_minrising at the same time and max-treedepth saturation – 26% to 98% of iterations on the diagonal arms – disappearing entirely. The"gmrf"arm of the same run reproduces #585’s null (pooled 1.08, p = 0.67).Two limits are on the record rather than smoothed over. At
adapt_delta = 0.8the non-centered configuration is unusable (250 divergences per 1000 iterations at an adapted step size of 2.06), which the 0.95 target repairs; and at 0.95 the three small 3x3 fixtures still show divergences the diagonal does not,pois_3x3_T4_rw2worst at 43.5 per fit, while the two largest configurations are clean in every arm. Whether that is new pathology or newly visible pathology in chains the diagonal metric was keeping stuck is not settled here and is carried as #598. Write-up:dev_notes/issue597/RESULTS597.md. apply_driftis testable (#597). The fused driftq += c M^-1 pthe zero-allocation NUTS loop takes moved fromhmc_nuts_optimized.cppintosrc/hmc_mass_drift.h. It andDenseMassMatrix::inv_mass_times_pare the two places a metric meets a momentum, and they are now pinned to each other by test on every metric the engine can build.The Knorr-Held Type-IV fixture’s scaffolding moved from the top of
test-st-iv-gmrf-mass.Rintotests/testthat/helper-st-iv.R, so the precision-informed diagonal and the margin metric are scored against one copy of the numerical-Hessian arbiter rather than two.
tulpa 0.1.6
-
The Type-IV spatiotemporal interaction has a precision-informed mass metric, and a measurement saying it does not beat the adapted diagonal (#585).
mass_matrix = "gmrf"replaces the Welford-adapted variances over anst_deltablock withdiag(Q^-1)of that block’s own posterior precisiontau (Q_s (x) Q_t) + diag(h_lik)plus the two sum-to-zero margins, at each warmup mass window. The metric stays diagonal – the request resolves toDIAGplus a flag before any leapfrog path reads it – andAUTOdoes not select it. The likelihood curvature comes throughLikelihoodSpec::eta_weights_fn, which is what #547 removed the half-wired version for lacking; a spec shipping none declines with a reason on the fit rather than silently.Scored on 96 paired NUTS fits (6 configurations x 8 seeds, the two arms sharing the simulated data and the chain seed), leapfrog steps per effective sample come out at a pooled geometric-mean ratio of 1.03 on the worst parameter, 0.91 on the interaction block and 1.09 on the hyperparameters, with every per-configuration sign test above p = 0.47 and per-pair ratios spanning 0.18 to 9.2. The conditioning says why, without a sampler:
cond(Q)is 1.3e5 to 2.1e5 and neither diagonal read moves it (diag(Q^-1)reaches 1.1e5,1/diag(Q)changes nothing), while deleting the S + T soft sum-to-zero margin directions takes the same matrices to 11.5 to 51. Those directions are1_S (x) aandb (x) 1_T, so no diagonal metric reaches them. A mass carrying them as a rank-(S+T) term reaches 6.8 to 18.6, which is #597. The Type-IV interaction path is testable from this package for the first time (#585). Nothing in tulpa sets
ModelData::has_spatiotemporalandspatiotemporal()errors at the R door, so no test could reach the Knorr-Held sampler path at all.src/test_st_iv_fixture.cppfills a Type-IVModelDatadirectly, the way a consumer package does, and exposes its layout, log-posterior, mass override and a NUTS fit. Every claim about the new metric is scored against the numerical Hessian of the engine’s own log-posterior over the block.
tulpa 0.1.5
Behaviour changes, in the order they are most likely to affect a fit.
A failed sparse solve is no longer reported as a converged fit (#510).
SparseCholeskySolver::solve()wasvoidand zero-filled on failure, and every Newton caller tested only that the step was finite – a zero step is finite and satisfiesmax|delta| < tol, so a null CHOLMOD solve came back as a converged fit with the mode left wherever the iteration happened to be.solve()returns a status and fillsNaN, and the four Newton call sites consume it.refill_from_dense()no longer discovers its sparsity pattern from the first Hessian’s VALUES, which let a coincidental near-zero in the first cell fix the pattern for every later one; an off-pattern entry now goes to the pattern-drop counter. The two Takahashi entry points validate their documented preconditions and refuse an unusable pivot rather than continuing past it. Consumers linking against the C API:tulpa_sparse_chol_solvefillsNaN, not zero, on a bad handle or a failed solve; no signature or struct layout changed.An exact-NUTS GP, SVC or multiscale-GP fit is now reproducible at a fixed seed (#587).
nngp_nc_backward’s range-gradient loop ran a dynamic OpenMP schedule while writing into the slot the thread number names, so the summation order – and with it the last bits of every draw – was a property of the run rather than of the data. Two runs of one call at the same seed differed by 5.2e-04. The rows are now cut into contiguous per-thread chunks summed in chunk order. Draws from a multi-threaded fit move once, to a value that no longer depends on the thread schedule.control$epsilonis honoured on the SGHMC and SGLD backends (#576, #577). SGHMC enabled its warmup step-size adapter unconditionally and SGLD never disabled its polynomial decay, so a supplied step size was accepted and then overwritten – every value produced the same chain. Both adapters now run only when the caller names no step size. A call supplying none is unchanged.control$n_threadsis refused on the sampler route rather than accepted and dropped (#587).OMP_NUM_THREADSremains the way to cap the team.The negative-binomial Gibbs kernels evaluate an exact density (#495). Three different
etaclamps and two probability floors are gone, replaced by the closed formy*eta - (y + r)*log(1 + exp(eta)), branch-stable on the sign ofeta; the clamps could not be made consistent, because the augmentation was drawn at the clamped predictor while the coefficient update solved the conditional the unclamped one defines. Three undocumented magnitude boxes on the coefficients, random effects and their scale are removed, so the chain targets the posterior rather than a truncation of it; the documented dispersion bound stays. Withstore_eta = TRUEthe saved linear predictor is the end-of-sweep state, where it was previously one sweep behind the coefficients beside it.Both negative-binomial kernels require an intercept (#497), where they previously shifted
etaby a per-sweep constant on a design whose first column is not all-ones, and wrote out of bounds on a zero-column design.The SPDE implicit-difference gradient carries its total-derivative term (#583).
Hreads the mode through the likelihood curvature, solog|H|depends on the hyperparameters twice and the envelope theorem does not cancel the mode’s own derivative out of the determinant. It now agrees with its finite difference; it was 5-10% away.Fixed-hyperparameter and sampler-side entry points validate the indices and lengths R hands them (#500, #503, #504, #516, #521, #522, #529, #532, #556, #559, #562, #564, #566, #569, #570). An out-of-bounds read on the R heap does not crash – it returns a finite double and the fit stops being a function of its data – so these are silent-wrong-answer paths, not crashes. Among them: a gradient buffer sized from the wrong argument (an out-of-bounds write), a random-effect group index with no upper bound anywhere, a covariance packer reaching
atanhon aNaNthat passed both of its one-sided clamps, and a GLMM oracle that dropped an out-of-range group instead of rejecting it and fitted a subset.A malformed
ModelDataraises instead of returning a log posterior (#563). One entry point returned0and another-INFINITY; a zero log posterior is indistinguishable from a valid flat one.Areal priors: the ICAR-BYM2 log and square-root sites are guarded, so the double path and its autodiff twin no longer describe different models at the mixing boundary (#485); the areal input validator is wired into the sampler-side ingestion, not only the Laplace entries (#501); and the multi-block BYM2 entry rejects a mixing weight outside the unit interval, as its two sibling entries already did (#532).
Temporal priors: one stationary floor behind every AR1 arm, including three gradient sites that used a raw factor and so scaled by a negative number past the boundary (#514); the temporal-GP centered and non-centered branches read one conditional variance (#499).
GPU: the batched triangular solves read the wrong triangle and computed only the diagonal inverse (#478); device information is enumerated rather than reported from placeholders (#480). Unverified at runtime – no CUDA device was available – and the affected entry points have no production caller.
SVC: one prior on the marginal standard deviation across both bases, where the Hilbert-space branch previously selected a different prior from the basis flag (#487).
Documentation:
gcol33/tulpa#NNNreferences are removed from code comments and roxygen (#575), and eight comments describing code that is no longer there are corrected (#568, #574, #525, #537).
tulpa 0.1.4
The NUTS metric is reachable from the sampler front door (#545).
select_and_init_mass_matrixresolved AUTO, detected the mass blocks and built the block-diagonalMassBlockmachinery on a path no caller could reach: every entry point but the SPDE one pinnedMassMatrixType::DIAG, and the SPDE layout carries none of the structures the detector looks for.tulpa_sample_glmm(control = list(mass_matrix = ))now selects it –"diag"(the default, so an existing fit is unchanged to the bit),"dense","block_diag"or"auto". Under"auto"the correlated hyperparameter groups get their own small dense blocks (the BYM2 and GP(log sigma, phi)pairs, the multiscale-temporal variances, a correlated random-slope term’s Cholesky coordinates) and an ICAR or latent-factor model inside theDENSE_MAX_PARAMSceiling takes a full dense metric.tulpa_hmc::parse_metric_typeis the one string-to-metric map and rejects an unrecognised name rather than defaulting to DIAG; the SPDE entry point took its own integer code and now comes through the same map. A backend with no mass matrix refuses a non-default value instead of ignoring it.ModelData vectors are checked before the RE layout indexes them (#546).
compute_param_layoutdrove the RE-slopes loop offn_re_termsand indexedre_n_coefs,re_correlated,re_n_cholandre_n_groups_multiby the term counter with no length test, and readlog_sigma_re_slopes[t][0]on a term with no coefficient.ModelDataships underinst/include, so aLinkingTopackage reaches all five without passing through the R-side validation. Each is now checked once, by name. The ICAR mass warm start guarded its CSR row pointer on!empty()while readingadj_row_ptr[s + 1]across the whole spatial block, and now requires the length that read needs.build_sampler_model_inputsvalidates the multiscale temporaltime_index/group_indexagainstn_times/n_groups: an out-of-range entry used to contribute zero throughcompute_temporal_eta’s per-read guards, so a mismatched index vector showed up as a quietly weaker temporal effect rather than an error.compute_temporal_etaalso checkstime_indexagainst then_obsits caller declares, andcpp_test_multiscale_temporal_log_likrejects a non-stationaryrho_shortthe stationary AR1 factor would otherwise absorb into its floor.The unreachable mass-block machinery is gone (#547).
PrecisionBlockandKroneckerBlockwere never initialised, andSparseGMRFBlockwas initialised, never factorised, then deactivated at warmup end – sohas_structured_blocks()was false on every path and the branches it guarded in the kinetic energy, the momentum draw,inv_mass_times_pand the leapfrog drift were dead, including three per-leapfrog-stepstd::vectorallocations inside the file that describes itself as the zero-allocation NUTS core (#548).hmc_sampler_mass_blocks.hdrops from 865 lines to 395. The ST_IV precision-informed mass override the deactivation was waiting on is tracked in #585 rather than left as a half-wired struct a reader cannot tell from a live one. The 13#if GP_DEBUG_BOUNDSblocks inhmc_gp_log_lik.hare gone with them:hmc_gp.hdefined the flag asfalseunconditionally, so no build flag could reach them, and each block that checked anything sat behind an always-on check that returned first. TheGP_AUTODIFF_DEBUGscaffold read acall_countdeclared under a different flag, so turning it on did not compile; it is gone too.The multiscale GP density stopped copying its inputs per gradient (#548).
multiscale_gp_log_lik_ttranscribedMultiscaleGPDatainto two temporaryGPDataobjects on every call – every neighbour array copied,nn_neighbor_distincluded atn_obs * nn * nndoubles – because the density took aGPData. It is now written against the six arrays it reads, and both the single-field and the two-scale entry points pass their own members straight in. The dense covariance accumulator (WelfordCovStats,n_params^2doubles per chain) is allocated only for a metric that starts DENSE, instead of on every chain of every fit.Every OpenMP team goes through
tulpa_omp_team_size(#550). The indexed joint scatter took its thread count from R (.tulpa_inner_threads, keyed on the core count), and the three exported OpenMP probes took theirs raw, so none of them sawOMP_NUM_THREADS,OMP_THREAD_LIMITor the two-core capR CMD checksets through_R_CHECK_LIMIT_CORES_. All four now request their team through the shared clamp, as doesparallel_block_reduce.Header hygiene in the HMC layer (#549). Ten UTF-8 BOMs stripped, and the comments a non-UTF-8 round trip had turned into bare
?restored to what they said.available_ram_bytes()deallocates the send rightmach_host_self()hands it on every macOS exit path, instead of leaking one per call.hmc_gp_nc.hsays at the top that it is a namespace-body fragment included insidenamespace tulpa_gpand must not be included directly. Refactor history, dated benchmark tables and a stale “known heisenbug” note that told the reader to avoid the only GP density there is are out of the code.
tulpa 0.1.3
The correlation Cholesky covers the whole cone (#431, #443). The raw parameters of a correlated random-effect term were mapped by
L[i, j] = tanh(raw)directly, which bounds each entry but not the row: atn_coefs >= 3a row’s squared sum can exceed 1 and the map covers a strict subset of the correlation-Cholesky cone. The three copies of that build then disagreed about what happens outside it – the HMC prior returned-Inf, the LKJ helper returnedfalse, and the Laplace spec solver clamped the squared diagonal at1e-12and carried on, producing rows of norm above 1 and aSigmawhose diagonal no longer matched the marginal SDs it was handed. Which of the two a user got depended on the backend rather than on the model.tulpa::build_L_from_rawis now the one build, templated over the scalar so the AD paths differentiate what the double paths evaluate, and it takestanh(raw)as a canonical partial correlation scaled by the room the row has left: every raw vector lands in the cone, so there is no support boundary left to disagree about.tulpa::raw_from_Lis its exact inverse and is whatpack_to_spec_re_paramsuses. The log-Jacobian gains the scaling term0.5 log(s)alongside the tanh term; the LKJ exponent onlog L[k,k]is unchanged. Atn_coefs == 2the scaling factor is 1 and its Jacobian term is0.5 log(1), so the two maps coincide term for term and a random intercept-and-slope fit is unchanged.Type-IV spatiotemporal rank is derived once (#438). The centered branch counted the adjacency’s connected components; the non-centered branch hardcoded the spatial rank as
S - 1.rank_spacemultiplieslog(tau_st)in the GMRF normalizer, so on a graph with more than one component – islands, or any unit its adjacency file leaves with no neighbours – the two parameterizations of the same model targeted different densities and reported differenttau_stposteriors, with nothing flagged.st_spatial_rank()is now the one derivation, called from the non-centered Type IV, the centered Type IV and Type III. The HSGP-ST branch also checksM * T_stagainst the latent span it was sized from before indexing.Areal entry points validate their adjacency (#441).
adj_col_idxindexes a WRITE into the dense Hessian (H[sp_idx][spatial_start + neighbor]) andspatial_idxindexes the latent field, both through raw pointer arithmetic with no bound, and neither was checked.tulpa::check_areal_inputs()(src/areal_input_check.h) checks the CSR shape, monotonicity, tail and column range plus the site index’s length and range, and is called bycpp_laplace_fit_spatial,cpp_laplace_fit_bym2,cpp_laplace_fit_car_properand the five nested areal / ST entries. An out-of-range value is now an R error naming the entry point and the offending index.Grid checkpoints fingerprint and serialize what they claim to (#442).
re_idxis anRcpp::NumericVectorand was folded atsizeof(int), so only its leading half reached the hash: two RE assignments agreeing there shared a fingerprint and a resume replayed one run’s cells under the other’s data.Fingerprint::fold_rvec()derives the element size from the R type, so the mismatch is not writable at a call site. The SPDE nested entry’soffsetis now folded as well (make_nl_grid_checkpointtakes optionaloffset/weights, and an absent one folds distinctly from a present zero). The per-cell record now carries everyLaplaceResultfield rather than eleven of them, so a resumed cell no longer comes back with an empty skew / importance / debias / CILA payload that the grid reads as “did not compute it”;CheckpointLog::MAGICis bumped accordingly.start_infeasibleis carried per cell through the grid merge and read bynl_grid_cell_to_result_list(), which hardcodedfalsefor every one-cell export.Stochastic Lanczos quadrature: breakdown, orthogonality, bounds (#444). A breakdown (
beta_jbelow tolerance) is the case where the Krylov space is exhausted and the quadrature is exact, and it was reported as “matrix is not positive definite”:alpha/betastayed padded with zeros out ton_lanczos, so the eigensolve returned Ritz values at 0 and the definiteness guard fired on them.lanczos()now returns the steps actually taken and resizes to them, and the guard skips a Ritz value carrying no quadrature weight. The recurrence gains the full reorthogonalisation the cited reference (Ubaru, Chen & Saad 2017) assumes, without which the basis loses orthogonality within a few steps andlog|H|picks up a bias on theN > 100Kpath that has no Cholesky to cross-check against. The CSC triple from R is validated before anything indexes off it.Three AD primitives agree with their double counterparts (#446).
fwd::expm1computedexp(x) - 1, the cancelling form, which is exactly whatlog1m_exp_fn’s small-argument branch routes throughexpm1to avoid – a zero-truncated Poisson at smallmuis both the regime where the truncation correction matters and the branch that was taken.safe_logreturned partial1/1e-15on the two reverse paths and0on the forward one in the clamped region, where the value is a constant and the derivative is therefore 0; all four now report 0 there.log_sum_expgained the double core’s non-finite guard on all three AD types, so two mixture components that both underflow give-Infrather than aNaNin the adjoint buffer, and the six hand-written copies of that reduction inautodiff_utils.hare onelog_sum_exp_fn<T>().fwd::pow(Dual, Dual)no longer forms0 * log(0)for a constant exponent.ad::Vargains the comparison operators its two sibling AD types already carry, without which a templated helper that branches on its argument compiles for three scalar types and not the fourth.A failed checkpoint truncation is an error (#449).
resize_file’sstd::error_codewas never read, and the append stream opens either way, so a failed truncation left every record the run appended sitting past an orphaned torn tail – written, invisible to the next resume, and recomputed with no message.cpp_laplace_sampleno longer ridges the caller’s matrix (#451). Rcpp binds a REALSXP argument without duplicating it, so the uniform ridge was written into the R matrix the caller still held, and a second call on the same matrix sampled from a precision carrying it twice.Multi-chain stochastic-sampler results guard the
log_likcopy against a short vector the way the VI/ESS shim already did (#439).
tulpa 0.1.2
The BYM2 outer grid checks its mixing-weight axis (#421).
cpp_nested_laplace_bym2fixed the cell count fromsigma_spatial_gridalone and never looked atrho_gridagain, where every sibling paired-grid entry in the same file checks the pairing. A shorterrho_gridwas read past the end of the R vector for every cell beyond its length, and a rho outside[0, 1]tooksqrt(1 - rho)toNaN, which reached the inner Newton through eta and returned aNaNcell instead of an error. All eight entry points now go through onenl_grid_axes_length(), which names the axis that does not pair and the length it has, and a mixing-weight axis is checked against its own support. The1e-10inside the two square roots istulpa::BYM2_RHO_EPSinsrc/bym2_mixing.h, one value for the Laplace kernels and the Polya-Gamma samplers, with what it costs at the endpoints written down.The Polya-Gamma kernels size their draw matrices for the number of saves the loop makes (#426).
n_save = (n_iter - n_warmup) / thintruncates, while the save condition(iter - n_warmup) %% thin == 0firesceiling((n_iter - n_warmup) / thin)times, so athinthat does not divide the post-warmup run wrote one row past the matrices.NumericMatrix’soperator()is unchecked and its offset isj * nrow + i, so the extra write landed on the first saved draw of the next column for every column but the last and one element past the allocation for the last – a plausible finite number either way. The two negative-binomial kernels now use the sharedpg_n_save(), which also refusesthin < 1andn_iter < n_warmup, andtulpa_gibbs()forwardsthinto the spatial and temporal routes, which used to drop it silently.Tests for the seven audit items the accompanying
src/work had already landed: the ICAR full conditional against a replica drawing from the same R stream, with the zeroing sweep as a negative control (#423);batch_nngp_scatter’s entry contract and its left-packed neighbour scan (#425); the corrected integrated Laplace on a parallel outer grid, pinned to the serial fit (#424); the joint checkpoint fingerprint against a changed adjacency and a promoted copy block (#419); the joint finite-difference gradient gate on one block per contribution kind through the sparse scatter fits actually run, plus dense-sparse equivalence on a pureINDEXED_SINGLEspec (#420); and the posterior SCALE that SGHMC and SGLD sample, which is what the noise-scale defect left wrong while every existing backend test read the mean (#422).test-nngp-prior-scatter.R’s conditional-variance reference conditions onC + kNngpNugget * I, the matrix the kernel factorizes, read off the scatter’s own return rather than written into the test (#578).
tulpa 0.1.1
One default fixed-effect prior, resolved at the front door (#408).
tulpa()substitutedsd = 2.5,10or100on the fixed effects depending on which backendmode = "auto"selected, soy ~ xandy ~ x + (1 | g)on the same data were fitted under priors a factor of 40 apart, and nothing on the fit recorded which one ran. The default is now the documentedprior_normal(0, 2.5)everywhere, defined once in.TULPA_PRIORwith a.PRIOR_CONSUMERStable naming each fitter that reads it, resolved before backend dispatch and passed down.summary()and the fit itself report the prior that was applied as$beta_prior. The nested-Laplace and SPDE paths are unchanged: they hold their own field-conditional prior and reject a suppliedbeta_prior.The AD scalar primitives are guarded at the same boundaries as the value path (#409). The templated log posterior is instantiated for
doubleand for two reverse-modeVartypes, and the runtime gradient check compares the double instantiation’s central differences against the arena gradient as though the two were one function. They were not:expclamped its argument on the value path and not on either AD path (so a log-scale hyperparameter past 709 returned+Infand took the backward pass with it), andsqrtgave a+Infpartial at exactly zero where the value path gives a finite0– an HSGP spectral density underflows to zero at a long lengthscale and a high basis index.arena::powandarena::logit(and their tape twins) carry the same treatment at a zero base and at the endpoints of the unit interval. The clamp bounds are one named constant intulpa::math.verify_gradient_runtime()reports a non-finite gradient instead of passing it (#410). Every comparison against aNaNis false, so the worst-relative-difference scan stepped over exactly the failure the check exists to catch and returnedtrue; NUTS then ran onNaNmomenta for the whole fit. The finite-difference step is now scaled by each coordinate’s own magnitude, and the two duplicated difference loops are one helper.A
beta_priora fitter cannot express is an error, not a silent swap (#411)..beta_prior_ridge_sd()replaced a prior with nosdfield – aprior_half_cauchy(), aprior_pc(), a misnamed list – by its default, sotulpa(..., mode = "ep", beta_prior = prior_half_cauchy(2.5))fitted under a different prior and reported nothing. Both resolvers now validate through one.beta_prior_fields(), so a prior one accepts is a prior the other accepts.tulpa_variogram()checkscoordsagainst the residual length (#412). A mismatch recycled a logical index and returned a well-formed semivariogram built from squared differences of unrelated pairs.moran_i()andtulpa_variogram()share one check, and coincident coordinates report the empty distance axis rather than failing insideseq().The generics tulpa borrows from bayesplot and rstantools dispatch under either attach order (#413).
pp_check,bayes_R2andposterior_predictare owned by those packages; tulpa’s copies masked them, sopp_check(y, yrep, fun)failed for the rest of the session once tulpa was attached..onLoad()now registers thetulpa_fitmethods on the owners’ generics and borrows the owners’ own.defaultmethods onto tulpa’s, so neither direction depends on attach order.The Polya-Gamma kernels validate every R-supplied index at entry (#415). Group, spatial, time and neighbour indices were converted to 0-based and used as raw subscripts, which Rcpp does not bounds-check, so an out-of-range
spatial_idxwas a read or write past the end of an R allocation. All eight entry points check length,NAand range through one helper, and the neighbour list is checked againstn_neighborsbefore either is walked.The compiled test harness is reached (#416). 55 of the 238 generated entry points had no caller, 48 of them in
src/test_helpers.cpp– including every autodiff-primitive probe and all three serial-versus-parallel equivalence helpers, so from the outside the package read as though that coverage existed. They are now wired intotest-autodiff-primitives.R,test-cpp-kernels.Randtest-parallel-equivalence.R, each against an independent R computation.cpp_pg_negbin_negbin_gibbsand its driver are removed: a numerator/denominator ratio sampler belongs to tulpaRatio, which carries its own copy.temporal_rtr()andspatiotemporal()/spatiotemporal_gp()refuse to build a specification no fitter reads (#407, #414). Both returned an object that flowed throughtulpa()as an unrestricted model, or errored about an unrelated field.fit_st_nested()is the spatiotemporal entry point.
tulpa 0.1.0
First CRAN release. The engine’s surface is unchanged from 0.0.198; this version number marks the release rather than a feature change.
-
The SPDE outer mode-find’s numerical gradient takes a central-difference step of
1e-2on the log scale (#403). At5e-2the step’s truncation error exceeds the reduction L-BFGS-B’s line search chases near a flat optimum, so the search can reach the correct mode and then abort, returning a nonzero convergence code with the best point still inop$par.bad_mode()reads that code as an unusable mode, andfit_spde_nested_ccd()falls back to the rectangular grid, so the CCD design silently does not engage.Measured on
test-spde-ccd.R’s analytic fixture across1e-4to5e-2: every step up to2.5e-2returns convergence 0 on Linux and Windows alike and agrees bit for bit, while5e-2atfactr = 1e5is the one cell that converges on one platform and aborts on the other, at a mode both platforms agree on to 11 significant digits. On the real inner-Laplace marginal the smaller step reaches the same mode in the same 13 evaluations at a lower objective (505.675740 against 505.675808), so the wider step was costing accuracy at the optimum. -
The outer mode-find’s tuning is a
controlknob, and every L-BFGS-B stop tolerance in the package is set in one table.fit_spde(control = list(mode_find = list(factr =, ndeps =, maxit =)))overrides any subset of the tuning for the(range, sigma)mode-find undermethod = "ccd"; an unknown sub-knob or a non-positive value hard-errors rather than fitting the default, matching whattulpa_check_control()gives the outer list.The values move to
.NL_MODE_FIND(R/settings.R), which records why the consumers differ, and thestats::optim()invocation itself moves to.nl_lbfgsb_mode_find()(R/mode_find.R), sofit_spde_nested_ccd()andfit_st_nested()’s auto-grid rescue share one call site instead of carrying a copy each.tulpa_pathfinder()reads itsfactrfrom the same table without sharing the helper: it is unbounded, takes an analytic gradient when the caller supplies one, and carriesmaxit/pgtolas its own arguments.Behaviour is unchanged on every path. The spatiotemporal rescue previously inherited
optim()’s ownndepsdefault, andtest-mode-find.Rpins that the value now written out is that default by running both calls and comparingpar,value,convergenceandhessian; the same file checks the helper against the call it replaced for each consumer, andtest-settings.Rlints the sources so a newfactrorndepsliteral outsidesettings.Rfails. test-sbc-frontdoor.Ropens its device withpdf()(#404).png()selects whatevergetOption("bitmapType")names, which resolves to the X11 driver on a headless machine and cannot start there even wherecapabilities("png")reportsTRUE. Nothing in that block reads raster output.Renamed the local
Tbindings inspatiotemporal_effects()andplot.tulpa_st_summary(), and theTfield of the (internal, unread)precision_structureslist, ton_t/n_times.TandFare not reserved words in R.Intra-chain OpenMP teams now honour
_R_CHECK_LIMIT_CORES_, capping at two threads underR CMD check. The cap lives intulpa_omp_team_size(), so every team-size decision inherits it, andcpp_get_max_threads()reports the size a region would actually get.-
An outer axis whose grid does not contain its own posterior mode now says so (#401).
h / sdis the regime variable the reported hyperparameter interval’s position sensitivity is governed by, and its SD side is a 3-point parabola at the axis’s modal cell that withheld a bareNAon five distinguishable conditions. One of them, the mode sitting on an END node, is not a missing measurement: it says the grid brackets nothing on that axis.Three consequences, all of them measured on the package’s own four-axis joint fixture at
axes_at(5), whereb1.alphaholds 57.7% of its axis mass on its lowest node (2.88x the uniform share). The whole-grid verdict was read off the axes that happened to score, soresolvedcould come backTRUEwith an unscored axis present; the note named the coarsest SCORED axis, which on that fixture is a different, healthy one, and told the reader to add nodes there; and.nl_railed_axes()– which findsb1.alpha:lowerinstantly from stored weights – was attached only inside the registry grid rescue, which does not run on a caller-pinned grid, so the placement the engine leaves alone by construction was the one that never reported it.Now:
.NL_AXIS_SD_REASONSis a closed vocabulary (too_few_nodes,mode_at_edge,coord_not_finite,stencil_degenerate,curvature_not_negative) carried on the NA as an attribute, so every existingis.finite()caller is unaffected; it is stamped through asouter_grid_resolution_declined, per axis, beside the existingtheta_within_cell_declined/theta_cell_edge_declined;resolvedrequires every axis to have scored; the rail report is attached wherever the resolution is; and the note reports unscored axes and railing BEFORE the coarsest scored one. A fit where nothing scored reports that nothing could be, rather than returningNULLas if it carried no resolution at all. _pkgdown.yml’sfigures:block is removed (#400).dev: svglitenamed a package rather than a device function, and qualifying it assvglite::svgliteonly moved the failure:pkgdown:::fig_save()setsdev.args$bg <- NAunconditionally and svglite rejects a logicalNAthere. The block’s other two keys were already inert –bgis a formal offig_save()that its body never reads. The defaultragg::agg_pngrenders the reference examples cleanly.pkgdown/build_site.Ris the pkgdown deploy entry point (#402).pkgdown:::package_mds()renders every root-level*.mdminus a hard-coded exclusion list with no config knob to extend it, soCLAUDE.md,AGENTS.md,todo.mdandapi.mdwere published verbatim andAGENTS.htmlwas the first entry in the site search index. The script holds them out of the root for the duration of the build and asserts they reached neitherdocs/norsearch.json.
tulpa 0.0.198
tulpa_re_aghq()returns the mode/theta cross-Hessian block (#398). Addsblup_cross(Bf) to the return value: the per-group-d^2 ell_g / d theta dbblock at the mode, needed to draw a group’s BLUP jointly with theta instead of independently (Cinv %*% t(Bf)is the first-orderdb_hat/dthetacorrection) – the same joint-draw contract.tobs_community_em()-based tulpaObs families already expose via their ownCinv/Bf. Computed via a cheap central finite difference oftheta_scoreoverb(O(d) oracle calls, independent ofn_theta). Declines to NA (never a silent 0) when the oracle’stheta_scorehas no analytic implementation, as on the R-closure bridge (make_site/make_group) –REGroupOraclegainshas_theta_score()to signal this. Verified against the closed-form binomial-GLMM cross term and an independent finite-difference re-solve of the group’s mode under a perturbed theta. Unblocks gcol33/tulpaObs#220 (ms_abun()’s posterior-SBC registration).tulpa_re_aghq()also returns the FULL per-group joint covariance across RE terms (blup_cov_g,blup_cross_g).blup_varonly ever exposed the per-term diagonal of a group’s posterior covariance; when a group carries more than one RE term sharing the same grouping factor (e.g. an abundance-arm term and a detection-arm term on the same species), the group’s mode is found jointly across every term’s coefficients, so real posterior covariance can exist BETWEEN terms –cpp_aghq_blups()already inverts the full joint Hessian to get it, it just discarded everything off the diagonal before this. Drawing a group’s terms independently would repeat gcol33/tulpaObs#226 one level deeper (inside a group instead of between theta and a group). Validated against a closed-form joint-Hessian construction on a toy model with deliberately collinear RE terms; the new diagonal is byte-identical to the pre-existingblup_var.
tulpa 0.0.197
-
Fix: the batched joint nested-Laplace driver’s DENSE path could converge a species to a different mode than its own independent fit (gcol33/tulpa#397).
run_multi_block_nested_laplace_joint_batch’s dense branch (small/medium fields,n_x < SPARSE_THRESHOLD) solved every Newton step and the final log-determinant through the fixed-ridge-onlydispatch_factor_solve/dispatch_factor_log_det, instead of the PD-escalatingjoint_pd_step_solve_dense(the gcol33/tulpa#344 fix) the single-species dense joint driver and the batched driver’s own SPARSE branch already use. A coupled likelihood whose observed Hessian is indefinite away from the mode (the occupancy mixture’s dark-cell term is not concave everywhere) could hit a negative pivot at one outer-grid cell; with no ridge escalation the Cholesky’ssqrtproducedNaN, silently dropping that cell’s weight from the fit and shifting every reported summary. Confirmed data-dependent (not batch-slot-dependent): the same species diverged identically whether alone, paired, or duplicated against itself, and grid cells before the affected one matched the independent fit to machine precision. Fixed by routing both the per-iteration dense solve and the final-pass log-determinant throughjoint_pd_step_solve_densewithJointPDMode::LM, mirroring the single-species driver and the batch driver’s own sparse branch exactly.
tulpa 0.0.196
The simulation-based-calibration and goodness-of-fit entry points are S3 generics, so a downstream package registers a method on its own fit object instead of exporting a second name for the same concept.
sbc(),pit_residuals(),test_dispersion(),test_outliers()andtest_zero_inflation()now dispatch; every existing body became thedefaultmethod, andsbc("prior_predictive", ...)/sbc("posterior", ...)keep working unchanged throughsbc.character(). A call passing the experiment BY NAME (sbc(experiment = "posterior")) has to drop the name or useobject =, since the first argument is what dispatch reads.dic()andcpo()are new generics over the same criteria layer [tulpa_criteria()] computes, for the two criteria the loo package owns no generic for. The default methods take a pointwise log-likelihood matrix. WAIC and PSIS-LOO deliberately get none:loo::waic()andloo::loo()already exist, and a same-named generic here would mask them.test_uniformity()muffles only the ties warning a discrete response produces, rather than every warning the Kolmogorov-Smirnov test can raise.
tulpa 0.0.195
A reported hyperparameter bound that left the node range now says so (gcol33/tulpa#390). An endpoint past the outermost NODE is produced by the
outsiderule –extendmirrors a half-cell beyond the outer coordinate – so it is an extrapolation rather than a bound the design supports.theta_ci_outside_nodesrecords it per axis ("lower"/"upper"/"both"/NA), the same rule that makes a declined placement say so (gcol33/tulpa#293). Recorded rather than corrected: the two used to be indistinguishable on the fit.-
The recentred axis’s mode-SD ceiling is settled:
max_sd_u = 3is kept, on evidence. gcol33/tulpa#387 could not score its VALUE because every ladder came back flat or non-monotone. The reason was the LEVEL, not the fixture. Over 48 (cap, span, node-count, clamp-policy) rungs on two ceiling-reaching fixtures at 200 seeds, the reported bound leaves the node range on 56-90% of fits at nominal 0.95 at EVERY setting and on 0% at nominal 0.50 – so 0.50 is the level whose bound the design supports, and 0.95 is where the earlier ladders were measuring theextendrule instead.At nominal 0.50: the cap is EXACTLY inert at nine nodes (1.5, 3 and 6 give identical coverage to three decimals in 7 of 8 cells); at the shipped five nodes, 3 is nearer nominal than 1.5 at
span = 4(0.450 / 0.415 against 0.370) and ties atspan = 2.5; and 6 reaches 0.520 only on a doubled width. Under the shippedsd_clamp_policy = "decline"a lower ceiling also is not free – dropping to 1.5 abandons the placement on 32-39% of fits.spanandn_ptsare kept with it: nine nodes moves 50% coverage FURTHER from nominal while costing proportionally more inner solves.
tulpa 0.0.194
-
CUDA is used when a device is available, and there is now exactly one definition of the batched-CUDA backend (gcol33/tulpa#396).
cuda_batched_choleskyand its siblings were defined TWICE, differently:gpu_backend.hcompiled stubs returningFALSEin the#elsebranch of#ifdef TULPA_ENABLE_CUDA– which neitherMakevarsever defined – whilegpu_nngp_laplace.hincludedgpu_cuda.hdirectly and compiled the real ones. Twoinlinedefinitions of the same entity across translation units is an ODR violation: the linker keeps one COMDAT and discards the rest, so whether CUDA ran at all was decided by link order rather than by any switch, and nothing in the package could report which had been built.gpu_cuda.his now included from exactly one place, and the NNGP kernels reach it throughgpu_backend.hlike everything else. Compiling it in needs no CUDA SDK at build time and no GPU at run time: the driver, cuBLAS and cuSOLVER entry points resolve dynamically and every entry returnsFALSEwhen absent, which is what makes “use CUDA if available” expressible as a default.TULPA_DISABLE_CUDAbuilds the stubs instead, and that is now a whole-program choice.cpp_gpu_backend_kind()reports"cuda"or"stub", so which implementation was compiled is observable rather than inferred – a silent either/or is what let this sit. It is deliberately separate fromcpp_gpu_available(), which asks whether a usable device is present at run time.Two closed investigations should be re-read in this light: gcol33/tulpa#283 fixed a column-major/row-major bug in this path, and gcol33/tulpa#389 ruled the GPU dispatch out as a cause – a conclusion consistent with the stub having won the link. The per-matrix batch verification added in 0.0.192 (gcol33/tulpa#392) is what now guards the path on machines where it does run, and the gcol33/tulpa#389 determinism arbiter passes with the CUDA backend live on a device-equipped machine.
tulpa 0.0.193
LatentBlock::d_facis read through one accessor that carries its contract (gcol33/tulpa#394). Twelve call sites read this grid-dependent eta mixing coefficient and they disagreed: eight invoked it directly and four guarded it withd_fac ? d_fac(k) : 1.0, on the SAME block vector. Only one can be the contract, and it is the unguarded one – all 38 block-construction sites setd_facunconditionally, andLatentBlockis not an exported header, so no consumer package can construct one without it.d_fac_at()now states that in one place and throws a named error instead of an uncaughtstd::bad_function_call; a block that ever did omit it would previously have been silently amplitude-1.0 down one path and a crash down another.The
obs_indicesscratch buffer is cleared by the caller, once (gcol33/tulpa#395). The contract asked IMPLEMENTATIONS to clear, and four of eleven call sites cleared defensively anyway while seven relied on it. The buffer isstatic thread_localand reused across the whole observation loop, so an implementation that appended would have scattered every stale (index, weight) pair into eta as though it belonged to the current row, growing with position in the loop – a silent wrong answer rather than a crash.fill_obs_indices()clears and dispatches, so neither side can forget and a new block kind cannot get it wrong.CudaContext::initialize()is serialized (gcol33/tulpa#393). It was an unsynchronized check-then-act: two threads both observinginitialized_ == falsewould both load the CUDA libraries and function pointers into the same members and callcuInittwice. The static’s construction is thread-safe; its initialization was not. Latent rather than live today – every entry that can build an NNGP block passesn_threads_outer = 1as a hardcoded literal, and the one entry taking it from R has no NNGP branch – but the outer grid is an OpenMP parallel-for and the block prep that reaches this deliberately runs outside its critical section.
tulpa 0.0.192
-
Every factor in a batched NNGP Cholesky is verified, not one of them (gcol33/tulpa#392).
batch_nngp_scatterhands the neighbour covariances tocuda_batched_cholesky()once the batch reaches 50 and then checked the result by refactorizing ONE matrix on the CPU and comparing. That catches a whole-batch failure – which is what it was written for, after a cuSOLVER column-major factor was read row-major (gcol33/tulpa#283) – but not a per-matrix one, and the call documents that it can fail partway. A probe at a fixed index accepts every failure beginning after it, and since the matrices have different effective sizes it is not representative of the batch either.Every matrix is now checked against the factorization’s own defining identity on the diagonal,
sum_k L[j][k]^2 == C[j][j], plus a positive finite pivot. That isO(n_nb^2)per matrix against theO(n_nb^3)the factorization costs, so full coverage sits an order below the work it verifies. The exact one-matrix CPU cross-check stays alongside it as the layout check.
tulpa 0.0.191
-
gp(approx = "nngp")takes the coordinate dimension you give it (gcol33/tulpa#391). 0.0.190 made the NNGP neighbour COVARIANCE read every coordinate column;compute_nngp_neighbors()still selected neighbours over the first two, so the selection and the covariance would have used different metrics on anything wider, and a 1-D domain could not build a graph at all – which put the dimension-general kernels out of reach from the front door. The ordering, the candidate distances and the cached neighbour-pair block are all read overncol(coords)now, and the pair block comes from onedist()call rather than a hand-rolled double loop. So a transect, a depth profile or a time axis is a 1-D NNGP, and a depth-resolved domain is a 3-D one.The 2-D path is unchanged to the bit: the same neighbour indices and distances, asserted at
tolerance = 0rather than approximately, and a CONSTANT extra column reproduces the 2-D graph exactly. One coordinate-spec parser behind the three spatial doors.
spatial_gp(),spatial_multiscale()andspatial_svc()carried three verbatim copies of the same formula/character parsing block..parse_coord_spec()is now the single body, with the arity as its one policy argument – and the policy is decided by what the spec ends up in rather than by taste. NNGP reads any dimension>= 1;approx = "hsgp"and every sampler mode take exactly 2, because both store coordinates at a fixed 2-D stride, and they say so with the dimension they were given.
tulpa 0.0.190
-
An NNGP fit is a function of its data again: the neighbour covariance was built from an out-of-bounds read (gcol33/tulpa#389). Three NNGP neighbour-covariance loops – the Laplace kernel (
laplace_core.cpp), the batched builder (gpu_nngp_laplace.h) and the PG-Gibbs sweep (pg_shared.h) – each formed the neighbour-to-neighbour distance by hand over coordinate columns 0 and 1, and read column 1 UNCONDITIONALLY. On ann x 1coordinate matrix that offset is1 * nrow + i, which isndoubles PAST the end of the matrix’s own allocation, so the neighbour covariance was built from whatever the R heap happened to hold behind it. Nothing crashed and no value looked odd – the read lands inside the heap and returns a finite double – so the fit simply stopped being a function of its data and moved with the process’s allocation history.MEASURED before the fix, the same seeds fitted twice in ONE process at
n_threads = 1: 10 of 20 fits differed at 49 locations withlog_marginalmoving 4.09, and 20 of 20 at 50 and 60. Which SIZES broke moved between sessions – 30 and 60 broke and 120 was clean in a run where the original report had 30 and 60 clean and 120 broken – so the size-dependence the first reading proposed was the allocation history, not a buffer edge. It was never the batched-Cholesky dispatch either: 49 locations is on the CPU path and broke.The three loops now share one helper,
tulpa_linalg::coords_dist()(src/linalg_fast.h), which sums over EVERY column the coordinate matrix carries. So the coordinate dimension is whatever the caller supplied, and a 1-column matrix is a 1-D domain – a transect, a depth profile, a time axis – rather than a 2-D one with a column missing. Three coordinate columns work on the nested-Laplace path for the same reason. The arbiter is that a CONSTANT extra column, which cancels in the distance, now reproduces the 1-D fit bit-for-bit at two different constants. GP field prediction reads the same metric the fit was built on.
cpp_gp_field_predict()computed both its distances over two columns while the fitting path is now dimension-general; leaving it pinned would have made prediction disagree with the fit on any other width, which is a silent metric mismatch rather than an error. It goes through the same helper and requires only that the prediction and fitted coordinates have the same number of columns.A path that cannot store a coordinate refuses it instead of misreading it. Every sampler spec stores coordinates in a flat buffer at stride 2 (
GPData::coordsand its siblings), a layout shared with the samplers and the ABI, so those sites are 2-D by construction and are not made general. They now calltulpa_linalg::require_coords_2col()and error with the arity they were given. The same guard covers the HSGP 2-D basis and the NNGP twin probes.The R side stopped silently reshaping coordinates to two columns. Four sampler specs and two prediction paths passed their coordinates through
matrix(as.numeric(x), n, 2), which does not CHECK the arity – it IMPOSES it. Ann x 1matrix is recycled so that column 2 equals column 1, putting every location on the diagonal and scaling every distance bysqrt(2); ann x 3matrix is truncated to its first two columns. Both are a different geometry accepted in silence..coords_2col()now errors on either, and.coords_plain()strips attributes without touching the shape for the paths that read the dimension.Measurements taken on a 1-D-coordinate NNGP fixture predate this. The
nngp_120row ofdev_notes/issue361/RESULTS361EXT.mdand the mode-SD ceiling evidence in gcol33/tulpa#387 were both taken on such a fixture and were reading out-of-bounds heap. Neither conclusion rested on that row – gcol33/tulpa#361’s default is carried by five other reproducible configurations, and gcol33/tulpa#387 keptmax_sd_u = 3explicitly because that row is inadmissible – but the row itself should be re-measured before it is cited (gcol33/tulpa#390).
tulpa 0.0.189
-
A recentred outer axis whose mode SD hit the CEILING now declines the placement and keeps its incoming span (gcol33/tulpa#387). A clamp is not a spread the finite-difference stencil measured – it is the stencil failing to resolve a direction, with a number substituted for what it could not read. Laying an axis from the substitute states a spread the fit does not have: on a
logaxismode +/- span * max_sd_uisexp(+/- 7.5), a factor of 1808 either side of the mode, and the reported interval is read off that span. The fit now recordsouter_grid_recenter_declined = "sd_ceiling_unresolved", which is the gcol33/tulpa#293 rule: a placement the engine declines to make has to say so rather than be indistinguishable from one that was not needed.controldoes not carry this;.NL_RECENTER$sd_clamp_policyis the single place it is set, and"clamp"restores what shipped through 0.0.186.MEASURED over 200 fixed-truth seeds on each of six configurations x two placement policies, arms paired seed by seed and differing only in this setting. Summed |coverage - nominal| over nominal 0.95 / 0.80 / 0.50 at the shipped placement, and the paired 95%-level record against
"clamp":"decline"0.1393 against 0.1464, changing 35 trials, winning 7 and losing none (sign test p = 0.0078) at an unchanged width (ratio 1.0000). A third policy,"relative"– cap the re-placed span by the INCOMING axis’s own span rather than by an absolute bound – scores 0.1536 and loses 7 trials to 1, and is kept as a selectable arm rather than shipped. The FLOOR keeps substituting, and it is the same table that says so. A clamped floor WIDENS a too-narrow axis, which is the direction that cannot rail, so substituting there is right and declining costs 22 trials against 9 (
sd_floor_policy = "clamp").min_sd_u = 0.15is a minimum of its ladder in both directions – dropping it to 0.05 loses 374 trials and wins none, raising it to 0.30 loses 30 and wins none. The floor is also the bound that actually binds: it engages on 3 of 7 rows and on every fit of those rows, where the ceiling reaches 2 of 268 axis reads.max_sd_u = 3is KEPT, measured rather than left alone. Across a ladder spanning a factor of 15 (0.4 to 6) the summed coverage deviation moves 0.2843 to 0.3071 and the mean 95% width 1.986 to 2.120, and 3 is the best rung on calibration. Fixtures built to REACH the ceiling – aniiddesign shrunk until 27.5% of raw mode SDs pass 3 – return a NON-MONOTONE coverage response whose driver is not the cap: no axis rails at any rung, and the reported bound lies outside the node range on ~89% of those fits, identically at 0.8 / 1.5 / 2 / 3. What moves across that ladder is where the outer-cell extrapolation lands. The earlier reading that the ceiling produces 95% widths in the hundreds came from the one row that reaches it,nngp_120, whose fits are not reproducible (gcol33/tulpa#389).Every recentred fit now says what its axes were laid from. Per moved axis,
outer_grid_recenter_sd_clamp("none"/"floor"/"ceiling"),outer_grid_recenter_sd_raw(what the stencil measured) and_sd_used(what the axis was laid from) – the raw SD is recorded even for an axis the policy declined, since that is the reading which says whether declining was right.diagnostics()names a clamped axis in its placement note. The clamp is applied in exactly one function,.nl_recenter_sd_clamp(), behind all four rescues – the spatiotemporal driver’s hand-inlined copy of the same two bounds included.A bound-decline is PER AXIS on the multi-axis drivers. The spatiotemporal rescue re-places
(tau_spatial, tau_temporal, rho)together, and declining the whole pass because ONE axis hit a bound discards the placement of the axes the mode-find did resolve – on thear1fixture, both precision axes thrown away becauserhoalone was unresolvable. Each axis now keeps its own incoming nodes andouter_grid_recenter_sd_declinednames which axes did so and on which bound, so a partially re-placed grid is not read as a fully re-placed one. With every free axis declined the pass reports the grid as the fixed one it still is. A decline for any other reason is a failure of the mode-find itself and still takes the pass down.
tulpa 0.0.188
-
Reported hyperparameter intervals are read with the box-uniform within-cell construction by default (gcol33/tulpa#357). The outer grid’s weights say how much mass each cell holds and not how it is spread inside the cell, and a quantile needs both. The engine now places each cell’s full mass at its own box EDGES and interpolates between edges, where it placed the cumulative mid-mass at each cell COORDINATE. Same masses, same boxes, knots moved half a cell – and a whole order of convergence, 2.00 against 1.04 against a closed-form posterior.
control$within_cell = "chord"restores the previous report per fit, exactly; point estimates, moments, draws and weights are untouched either way.The construction has been selectable since 0.0.175 and the default was held back by two things, both of which the placement work in 0.0.187 changed. Until gcol33/tulpa#361 the default axes were laid without reference to the posterior, so every earlier measurement of this choice was taken on a grid pinned coarser than any a user now gets. Re-measured at the placement the engine ships, summed |coverage - nominal| over nominal 0.95 / 0.80 / 0.50, chord against box-uniform: 0.2900 against 0.1233 on gcol33/tulpa#337’s own pre-registered fixed-truth instrument (300 seeds), 0.2004 against 0.0361 over the 4680 truth-swept fits of the same fixture whose axis contained the truth, and 0.2467 against 0.1572 over nine (config, axis) rows spanning seven families at 200 seeds each. Box-uniform is nearer nominal on 6 of those 9 rows and at all three levels of the other two, at 0.69 to 1.08x the width.
The conditional-coverage sensitivity that held the default back is a property of ANY within-cell reconstruction, and at the shipped placement it no longer separates the two: box-uniform’s realized 95% coverage swings 0.110 across a cell where it swung 0.415 on the coarse pinned grid it was measured on, and at nominal 0.50 the two reads swing 0.238 and 0.231. A resolution-conditional default was scored rather than assumed and is dominated by the fixed rule (best threshold 0.1733 against 0.1572).
The regime it is weakest in is recorded rather than left to be rediscovered: on a coarse grid PINNED by the caller with four crossed blocks and no placement pass, box-uniform wins one resolution and loses the next (per-axis summed deviation 0.5083 against the chord read’s 0.6500 at four levels, 0.6875 against 0.5917 at five), because four axes give four independent box positions. That is the regime every measurement before 0.0.187 was taken in, and it is not what a default fit gets.
Two consequences worth knowing. A locally CCD-refined grid is not a partition that tiles, so it declines the new default and reports the chord read with
theta_within_cell_declined = "support_mixed"– a difference a performance knob introduces, which is why it is recorded per axis rather than silent. And.re_cov_derived_summary()is deliberately pinned to the chord read: its values are derived quantities at the nodes rather than the design’s own cell coordinates on the reported axis, so a box partition of them would assert a property nothing measured. The RE-covariance backends are unchanged.
tulpa 0.0.187
The outer-grid placement pass reaches every registry family whose axes carry a coordinate, and every one it does not reach says why (gcol33/tulpa#361).
.NL_REGISTRY_AXIS_FIELDnamed movable axes foricarandbym2alone; it now coversicar,rw1,rw2,iid,bym2,nngp,hsgpandspde, and the field writer behind the finite-difference stencil is one generic pass over that table instead of a hand-written branch per family (which silently ignored itsthetaargument for everything else, so the curvature was unusable there anyway). Membership follows one rule – the transform registry names a coordinate for EVERY axis of the family’s grid, and its grid fields bind to those axes one for one – andtest-nl-registry-axis-scope.Rholds the table to that rule from both sides, so a family added with a placeable grid and no entry fails rather than being left out in silence. The seven families it does not cover are covered by a stated reason on the fit:car_proper,ar1andhsgp_moeach carry a correlation axis on a support the registry will not guess and decline withunguessable_axis: rho;mcar,miidandtgmrfhold their axes in a single matrix field;lfcarries no outer axis.The same pass now runs on the MULTI-BLOCK
tulpa_nested_laplace(prior = list(...))path, which previously stamped no placement at all – not"fixed", not a decline, not the railed-axis report. A moved block is re-crossed on its own fields while its neighbours keep theirs, and the finite-difference stencil re-evaluates the inner marginal through the driver’s owntheta_grid_override, with each SPDE block’s PC prior folded back on so the stencil differences the target the grid integrates.Behaviour change:
control$auto_recenter = TRUEnow re-places a grid that does not RESOLVE its posterior, not only one that RAILS. An axis whose median node spacing exceeds.NL_RECENTER$resolve_mult(2) posterior SDs in its own coordinate triggers the pass alongside the rail test; both tests read the weights the fit already stored, so a grid that brackets and resolves its mode still costs nothing beyond them. Measured over 200 fixed-truth seeds on each of six configurations (icar chain / icar lattice / rw1 / bym2 / iid / nngp, eight scored axes), mean |coverage - nominal| against the rail-only policy goes 0.043 -> 0.030 at the 95% level, 0.171 -> 0.084 at 80% and 0.243 -> 0.129 at 50%, at 0.63 times the 95% interval width and 0.76 times the median bias, for 1.71 times the wall clock. The rail-only policy is available asauto_recenter = "rail";FALSEand"always"are unchanged.Unconditional re-placement (
"always") is not what shipped, and the reason is cost rather than a clean calibration win. The two policies agree seed for seed on five of the six measured configurations; they differ on the one whose default axes already resolve their own posterior (NNGP, medianh / sd1.81 and 1.50), where the default fires on 39.5% of seeds against 97.5% and covers 0.530 / 0.500 at the 50% level against 0.135 / 0.385. Coverage arbitrates a placement rule (gcol33/tulpa#331), so that row favours the default – but the reference read on a dense pinned axis that contains the posterior says"always"is the nearest read of it there, and that the fixture’s posterior itself sits 0.62 / 0.73 above its own truth on the log scale, so a narrower span covers better by cancellation. What separates the two cleanly is 1.71 against 2.04 times the wall clock. The threshold of 2 minimizes the 50% deviation and the median bias over the measured family of thresholds and sits 0.0006 off the best 95% deviation.The recentred axis’s
h / sdtarget of 1.25 (5 nodes overmode +/- 2.5 sd) is measured rather than asserted. Sweeping the node count at fixed reach over 200 seeds on two configurations, mean |coverage - nominal| at 3 / 5 / 7 / 9 nodes is 0.038 / 0.032 / 0.027 / 0.022 at 95%, 0.155 / 0.062 / 0.067 / 0.082 at 80% and 0.182 / 0.185 / 0.125 / 0.130 at 50%: 3 nodes is clearly worse, and past 5 the gain is small and not uniform across levels. Cost is linear in nodes per axis and multiplicative across crossed axes, so 7 and 9 nodes put the widest crossed default the engine lays (hsgp_mo, four axes) at 2401 and 6561 cells, both past.NL_MULTI_GRID_HARD_CAP = 2048, where 5 nodes is 625.tulpa_nested_laplace_joint()andfit_st_nested()refuse every per-axis policy name rather than only"always": their rescues trigger on the whole grid’s collapsed-edge regime, so none of the three has a measured meaning there.
tulpa 0.0.186
-
Behaviour change:
control$skew_correctnow defaults toTRUE(gcol33/tulpa#364).summary()andconfint()on a nested-Laplace fit report Cornish-Fisher marginal quantiles at each coefficient’s own inner-Laplacegamma_3, about the centregamma_1 + gamma_3 / 2that Rue, Martino & Chopin- eq. (22) implies, wherever the combined inner band admits the coefficient – and the previous read wherever it does not.
control$skew_correct = FALSErestores the uncorrected report exactly. Point estimates, standard errors,vcov(), draws, modes, weights and every hyperparameter summary are untouched: the correction is post-processing on the reported quantiles.skew_appliednames the rows it was used on,interval_sourcenames the read, and$skew_correction$reasonnames the score that declined any row.
The three things a default-on had to be safe against, all measured on current main after the two changes in 0.0.185.
The flip survives the shipped gate. Scored by paired CRPS against the read a default-OFF fit gives – the gcol33/tulpa#336 grid mixture – over 400 prior-predictive replicates each: the rare-event binomial-logit intercept
t = -1.895, and the small-group Bernoulli design’s two coefficientst = -3.765andt = -3.201. The pre-gcol33/tulpa#354 read that had no location term scored+3.54/+6.12/+4.64on the same fixtures.Coverage holds across model classes. Twelve configurations – the six built-in families on the single-block driver, a rare-event small-group binomial, a small-group Poisson, the same data on the joint driver, and three crossed groupings at outer dimension 3 – read off ONE solve per seed by the shipped
recov_sweep(), so the corrected and uncorrected arms are paired and differ only in the marginal read. Pooled over 960 trials at nominal 0.95:0.9510 -> 0.9542, standard error 0.0070, with every configuration inside the 3-standard-error acceptance the shipped gates use and the gaussian family identical to the bit. Two small-sample classes move, in opposite directions, and are the whole of the movement: at 200 seeds and three levels the correction takes the small-group Poisson design from 0.8950 / 0.7050 / 0.4200 to 0.9400 / 0.7950 / 0.4650 at nominal 0.95 / 0.80 / 0.50, and the rare-event binomial from 0.9650 / 0.8050 / 0.4900 to 0.9175 / 0.7550 / 0.4700. Summed distance from nominal over the nine cells: 0.295 uncorrected, 0.175 corrected.The rare-event drop is the exact answer. Coverage at a FIXED truth cannot say whether an interval moved away from the right one – a credible interval attains its nominal rate averaged over the prior, not at one parameter value. Fixture A’s posterior is exact by one-dimensional quadrature, so the same rare-event regime runs at five fixed truths with the exact posterior as a third arm (2000 fits): pooled, exact 0.9470 / 0.8650 / 0.5630, corrected 0.9290 / 0.8650 / 0.5630, Gaussian 0.9625 / 0.8210 / 0.4165. The corrected interval reproduces what the exact posterior does at 0.80 and 0.50 and is 0.018 from it at 0.95, where the Gaussian is 0.044 and 0.147 away at the two lower levels. At
beta = -2and level 0.50 the Gaussian interval contains the truth on 0 of 400 replicates, the exact posterior’s on 367, and the corrected one onThe decline paths are no-ops. A coupled fit (every arm a multi-eta unit, so no location term), a coefficient the importance k-hat flags, a coefficient past the shape band and a non-nested fit all report bounds identical to the correction-off fit, to
0.000e+00, while an eligible coefficient on the same fit moves by 0.397. That required gcol33/tulpa#386 in 0.0.185; without it every one of those classes moved. - eq. (22) implies, wherever the combined inner band admits the coefficient – and the previous read wherever it does not.
tulpa 0.0.185
-
The CENTRE band on the inner-Laplace skew correction is off (
.NL_DIAG$centre_unreliable = Inf, gcol33/tulpa#376). It shipped at 1.20 in gcol33/tulpa#362 as the smallest cutoff that declined nothing the correction was measured to help – on four fixtures none of which could reach it. Three that do reach it were then built on two sampling designs, and the cutoff ladder over the enlarged set (6220 coefficient-seeds, 3600 of them admitted by the other bands) is monotone and zero only past the largest admitted centre measured: the per-fixture-coefficient costs sum to+0.30at 1.00,+0.27at the shipped 1.20,+0.19at 2.00 and0only at 5.00. No cutoff is protective anywhere – all 13 fixture-coefficients score a negative paired gain, and binned by|m|the correction gets BETTER as the centre grows without turning.The mechanism is why.
m_i = (1/2) sum_j c_j rho_ijandgamma_3(i) = sum_j c_j rho_ij^3are the same weighted sum at the first and third powers, so a large centre carrying a smallgamma_3is uniformly WEAK correlation – the well-behaved incidental-parameter regime – rather than a strong direction being extrapolated. The band was anti-correlated with the pathology it was imagined for.Measured on those stored coefficient-seeds: 376 rows change and every one is
centre_unreliable -> eligible, with the 2620 the shape band and the combined inner band decline untouched. On those 376 the correction they now take scores-0.09665against the Gaussian they used to report (se 0.00676, t -14.31) where the exact posterior scores-0.09721– 99.4% of the achievable gain. A live re-fit of the small-group Poisson fixture agrees through the engine’s own record: 21 of 80 rows move, max admitted|m|2.404, and every one still bandedgoodbygamma_3and by the combined inner band.Nothing else changes.
cornish_fisher_in_band()is still the one predicate behind both the eligibility record and the quantile path,centre_unreliableis still in.SKEW_CORRECT_REASONSand in the precedence, and the cutoff is still an argument –.nl_skew_correction_attach(max_abs_centre =)now takes it – so a finite value restores the band on every path at once, which is what the decline tests drive.gamma1_not_computableis what guards an unformed location term and is untouched. A coefficient the skew correction DECLINES keeps the grid-mixture read (gcol33/tulpa#386).
.nl_fixed_interval()branched on the whole fit, soskew_correct = TRUEsent every reported coefficient down the MAP-cell path and the ones the bands refused fell back past the gcol33/tulpa#336 mixture CDF tomu +/- z sigma. The two reads cannot be composed on a CORRECTED coefficient –gamma_3is retained at the MAP cell only – but a declined one has no MAP-cell read to preserve, so the fallback gave up the across-cell shape for nothing. Worst on a fully coupled fit, wheregamma_1is unreachable and every coefficient declines: enabling the correction there moved every bound and corrected none. The base read is now computed first and only the rows.nl_skew_marginal()applied to are overwritten;interval_sourcereports"skew_map_cell"when every row was corrected,"skew_map_cell/mixture_cdf"when both reads are in play, and the base source when none was.
tulpa 0.0.184
-
weightsreaches the spatial mode-finding, not only the marginal precision built at it (gcol33/tulpa#385).dispatch_laplace_spatial()carried noweightsargument at all, so a spatialtulpa_laplace()fit found the UNWEIGHTED mode and then handed it to.marginal_H_beta_*(), which has always consumedweights– the reported mode and the reported precision described two different models. On a 25-cell ICAR fixture at a 4:1 weight split the slope moved 0.740 -> 0.880 (19%) once the weight reached the Newton loop, the score of the weighted objective at the reported mode was 14.86 rather than 0, andH_betamatched the WEIGHTED Schur at that unweighted point to 2.0e-16 while sitting 130% away from the unweighted one. Reachable fromtulpa_em_laplace(), which forwards a block’sweightsunconditionally, including for a block that also carriesspatial.The arithmetic already existed:
BuiltinFamilyResponse::weightsscales each row’s log-density, score and Fisher information alike, and the third-derivative oracle behindgamma_3reads it too. What was missing was the argument. All six spatial kernels now take it –cpp_laplace_fit_spatial/_bym2/_car_proper/_hsgpthroughbuild_spec_family_inputs(), andcpp_laplace_fit_gp/_spde/_spde_precomputedthrough aweightsfield onJointArmthatmake_single_arm()fills and the joint driver hands to its built-in-family response. None needed the error fallback. The fractional-nu SPDE marginal (cpp_spde_fractional_logmarginal) is weighted with them, so a weighted mode is never paired with an unweighted log-marginal; its gaussian branch uses the|I + K B|form, which stays finite at a zero weight where the scaled-variance form does not.Measured rather than asserted. The weighted mode reproduces an R Newton solve on the penalized objective written from its definition to 8.5e-15, against 0.140 for the unweighted one; the weighted score at the reported mode falls from 14.86 to 1.4e-06 (the loop’s own step-based floor);
H_betaand the Schur at the reported mode now agree to 4.8e-17 with the samew. An integer weight equals row replication on every kernel, which reads the weighted path against the engine’s own unweighted solver on a different data set. Nothing moves atweights = NULL: 25 fits spanning every kernel through both the raw C++ entry and the front door (includingH_beta) areidentical()to the pre-fix build, worst numeric difference 0. No ABI change – every struct touched lives insrc/, none ininst/include/tulpa/.Evidence in dev_notes/issue385/.
tulpa 0.0.183
An EM soft label travels on
weights, not on a fractionaly(gcol33/tulpa#383).tulpa_em_laplace()’s own zero-inflated-Poisson example encoded the occupancy arm aslist(y = weights, ...), and the count-family guard refused it – the lastR CMD check --run-donttestERROR. The guard is right and the encoding was wrong: the M-step maximizes the expected complete-data log-likelihood, which for a Bernoulli latent issum_i [w_i log p_i + (1 - w_i) log(1 - p_i)], a WEIGHTED Bernoulli likelihood carrying no binomial coefficient. A fractionalyasks instead for the exact binomial density at a non-integer response, whoselchoose(1, w)normalizer is neither zero nor free ofw– a different objective that shares a maximizer only once that term is dropped. The channel for the right one already existed:BuiltinFamilyResponse::weightsscales each row’s log-density, score and Fisher information alike, so the encoding is two rows per unit,y = 1at weightwandy = 0at weight1 - w. The example, them_step_encodecontract and the block validator now say so, and a fractional binomialyon an EM block errors naming the channel rather than the density.That claim is measured, not asserted (
test-em-laplace-recovery.R). One M-step’s binomial arm reproducesglm(family = binomial, weights = )to 3.4e-07 and thecbind(w, 1 - w)form to the same figure, itsH_betais the weighted Fisher information to 1.2e-12 relative, and the Poisson arm agrees with its ownglmto 2.5e-08 – the residual in each case being the engine’s weak built-in fixed-effect ridge, whichglmdoes not carry. Zero-inflated-Poisson recovery over 12 seeds at n = 2000 returns (0.796, -0.903, 1.197, 0.501) against truth (0.8, -0.9, 1.2, 0.5), and the observed-data log-likelihood – written out independently of the fit – rises monotonically across undamped EM iterations, worst decrease -2.6e-11 against a total climb of 78.8.test-em-laplace.Rmocks the fitter throughout, so until now the driver had 20 structural tests and no recovery.tulpa_laplace(weights = )is length-validated (gcol33/tulpa#384). The kernel borrows the vector as a bare pointer indexed toN, so a short one was an out-of-bounds read that returned the start vector as the mode with no error..validate_submodel_block()checks the same field on an EM block.The EM occupancy block in
test-recovery-assembled.Rran for the first time. It carried the same fractional-yencoding, so the guard had made it unrunnable since 0.0.30 and its premise – that the raw EM leaves the intercept biased and MI / Gibbs reduce that bias – had never been measured. Under the corrected encoding the raw M-step is already at the expected complete-data maximum and there is no point bias to remove: median|b0 - truth|is 0.0817 raw against 0.0863 under MI and 0.0856 under Gibbs. What the corrections do add is the latent-state uncertainty the raw Laplace conditions away,V_between > 0andV_total > V_within, and that is what the block now asserts.
tulpa 0.0.182
-
sbc(experiment = "posterior")reports the pooling premise from the guard’s own state rather than asserting it (gcol33/tulpa#380). The driver callspool()for every simulation, so"verified"was always the true answer – but writing it as a constant is the pattern gcol33/tulpa#293 is about, where a field that cannot say “not reached” hides a path that was never taken. It now readsstate$checked, the same wayfresh_groupsalready read its own.
tulpa 0.0.181
R CMD checkis clean again (gcol33/tulpa#382). Three pre-existing defects, none related to the code they sat next to, and each a one-line fix.temporal_rtr()’s example calledtemporal_rw1()with no arguments whiletime_varhas no default, sochecking exampleswas an ERROR on any machine; it now passes"year", as every other call site in the package does..canonical_family()’s roxygen linked.family_or_stop(), which is internal and has no Rd, giving a dangling cross-reference; the sentence naming it stays, the link goes. Andlmtest,numDerivandspdepare used through::in the test suite and were not declared – they are used TODAY, all three are on CRAN, and this is a declaration rather than a new dependency. ThenumDerivtest is nowskip_if_not_installed()-guarded like the other two, so it skips rather than errors where the package is absent.The same hygiene one layer over:
test-posterior-sbc.R’s new front-door equivalence assertion (gcol33/tulpa#380) compared the whole driver return, including thefit_obsthe posterior driver attaches, whose$timingis wall clock and differs between two identical runs by construction. The assertion now drops that attribute and keeps the experiment.
tulpa 0.0.180
New:
sbc(), one exported front door for simulation-based calibration (gcol33/tulpa#380). The SBC machinery from gcol33/tulpa#335, gcol33/tulpa#339 and gcol33/tulpa#350 was complete and independently arbitrated, but it lived only intests/testthat/helper-sbc.R, so a user could not run SBC on their own fit and a method claim citing it had no engine path a reader could reproduce. Sections 1 to 6 of that file – the predictive shapes, the PIT with within-atom randomization and its folded read, the CRPS closed forms, the exact simultaneous ECDF bands, and both drivers – moved unchanged intoR/sbc.R. The tests now read the PACKAGE functions; nothing is duplicated.sbc(experiment = )selects the two experiments in which the PIT is uniform and the CRPS is a proper posterior score:"prior_predictive"(ordinary SBC, takingsimulator/fitter) and"posterior"(calibration conditional on an observed data set, Sailynoja et al. 2026 Algorithm 2, taking the six-callbackmodel). A fixed-truth sweep is not offered – it is not an SBC experiment – and the refusalsbc_crps_compare()enforces on one is unchanged underneath. The result carries the per-(arm, quantity) PIT, the report table, the calibrated band and what each guard concluded, and reads throughprint(),summary(baseline = )(which adds the paired proper-score ranking),plot()(the ECDF difference against the band) anddiagnostics(). Passing ansbcresult todiagnostics(fit, sbc = )prints the calibration verdict under the fit’s own reliability band, which is a screen and not a verdict: the two are measured to disagree in both directions.The five predictive shapes
sbc_mixture(),sbc_normal(),sbc_discrete(),sbc_rank()andsbc_draws()are exported alongside it as the argument type a fitter returns. They are the extension point, not alternative verbs.Two guards, each on a premise that silently invalidates a result. The prior-predictive path refuses a scored quantity whose truth does not move across simulations – what an improper prior looks like from outside, and what the nested door’s fixed effects are – before spending the fits, naming
experiment = "posterior"as the way out; a location parameter admitted by a structural argument goes throughflat_prior, which is checked in both directions and travels on the result. The posterior path refuses apool()returning no more than one of its two inputs (fitting the replicate alone is ordinary SBC under a hand-made prior), and verifies disjoint group LABELS whenmodel$group_idsis supplied, recording the premise as unverified rather than assumed when it is not. Whether the simulator drew those groups’ effects independently is not observable from outside the callback and is not claimed.
tulpa 0.0.179
-
The chord read’s own interior interpolation no longer reports
Infas a bound (gcol33/tulpa#381)..nl_wtd_quantile()delegates the interior tostats::approx, whose linear formy0 + (y1 - y0) * tbuilds the DIFFERENCE before scaling it, so two adjacent knots more than the double range apart take it out of that range and the reported bound comes back non-finite at a probability sitting strictly between two FINITE coordinates. This is the interior read, not a cell edge: it reproduces with the partition finite and its mirror standing, and on thesamplesupport that forms no edge at all, which is what separates it from gcol33/tulpa#377, gcol33/tulpa#378 and gcol33/tulpa#379.The convex form
(1 - t) y0 + t y1cannot overflow there, but it is NOT substituted wholesale: the two are not the same double, and over 840000 randomized reads 21.49% of them move between the forms, by up to 4.07e-10 relatively where the difference cancels. Three fixes on this path are pinned onidentical()against whatapproxreturns. So.nl_interp_repair()is reached only where the straight read already failed to return a double, and a bracket whose knots are not both finite declines rather than inventing a number. Measured over 1620000 reads (60000 node sets straddling the double range in both signs x five declarations x three outside policies): non-finite bounds 9504 -> 0, finite pre-fix reads that moved 0, repaired bounds outside their own bracket 0, mis-ordered reads 0.
tulpa 0.0.178
-
An undeclared axis’s mirrored cell edge is guarded the way a declared one’s is, so a reported interval bound cannot come back
InforNaN(gcol33/tulpa#379)..nl_cell_partition()mirrors the extreme cell’s half-spacing to place the outer edges; gcol33/tulpa#377 gave that mirror a fallback on a DECLARED domain and left the surviving guess branch never checking the edge it produced. On the LINEAR coordinate the mirror needs the extreme coordinate plus half its own spacing to stay in the double range, and at the top of that range it does not:c(1, 1e300, .Machine$double.xmax)mirrored toInfand the DEFAULT chord read reportedInfas a 97.5% bound from a partition recording no reason, whilec(-.Machine$double.xmax, 0, .Machine$double.xmax)mirrored to-Infand the interpolation between a non-finite knot and a finite one reportedNaN.Such a mirror now falls back to the extreme coordinates – representable by construction, and inside whatever support the axis has, since they are coordinates of it – and records
mirrored_edge_not_representable, a fourth entry in the closed.NL_EDGE_DECLINEDvocabulary (gcol33/tulpa#293: a silent-disable path needs a reason). It is a second name rather thanmirrored_edge_outside_domainbecause there is no domain here to be outside of, and it takes precedence over anodes_outside_declared_domain/unknown_domainalready in hand: those name a declaration that was set aside, after which the guess ran, and this one names what the edges ARE.The guess itself is not otherwise narrowed. An undeclared all-positive axis whose log mirror underflows keeps its
0lower edge, which is gcol33/tulpa#377’s boundary from the other side – with no declaration there is no support to measure an edge against, andcar_proper’srho_carkeeps exactly the edge it has.Measured over 595010 reads (59501 randomized node sets spanning the double range in both signs x five declarations x both within-cell constructions): non-finite partition edges 69930 -> 0, non-finite reported bounds attributable to the mirror 251746 -> 0, mis-ordered bounds 0 -> 0, finite bounds outside a declared containing support 0 -> 0, and no read gains a non-finite bound. Byte-identical elsewhere: over 540 (node set, domain, weighting) cases and 4320 quantile comparisons, plus 118405 randomized node sets, everything whose pre-fix partition had finite edges is
identical()on the edges, the boxes, the coordinate, the reason and every quantile read; 0 of 118405 moved despite a finite pre-fix edge, and all 960 replaced partitions are finite, bracketing and tile.Only the linear coordinate reaches it –
log/qlogis/atanhland inside about +/- 745 – and no default.NL_GRIDaxis is near it; it is reachable through a pinned grid and throughhyper_axis_spec(bounds = c(-Inf, Inf)).test-within-cell-box-uniform.R’sboxes_do_not_tilefixture moves for the second time, off the undeclaredc(1, 1e300, .Machine$double.xmax)– which now tiles – and onto theunitaxis at the subnormal floor, whose decline is a property of the node set against its coordinate and which no edge guard changes. A fit’s interval read note reports the two halves of that vocabulary separately, because they say opposite things about the bound.
.NL_EDGE_FALLBACKnames the reasons whose edges ARE the extreme coordinates (mirrored_edge_outside_domain,mirrored_edge_not_representable), so the bound is conservative on that side;nodes_outside_declared_domain/unknown_domainset a declaration aside, after which the guess ran and its mirror stood, so the bound is a guessed edge. The single sentence.tulpa_interval_read_note()fired for all of them reported the second pair as running to the extreme coordinate, which is the opposite of what happened.
tulpa 0.0.177
-
The box builder’s interior midpoint is formed as
a / 2 + b / 2rather than(a + b) / 2, so a partition whose every edge is a representable double is built instead of declined (gcol33/tulpa#378)..nl_box_edges_from()bisects each interior cell spacing in the axis’s own coordinate; the sum was formed before the halving, so two coordinates near the top of the double range took it toInfand the whole partition was refused withboxes_do_not_tile, the axis falling back to the chord read.c(1, 1e300, .Machine$double.xmax)declaredunboundedis the reachable case:(1e300 + double.xmax) / 2isInfwhile1e300 / 2 + double.xmax / 2is8.988466e+307, and the boxes that midpoint defines tile the axis.Only the LINEAR coordinate reaches it –
log/qlogis/atanhland inside about +/- 745, where no sum of two coordinates can overflow – so it is theunboundeddeclaration and the undeclared axis whose values are not all positive. No default.NL_GRIDaxis is near it; it is reachable through a pinned grid and throughhyper_axis_spec(bounds = c(-Inf, Inf)).a / 2 + b / 2and NOTa + (b - a) / 2, measured rather than pattern-matched (dev_notes/issue378/midpoint378.R, 4e6 randomized pairs spanning the double range in both signs). The offset form overflows onb - afor opposite-sign extremes – reachable, since the linear coordinate is signed – taking 1117 pairs toInfthata / 2 + b / 2returns finite, and it rounds twice on ORDINARY operands, moving 52536 of the 3998914 pairs where(a + b) / 2is finite.a / 2 + b / 2produced 0 non-finite results, and stays inside[a, b]on 2e7 subnormal-heavy ordered pairs.BYTE-identical wherever the sum form worked at all. Halving a normal double decrements the exponent and leaves the significand alone, so
a / 2andb / 2are exact and their sum carries ONE rounding of the same real the sum form rounds once – rounding to nearest commutes with scaling by a power of two. Asserted withidentical()against the pre-fix file sourced from git into its own environment over the namespace (dev_notes/issue378/identity378.R): of 135 (node set, domain) cases over 4 weightings x 4 support kinds x 2 within-cell constructions x 11 probabilities, 133 are identical on the cell edges, the box partition and every quantile read, and the 2 that move were both pre-fix DECLINES that now tile. Over 197374 randomized node sets across all four domains, the fix removes 24045 declines and introduces 0, with 172860 tiled under both and 0 of them moved; all 23913 recovered partitions are finite, strictly increasing, of the right length, contain their own coordinates and stay inside the declared support. The 3621143 pairs where both halvings are exact carry 0 differences; the two forms part only where a halving is subnormal (|x| below about 2.2e-308), by at most 1.0e-320.The decline is kept, not removed.
test-within-cell-box-uniform.R’sboxes_do_not_tilepin moves to the same node set UNDECLARED, where the decline is a property of the node set rather than of the arithmetic: the partition mirrors the extreme half-spacing in the value itself anddouble.xmax + (double.xmax - 1e300) / 2is past the double range in any form, so there is no better midpoint to compute. A second pin covers a node set the coordinate map cannot separate (aunitaxis at the subnormal floor, whereplogis(qlogis(1e-320))underflows to 0), alongside the zero-width interior box already pinned. A new randomizedidentical()sweep over all four domains holds the bisector to the sum form’s own numbers.
tulpa 0.0.176
-
A DECLARED hyperparameter support is no longer overruled by the coordinate guess that sits under it, so a reported outer cell edge cannot leave the support its own axis named (gcol33/tulpa#377). gcol33/tulpa#369 gave
.nl_cell_edges()the axis’s domain and checked the mapped edge against that domain’sin_domainbefore accepting it. The chain did not stop at that check: a rejected edge fell through to theall(v > 0)log guess, which computes the SAME number on apositiveaxis and tests onlyis.finite(), so the weaker test accepted one line later exactly what the stronger one had just refused.Measured on the issue’s own node set,
c(1e-320, 1e-310, 1)declaredpositive:exp(log(1e-320) - 0.5 (log(1e-310) - log(1e-320)))underflows to exactly 0, and the fit reported a lower bound of5.93e-323for a quantity whose declared support isx > 0. From the other end of the double range,c(1, 1e300, .Machine$double.xmax)overflowed toInf, fell past the log guess to the LINEAR mirror, and reported-4.97e+299andInffor the same declared support. It is gcol33/tulpa#369’s defect one step down: there aunitaxis reported 1.0028 because the domain never reached the edge; here it reaches it and is overruled.THE PRECEDENCE, not a patch on the symptom. A declared domain that CONTAINS every coordinate is authoritative: its mirrored edge is taken when finite and in-domain, and otherwise the partition DECLINES to the extreme coordinates themselves – which that same containment test has already placed inside the support, and which are the conservative answer – recording
mirrored_edge_outside_domain. The guess is RESTRICTED, not removed: it runs where there is no declaration to contradict (no domain named,nodes_outside_declared_domain,unknown_domain), andcar_proper’srho_caron the adjacency eigenvalue interval keeps exactly the edge it had. A node set the declaration does not contain cannot be honoured by any edge at all – the edges bracket the coordinates – so that case is the guess’s by construction and says so rather than silently taking it.The reason field is gcol33/tulpa#293’s requirement and rides the hook gcol33/tulpa#357 already built:
.nl_cell_partition()reports the COORDINATE it settled on, so it is also where the decline lives, and both readers take it from the same return. It travels out through.nl_summary_quantile_read()and.nl_axis_quantiles()to the fit astheta_cell_edge_coord/theta_cell_edge_declined, one per axis, anddiagnostics()says which axis reports a conservative bound and why..NL_EDGE_DECLINEDis the closed vocabulary.WHAT MOVES AND WHAT DOES NOT, proven with
identical()rather than a tolerance (dev_notes/issue377/identity377.R,identity377.out): the pre-fixR/nested_laplace_moments.Ris sourced from git into its own environment over the tulpa namespace, and 22 node sets x 5 domain declarations x 4 weightings x 4 support kinds x 2 within-cell constructions are compared call for call. Of 110 (node set, domain) cases, 100 are byte-identical on the edges, on the box partition and on every quantile read; the 10 that move are exactly the ones whose partition reportsmirrored_edge_outside_domain, and no such case arises on an undeclared axis. Over 10324 node sets inside their declared domain – the fixtures plus a 20000-draw randomized sweep across all four domains – 174 produced an edge outside that domain before the fix and 0 after.The invariant asserted alongside gcol33/tulpa#369’s
unit/correlationregressions intest-nl-interval-support.Ris the general one: whenever every coordinate is inside a declared domain, both edges are finite and inside it too, for every entry of.NL_DOMAIN_TRANSFORM.test-within-cell-box-uniform.R’sboxes_do_not_tilefixture moves to theunboundeddeclaration, where an interior midpoint’s own sum still overflows before it is halved; declaredpositive, that same node set now has a finite in-support partition and the box read no longer declines on it.
tulpa 0.0.175
-
Box-uniform is promoted to a selectable WITHIN-CELL construction behind the existing
supporttaxonomy, and the position sensitivity it carries is reported rather than silently accepted (gcol33/tulpa#357). The outer grid’s cell weights say how much mass each cell holds; they do not say how it is spread inside the cell, and a reported quantile needs both."chord"– the shipped read – puts the cumulative MID-mass at each cell COORDINATE and interpolates between coordinates."box_uniform"puts the cumulative FULL mass at each cell EDGE and interpolates between edges: the same masses over the same boxes with the knots moved half a cell.control$within_cell = "box_uniform"selects it ontulpa_nested_laplace(),tulpa_nested_laplace_joint()and the ST front door.THE DEFAULT DOES NOT MOVE, and that is shown rather than asserted: seven fits spanning the single-block registry, the joint single-block driver, the multi-block tensor, CCD and adaptive paths, a bounded
bym2rhoaxis and the gcol33/tulpa#337 fixture were run against HEAD’sR/and against this tree with the same DLL, data and control lists, and every reportedtheta_median/theta_ci_lo/theta_ci_hi/theta_mean/theta_sd/log_marginal/weightsand per-block moment isidentical()(dev_notes/issue357/identity357.R,identity357.out).It is a second FIELD on each
.NL_SUPPORTentry rather than a fifth kind:outsideis a fact about what the producer left behind,withinis a caller’s choice about how to read it, and the two are orthogonal. Only a"density"support admits box-uniform – a CCD design is a moment rule, a locally refined grid’s replacement clouds sit inside one base cell so a Voronoi partition of the node set is not the design’s own boxes, and a posterior sample’s values are order statistics..nl_cell_edges()is now.nl_cell_partition()$edges, which also reports the COORDINATE the edges came out of, because the box read has to bisect the interior spacings in the same coordinate the outer half-cells were mirrored in or the partition it tiles the axis with is not one partition. That reconciles the construction with gcol33/tulpa#369: on aunitaxis the boxes are bisected in logit, so a BYM2 mixing weight with a node at 0.99972 (gcol33/tulpa#361 extendedbym2_rhoto six nodes including 0.999) stays inside (0, 1) where the log guess reaches 1.0015.dev_notes/issue337/recon.R’sslice_masses()STOPS when the boxes do not tile; a reported interval cannot take that behaviour, so a partition that is not finite and strictly increasing DECLINES with a recorded reason ("support_<kind>","single_node","boxes_do_not_tile","no_usable_node") and that axis falls back to the chord read on its own.THE PARTITION COMES FROM THE GRID AND THE MASSES FROM THE WEIGHTS, which is what gcol33/tulpa#337’s own “keep the masses, tile the axis” says and what
recon.Rdoes (axis_geometry()on the whole grid,slice_masses()on the positive weights). A cell whose integration weight underflows to exactly 0 still SITS on the axis, and its coordinate is what fixes its neighbour’s box edge; filtering the coordinates by weight – which is right for the chord read, whose knots ARE the positive-weight coordinates – shrinks that neighbour’s box to nothing. On the coarsest rung of the re-scored ladder (2 cells at 400 groups) the softmax underflows one of two cells on 43 of 150 seeds, so that filter was the difference between measuring the construction and measuring the chord read under another name. An empty interior box is a FLAT segment of the CDF: the quantile is located on the cumulative mass and evaluated inside the box it lands in, so an empty box is stepped over rather than interpolated across. Every nested path now stamps what its per-axis intervals were read off, not only the multi-block driver (gcol33/tulpa#357).
theta_interval_read/theta_interval_design_masswere filled by.joint_dispatch_multi()alone, so the single-block, joint single-block, registry and ST paths were indistinguishable from a fit that does not record it –helper-outer-grid-dump.R’s.ogd_support()fell back to.nl_node_support()for exactly that reason..nl_posterior_moments()and its two multi-block siblings stamp the pair, alongside the newwithin_cell_requested/theta_within_cell/theta_within_cell_declined..tulpa_interval_read_note()had no consumer at all; it and the new within-cell note are now attached bylaplace_diagnostics()and printed.A fit reports how coarse its own outer grid is:
outer_grid_cell_width/outer_grid_axis_sd/outer_grid_h_over_sd, per axis, both in that axis’s own coordinate (gcol33/tulpa#357). Every within-cell construction resolves an interval endpoint to within one cell, so part of the reported width and of its realized coverage is a property of where the grid fell rather than of the posterior, andh / sdis what governs how much. Below.nl_diag("grid_resolved") = 1the cells are narrower than the posterior they discretize and the two constructions converge; the 34-configuration census of the engine’s own default axes puts every one of them above it (minimum 1.01, median 4.25, maximum 18.06), so.tulpa_grid_resolution_note()names the coarsest axis and its ratio..nl_laplace_at_mode_sd_axis()takes the coordinate as an argument andreturn_log_sdbecomesreturn_u_sd, the SD in whatever coordinate the parabola was fit in – the branch existed and had no caller inR/.The re-score on current main, which is the open question gcol33/tulpa#357 was left with. Neither the box-position sweep’s blocker nor the ladder’s verdict moves. gcol33/tulpa#361 (default axis placement,
bym2_rhonode set), #369 (the outer cell edge’s coordinate) and #375 (the rail guard) all landed since the numbers were taken, but the fixture the comparison is measured on pins its own grid, so none of them reaches it. Repeated on the working tree through the SHIPPED.nl_axis_quantiles(within = ), 12 positions x 200 seeds at five and seven levels, the conditional 95% coverage across one cell reproduces the recorded numbers exactly: box-uniform 0.585 to 1.000 at five levels, SWING 0.4150, box-averaged 0.9033 against nominal 0.95; 0.895 to 0.995 at seven, swing 0.1000, averaged 0.9508. The chord read’s own conditional coverage at nominal 0.50 runs 0.655 to 0.950 (swing 0.2950) and 0.720 to 0.855 (0.1350), so both reads have the dependence and the chord read hides it behind width at 0.95. The promoted engine read reproduces the armdev_notes/issue337/recon.Rscored to 1.8e-15 on every one of the 4800 fits. The twelve-rung ladder transfers too: the paired CRPS favours box-uniform at 12 of 12 rungs, the folded PIT at 12 of 12, the 95% coverage is closer to nominal at 11 of 12 (the twelfth an exact tie, 0.9933 and 0.9067 both 0.0433 from nominal) and raw KS at 10 of 12, the two exceptions the samelevels = 2pair the issue records. The default flip stays the maintainer’s call and the issue stays open on it. Evidence:dev_notes/issue357/RESULTS.md,common357b.R,boxpos357b.R,coarse357b.R,analyse357b.R,identity357.R,unit357.R.
tulpa 0.0.174
- The skew correction’s centre band carries what it is measured to cost (gcol33/tulpa#376).
.NL_DIAG$centre_unreliable = 1.20was chosen in gcol33/tulpa#362 as the smallest cutoff declining nothing the correction was measured to help on, over four fixtures none of which could reach it. Re-scored over SEVEN fixtures and 4224 admitted coefficient-seeds – every one gated by the SHIPPED combined inner band, which the earlier sweep did not apply – the paired cost of the band is monotone in the cutoff and reaches zero only past the largest admitted centre measured (4.62):+0.302at 1.00,+0.270at 1.20,+0.191at 2.00,+0.089at 3.00,0at 5.00. No cutoff is protective on any fixture: all 13 fixture-coefficients score a negative paired gain (t -1.65 to -9.83) and the correction gets BETTER as the centre grows,-0.036at|m|in (1.2, 1.5] through-0.249at (3, 6]. The reason is in the mechanism:mandgamma_3are the same weighted sum at the first and third powers of the per-observation correlation, sorho_eff = sqrt(|gamma_3| / (2 |m|))bounds the strongest single correlation from below (exactly 1 on an intercept-only fit), and a large|m|with a small|gamma_3|is uniformly WEAK correlation – medianrho_eff0.72 to 0.84 on the fixtures that stay under the band and 0.086 to 0.116 on the three that reach past it. The constant is unchanged and the decline path is unchanged; its justification inR/settings.Rnow records the enlarged measurement, the derivation, and the price the band is being paid for.
tulpa 0.0.173
- A fit reaches the skew correction’s centre band from its own data, and the decline path is tested there (gcol33/tulpa#364). The band (
.NL_DIAG$centre_unreliable = 1.20, gcol33/tulpa#362) had never fired: the largest admitted centre on any measured fixture was 1.192 and the only test producingcentre_unreliabledid it by overwritinginner_skew_gamma1. The applied centre ism_i = cross / (2 sigma_i)– thegamma_3cancels exactly betweengamma_1andgamma_3 / 2– so withrho_ijthe Gaussian correlation betweeneta_jand the probed coordinate,m_iandgamma_3(i)are the same weighted sum at the first and third powers ofrho, and reaching the centre band inside the shape band needs many weakly correlated COHERENT terms rather than a few strong ones. A small-group Poisson random-effect fit supplies both:l''' = -muis uniformly negative so every term adds, and many small groups send|m| ~ sqrt(G)up while|gamma_3| ~ G^-1/2falls. On 150 groups of 2 the band declines 178 of 300 intercept-seeds, every one with the shape band and the combined inner band both admitting.
tulpa 0.0.172
- The rail detector’s materiality guard is read on a node-count-free scale (gcol33/tulpa#375).
.nl_axis_rail()required the boundary node to carry at least.NL_RECENTER$edge_mass = 0.5of its axis’s marginal weight, a per-NODE share against a constant that does not know the node count – so the same posterior read at more nodes carried less on any one of them and a LONGER axis became a WEAKER detector, exactly as #361 lengthened the defaultbym2_rhospan the rescue was meant to compose with. The reference is now what a FLAT marginal would put on that node:edge_mass_mult = 2againstm * w[k], which is 1 for a flat marginal at any node count and is the retired 0.5 share at the four nodes it was tuned on. On one BYM2 posterior held past a fixed span and read at 4 / 5 / 6 / 8 / 12 / 20 nodes, the rail now fires on 12 / 11 / 10 / 10 / 10 / 10 of 20 seeds – the count whose marginal is maximal at the top node at all – against 12 / 10 / 6 / 3 / 0 / 0 before. A marginal flat to within its own noise, and one that merely tilts 1.6x across the span, still decline at every node count. Paired fixed-truth interval coverage over 240 fits at four truths, both arms the shipped engine of their own library: 41 replicates move and 199 are byte-identical, coverage gains 10 / 20 / 30 and loses 0 / 0 / 1 at the 95 / 80 / 50% levels, and the 95% coverage atrho = 0.9999goes 0.000 to 0.167 where the detector had never fired at all.
tulpa 0.0.171
The default BYM2 mixing-weight axis reaches 0.999 (gcol33/tulpa#361). On a BOUNDED axis a coarse span and a short span are not interchangeable: the
outside = "extend"read mirrors the outer cell edge in the axis’s own logit coordinate, so the four-node default could not report an upper bound aboveplogis(logit(0.95) + 0.5 (logit(0.95) - logit(0.8))) = 0.97642whatever the data said, and no node count moves that. Measured over 800 fits per candidate node set – four fixed truths, 200 seeds, every arm the shipped engine with only.NL_GRID$bym2_rhoreplaced – 95% coverage atrho = 0.99goes 0.555 to 0.955 and at 0.97 goes 0.820 to 0.950, mean |coverage - nominal| over the four truths goes 0.140 to 0.011 at 95%, 0.189 to 0.088 at 80% and 0.081 to 0.099 at 50%, and mean |median bias| goes 0.058 to 0.041, at a mean 95% width of 0.494 against 0.487. The 0.555 the four-node axis managed atrho = 0.99WAS the fraction the placement rescue moved; the six-node axis contains its own mode on every one of those 800 fits, so it also spends no refit. A five-node candidate stopping at 0.99 was scored too and is not what shipped: it takes 95% coverage most of the way (0.900 atrho = 0.99) while its 50% interval covers 0 of 200 there.Three joint drivers restated a default axis’s nodes as a literal instead of reading
.NL_GRID, so a change to the table did not reach them: bym2’srho_gridin.joint_backendsand.joint_multi_axis_grid, and ar1’srho_gridin the latter. All three read.nl_grid_axis()now. The joint paths’ proper-CAR correlation nodes were duplicated between the same two files and are a.NL_GRIDentry of their own (joint_car_rho), separate fromcar_rhobecause the joint drivers do not computerho_boundsand lay fixed nodes on (0, 1) rather than the adjacency eigenvalue interval. It is deliberately not bound to a.NL_FAMILY_AXESfield, since that table is what.nl_axis_matches_default()reads and binding it would reclassify a caller’s identical nodes from a pin to a default.control$auto_recentertakes"always"(gcol33/tulpa#361 checklist items 1 and 2): recentre every movable default axis on its posterior mode whatever the fit did, rather than only an axis that rails. The recentred axis ismode +/- 2.5 sdover 5 nodes, so it resolves the posterior ath / sd = 1.25by construction against a census median of 3.9 on the fixed spans. Measured over 200 fixed-truth seeds on each of a 100-region ICAR chain, a 144-cell ICAR lattice and a 100-region BYM2, mean |coverage - nominal| over the four (config, axis) rows goes 0.036 to 0.026 at the 95% level, 0.176 to 0.078 at 80% and 0.268 to 0.160 at 50%, at 0.24 to 0.81 times the 95% interval width – the fixed arm covers exactly 1.000 at nominal 0.95 on three of the four rows – and medianh / sdgoes 3.09 / 2.92 / 1.99 to 1.29 / 1.29 / 1.02. It is NOT the default: it is a second full grid solve plus the finite-difference mode/Hessian stencil on every fit, measured at 1.88x / 1.94x / 2.23x the wall clock, and.NL_REGISTRY_AXIS_FIELDnames movable axes for icar and bym2 only, so as a default it would move two families and leave eleven on their fixed spans.tulpa_nested_laplace_joint()andfit_st_nested()refuse the value rather than accept it and ignore it – their rescues trigger on the whole grid’s collapsed-edge regime, not on a per-axis rail, so"always"has no measured meaning there.
tulpa 0.0.170
-
A joint fit is reproducible bit for bit at any thread count (gcol33/tulpa#374).
reduction(+:)leaves the order its per-thread private copies are combined in unspecified, and libgomp combines them as each thread finishes; floating-point addition is not associative, so the data log-likelihood landed an ulp or two apart from one run to the next and the inner Newton loop’s convergence test and line search amplified that into ~1e-13 onlog_marginal. A capture of ten fits and 90 fields atn_threads = 4, taken twice from the same build with every seed fixed, moved 15 of the 90.tulpa_parallel_sum()(src/omp_threads.h) now cuts the range intoteamcontiguous chunks by index arithmetic, sums each chunk left to right into its own slot, and adds the slots in chunk order. Nothing about the answer is left to the runtime: not the combination order, and not the partition either, so a fit reproduces itself even where the runtime hands back a smaller team than the one requested. The parallel loop runs over the chunks rather than the observations, which keeps the combinedparallel forconstruct. Because gcol33/tulpa#373 had already funnelled every one of these sums through that one helper, the change is one function and reaches the joint and non-joint paths together.What this buys is reproducibility AT a thread count, not across thread counts: chunking the range imposes its own association, so a fit at
n_threads = 4still agrees with the same fit at one thread only to floating-point tolerance (measured at 6e-14 onlog_marginalover four families), which is the invariant?tulpa_nested_laplace_jointnow states.The one-thread route is untouched: it keeps its plain loop and allocates nothing, and the same 90 fields are
identical()before and after the change. The same capture taken twice atn_threads = 4after it moves 0 of 90. Cost, with the three reduction shapes alternated inside one process so they share the machine’s load: 1.00 / 1.03 / 1.02 times the old cost at 48 observations with a team of 2 / 4 / 8, and indistinguishable at 960 and 12000 observations. Rows where all three arms run the same code move by up to 17% in the same capture, so the 3% is at the edge of what the instrument resolves.
tulpa 0.0.169
-
A one-thread OpenMP region is no longer entered at all on any loop reached per objective evaluation, per Newton iteration or per Gibbs sweep (gcol33/tulpa#373). gcol33/tulpa#365 measured that a region guarded by
if(n_threads > 1)still enters libgomp when the clause is false, and that the entry cost 7.6 microseconds per objective evaluation on the joint path; the two loops fixed there each grew their own runtime branch. That policy is now one pair of helpers inomp_threads.h,tulpa_parallel_for()andtulpa_parallel_sum(), which take a plain loop at a team of one, and the two #365 loops were moved onto them so there is a single place the rule lives.Sites moved onto the helpers: the single-arm spec eta assembly (
compute_eta_spec, which had no guard at all and so entered a one-thread region unconditionally on every non-joint nested-Laplace objective evaluation), bothcompute_total_log_likoverloads for built-in families,tulpa_linalg::matvec(reached per leapfrog step throughprecompute_generic_fixed_eta), the five per-observation regions inpg_gibbs_core_stepand the ten per-sweep regions across the four Polya-Gamma samplers, and the NNGP non-centered reverse sweep (nngp_nc_backward), whose per-thread workspace slot and accumulators are now parameters of a single-sourced row body rather than captures.A serialised region body runs in index order, which is what the plain loop does, so both routes agree bit for bit,
reduction(+:)included. Verified over 292 captured fields and 68968 doubles – non-joint nested Laplace at three families and three field types, a corrected-integrated-Laplace fit,cpp_laplace_fit, six Polya-Gamma samplers, generic NUTS, NNGP exact NUTS and single-arm joint fits – at one thread and at four, and again underOMP_NUM_THREADS=1. Every field isidentical()before and after except a handful on the joint fitter atn_threads = 4, which a control run showed is not reproducible run to run under a FIXED build in either direction: anum_threads(4)reduction combines its per-thread partial sums in an unspecified order.Cost off a corrected-integrated-Laplace M ladder on the non-joint fitter (7-cell sigma grid, one thread,
variant = "qmc", two runs per arm): the per-objective-evaluation fixed cost falls 3.39 -> 1.31 us binomial, 3.66 -> 1.34 us poisson and 3.25 -> 1.39 us gaussian, and the per-observation cost falls about 8% with it, since a plain loop keeps the eta assembly in the caller’s frame instead of an outlined region body. Per auxiliary draw at N = 48: 9.96 -> 7.27, 8.97 -> 6.58 and 9.91 -> 7.77 us.
tulpa 0.0.168
-
The eta-independent part of a built-in family’s log-density is now evaluated once per observation per FIT rather than once per observation per objective evaluation (gcol33/tulpa#372). Three separate causes measured as one number, because all three sit in the shared penalized objective and are therefore paid by every line-search evaluation of every inner Newton solve in the engine: binomial spent
R::lchoose(n, y), threelgammafncalls, inside the density; poisson spentlgammafn(y + 1); gaussian matched no special branch and fell through the ladder toparse_family_link, which builds aFamilyLinkcarrying twostd::strings.BuiltinFamilyResponse::prepare()(andFamilyLogLik::prepare()) resolves the family code once into aFamilyKindplus a parsedFamilyLink, and fills a per-observation vector of the constant.log_lik_for_familyandgrad_hess_for_familygain an entry point taking both, so the objective adds a looked-up double where it calledlgamma. Each ladder is written once, in a core that thestd::stringoverload forwards into after an allocation-free classification, so a caller still passing a string does exactly the work it did before and no path regresses. A response that was never prepared reports so and takes the string route, so a construction site that forgets the call loses the saving, never the constant.The split densities are
kernel + constin that order at every call site, so a caller holding a precomputed constant reproduces them bit for bit. Measured over 23 fits (ten families x single-arm and joint, plus three corrected-integrated-Laplace fits) and 253 captured fields – log-marginals, modes, per-cell modes and Hessians, grid weights, coefficients, intervals,vcov,logLik– every fieldidentical()before and after, no tolerance.Cost, read off a corrected-integrated-Laplace M ladder (7-cell sigma grid, one thread,
variant = "qmc"), the two arms alternated so they share the machine’s load: per-observation cost of the objective 0.190 -> 0.149 us binomial, 0.230 -> 0.138 us gaussian, 0.189 -> 0.118 us poisson. Per auxiliary draw at N = 48 that is 9.9 -> 6.4, 11.3 -> 6.4 and 10.3 -> 5.7 us.
tulpa 0.0.167
- The single-arm nested-Laplace loop reports its log-marginal at the Newton mode (gcol33/tulpa#371).
laplace_newton_solve_llappliedcenter_effects_fn(x)after the loop converged and then re-evaluated eta, the Hessian, the data log-likelihood and the log-prior at the shifted point, so what it reported was a Laplace expansion at a non-stationary point. The fold each caller applies preserves eta, which is why the data term never moved; the log-prior is what does. A proper field prior is not shift-invariant, and even an intrinsic one moves the beta ridge through the coefficient the fold lands in, so the size of the error tracks how far the field is from shift-invariant:score_maxat the reported point measured 1.6e-05 on an RW1 fixture and 5.7e-02 on an AR1 one, both ~1e-14 now. That propagated into every cell weight, since the outer grid weights cells bysoftmax(log_marginal). The ordering is now the joint loops’: log-marginal and every inner-layer probe at the uncentered iterate,center_effects_fnlast and over the reported mode alone, with the correction presenting its draws through the fold. Measured on the RW1 fixture, the reported log-marginal moves 2.6e-09 and the weights 7.4e-11. -
tests/testthat/test-cila.Rholds the non-joint gaussian arbiter to the joint file’s 1e-9, where it read 3.4e-07 before, and pinsscore_max.
tulpa 0.0.166
A reported hyperparameter interval stays inside the quantity’s own support (gcol33/tulpa#369). gcol33/tulpa#353 gave the density read
outside = "extend", so the interval reaches the outer CELL EDGE rather than clamping at the extreme node, and.nl_cell_edges()mirrored the extreme half-spacing in a coordinate GUESSED from the values – log whenever they were all positive, which a proportion is. A BYM2 mixing weight whose top default node is 0.95 therefore got an upper edge of 1.0353, and the fit reported a 97.5% bound above 1 for a quantity that lives in (0, 1) and is singular at both ends: 1.0028 on the default axis, 1.0012 after gcol33/tulpa#361 moves it. The edge is now mirrored in the quantity’s OWN coordinate, and that coordinate is not a new notion of a domain:.NL_DOMAIN_TRANSFORMalready definespositive/unit/correlation/unbounded, and.joint_axis_domains()already maps every outer axis onto one of them from the SAME per-axis registry the outer Pareto-k unconstrains with – it was simply consulted only on the moment-rule read. It is now supplied whatever the support, on the registry, multi-block, spatial-field and axis-summary paths;hyper_axis_spec()’s declaredboundsname the domain on the generictulpa_hyper_grid()door.positivereproduces the log mirroring exactly andunboundedthe linear one, so a scale axis is bit-identical; an axis whose support the registry will not name (car_proper’srho_caron the adjacency eigenvalue interval) keeps the guess rather than acquiring an invented support, and a node set that contradicts the domain the caller named falls back to it too.A fit says which of its outer axes do not contain their own posterior mode, even when nothing can be done about it (gcol33/tulpa#370). The placement rescue read its per-family scope table FIRST and returned before stamping anything, so a
car_properfit – whoserho_carsupport the transform registry will not guess, which blocks placement for every axis of the fit including its ordinary positive-scaletau– was indistinguishable from a fit the rescue never applied to. The rail REPORT ($outer_grid_railed_axes, gcol33/tulpa#361) is now taken before anything can decline, since it reads stored weights and needs neither curvature nor a scope entry, and the decline names the blocking axis ("unguessable_axis: rho_car") or says the family carries none the rescue is built for ("family_out_of_scope"). Both are READ BACK rather than only recorded, per gcol33/tulpa#346:diagnostics()carriesgrid_railed_axes/grid_placement/grid_recentred_axes/grid_placement_declined, andprint()shows a one-line reading beside the gcol33/tulpa#276 regime note.A corrected fit carries ONE grid weighting (gcol33/tulpa#367). The corrected per-cell masses are what
fit$drawsis pooled from, so they are the fit’s grid posterior and are now reported asfit$weights/fit$log_marginal, withfit$weights_sourcenaming the read. The pre-correction pair is kept asfit$cila$laplace$weights/$log_marginal, a name that cannot be mistaken for the reported one, andfit$cila$cell_weights/$cell_log_marginalare the adopted vectors THEMSELVES –identical(), not equal to a tolerance – so a caller pairing the two cannot pair them wrongly. Each fitter’s own hyperparameter-summary tail is re-run on the adopted read, sotheta_meanand the axis quantiles describe the weights the fit reports rather than the ones it replaced. A grid whose cells did not all produce a usable particle set is read conditional on the cells that remain, the gcol33/tulpa#342 convention, with$cila$retained_massrecording the original share.The correction reaches
tulpa_nested_laplace(), the single-block non-joint fitter (gcol33/tulpa#368).control$cilathreads throughlaplace_newton_solve_ll,spec_inner_solve,run_multi_block_nested_laplaceand everycpp_nested_laplace_*entry, so icar / bym2 / car_proper / nngp / hsgp / rw1 / rw2 / ar1 / the ST variants and the multi-block driver all carry it. That loop centres BEFORE the correction runs and re-evaluates its factor at the centred point, so it proposes fromresult.modeand presents its draws unchanged where the joint loops apply a centring fold.A sparsely factorized cell is corrected instead of declining (gcol33/tulpa#366). A draw with covariance
A^-1isP' L^-T eps, which the live CHOLMOD factor supplies directly through its ownLtandPtsolves – no refactorization, no dense triangle, and a whole chunk of draws per call. This opens the correction to SPDE, NNGP, ICAR at scale, MCAR, HSGP-MO and latent factors, and to any fit above the sparse dispatch threshold. Only an LL’ factor carries that square root; a simplicial LDL’ fallback declines by name ("sparse_factor_not_ll") rather than drawing from the wrong covariance.Every objective evaluation in the joint Newton loop was paying about 7.6 microseconds of OpenMP region entry it could not use (gcol33/tulpa#365). The eta assembly and the data log-likelihood both guarded their parallel region with
if(n_threads > 1), which serialises the body but still enters libgomp; at one thread they now take a plain loop, in the same index order, so the numbers are unchanged. Measured on a 48-observation binomial joint fit: the correction’s marginal cost per auxiliary draw falls from 26.80 to 19.23 microseconds, and the line search pays the same saving on every fit. What remains is 1.9 microseconds of the correction’s own machinery (Sobol net, normal quantile, triangular solve, bookkeeping), flat in the data size, plus 0.345 microseconds per observation inside the shared per-observation likelihood – 93% of the cost at that size, and not reachable by batching the density call.
tulpa 0.0.165
- The inner-Laplace skew correction’s CENTRE is banded, not only its shape (gcol33/tulpa#362). The reported quantile is
mu_i + sigma_i {gamma_1 + gamma_3 / 2 + w(z_p; gamma_3)}, so the correction RELOCATES the marginal by that centre andcornish_fisher_eligible()bounded only the reshaping:|gamma_3|was banded and the centre was accepted at any size as long as it was finite. Since gcol33/tulpa#354 gave the centre a location term the admitted displacement reached about one standard error on the engine’s own fixtures with nothing noticing. One predicate now owns the whole band decision (cornish_fisher_in_band()), a coefficient pastcentre_unreliablereports the Gaussian quantiles and recordscentre_unreliable, and the eligibility recordsummary()/confint()consume is READ from that same predicate rather than re-derived beside it. - The cutoff is measured. 3800 prior-predictive fits across four fixtures with an exact reference, each candidate scored as the PAIRED CRPS difference between the banded and the unbanded correction on the same fits, so the number is what the band costs: +0.0280 at 0.95, +0.0145 at 1.00, +0.0020 at 1.10, +0.0007 at 1.15 and 0 at 1.20, where the largest centre the shape band admits anywhere (1.192) still fits. Binned by centre magnitude the correction recovers 71% to 92% of the distance from the Laplace centre to the exact marginal mean and gains MOST in the largest bins, so every tighter band gives up the coefficients it helps most on. Two fixtures were built for the sweep – rare-event binomial-logit designs carrying a slope, which is what makes
var(eta_j | x_i)and sogamma_1nonzero – because the existing fixtures cannot reach a large centre (dev_notes/issue362/RESULTS.md). -
control$skew_correctstill defaults toFALSE. Under the banded gate the gcol33/tulpa#354 flip is unchanged: the centre band declines nothing on either fixture the default-on decision rests on (paired CRPS t -1.895 on the rare-event intercept, -3.77 / -3.20 on the small-group RE fit, against gcol33/tulpa#302’s +3.54 / +6.12 / +4.64).
tulpa 0.0.164
A default outer axis that does not contain its own posterior mode is detected per axis and moved (gcol33/tulpa#361). The auto-recenter (gcol33/tulpa#290) triggered on the WHOLE grid collapsing onto one cell –
ess_gridover the tensor – so on a crossed grid a second axis carrying spread hid an axis hard against its own boundary; it also named one movable axis per family and placed only in thelogcoordinate. A BYM2rhorailed against its 0.95 ceiling was therefore detected (pareto_k_grid_edge_axesnames it) and then left there, with the fit recordinggrid_not_collapsedon a grid that had collapsed. An axis is now railed when its OWN marginal – the one.nl_axis_quantiles()reports that axis’s median and interval from – is maximal at an endpoint and that endpoint carries at least half the marginal weight, which for a unimodal marginal is exactly the statement that the mode is at or beyond the boundary. Every axis a family lists in.NL_REGISTRY_AXIS_FIELDis movable on its own rail, in whichever coordinate the engine’s own transform registry gives it (logfor a scale,logit01for a mixing weight, mapped back into the OPEN interval). Every registry fit records$outer_grid_railed_axeswhether or not a rescue could move the axis, so a span the engine may not touch – a user pin above all – says so instead of silently integrating a tail. Provenance is unchanged and still decides: a pinned axis is reported and never moved. MEASURED over gcol33/tulpa#357’s 34-configuration census, read as the shipped engine reports it: railed axes 2 -> 0, recentred fits 2 -> 6, reported median and interval moving on 3 rows. On a fixed-truth BYM2 sweep (100 regions,rhoin {0.70, 0.90, 0.97, 0.99}, 60 seeds, both arms the shipped engine of their own build) mean absolute deviation from nominal coverage goes 0.229 -> 0.075 at the 50% level and 0.046 -> 0.038 at 95%, the median’s bias shrinks at every truth, and the 95% interval narrows 0.548 -> 0.508 at equal coverage; atrho = 0.99the 50% interval went from containing the truth on 3 of 60 replicates to 32 of 60. Against a reference read on an axis that contains the posterior, the moved axis is nearer on 84 of 84 replicates whose read changed.dev_notes/issue361/RESULTS.md.-
Corrected integrated Laplace joins the engine as a second INNER-LAYER DEBIAS, alongside the gcol33/tulpa#304 / #306 subspace debias (gcol33/tulpa#351, after Lai, Margossian & Sheldon, arXiv:2605.20345). The two differ in what they select, not in what they approximate: the subspace debias picks the coordinates the inner bands flagged and runs exact Metropolis on them, carrying the rest at their Gaussian conditional, while this one selects nothing. At every outer cell it draws
n_pointspoints from the WHOLE inner Gaussian, weights each by the exact joint density it came from, and reports the weighted particles; the cell marginal and the latent posterior both converge to the exact ones as the effort grows, son_pointsis the only dial.control$cilaontulpa_nested_laplace_joint(), off by default.MEASURED on gcol33/tulpa#341’s fixture B – a small-group Bernoulli GLMM over a 7-point hyperparameter grid, which is the shape of a real consumer fit – at 400 prior-predictive replicates scored by
tests/testthat/helper-sbc.Ragainst a machine-accurate quadrature. Paired CRPS against that quadrature on the intercept:arm CRPS - exact t SBC p_unif shipped nested read +0.0447 4.44 1.6e-03 + subspace debias +0.0191 3.17 0.017 + cila, M = 1024 +0.00090 1.10 0.15 + cila, M = 4096 +0.000033 0.05 0.80 The slope behaves the same way (subspace +0.0138 at t = 2.38, cila at M = 1024 +0.00027 at t = 0.30), and the hyperparameter, already correct before, stays correct. All three variants sit inside
k < 0.7on every one of the 400 replicates, median 0.42 / 0.51 / 0.51 forqmc/is/rqmc.COST on the same fixture, medians over 8 seeds at 3 repetitions: M = 1024 is 10.2x a plain fit and M = 4096 is 37.6x, against the subspace debias’s 26.1x. The correction is one extra pass over the settled grid plus M evaluations of the Newton loop’s own penalized objective per cell, and that closure takes one latent vector at a time, so the M evaluations dominate and are what a batched density interface would address.
The auxiliary point set is a NATIVE Sobol’ net – Joe & Kuo direction numbers for 1024 dimensions compiled in, Gray-code recurrence, no new dependency (
src/sobol.h). Its low-discrepancy property is asserted against Monte Carlo rather than assumed: on a smooth integrand the QMC error slope beats the plain MC slope by 0.51 / 0.51 / 0.35 / 0.21 at d = 1 / 2 / 14 / 18, and the first2^m - 1points fill every elementary interval of every one-dimensional projection exactly once. Past the tabulated dimension the request falls back to iid draws and recordsfallback = "sobol_dim_exceeded", so a fit never reports a variant it did not run.The correction’s randomness is ENGINE-OWNED, not R’s. Requesting it leaves every other posterior quantity on the fit bit-for-bit unchanged, the outer grid stays parallel-integrable (the subspace debias forces a serial grid precisely because it draws from R), and the reported k-hat does not flap with the seed;
control$cila$seedselects an independent realization of the randomized-QMC shifts.Below 512 points per cell the request is REFUSED rather than served. Proposition 5’s recovered posterior is literally a weighted particle set, so a truth outside the particle range gets PIT exactly 0 or 1 – gcol33/tulpa#341 measured every variant leaving the simultaneous calibration band at M = 64 and the iid one still leaving it at M = 256.
Every decline says why. A cell whose inner solve factorized SPARSELY reports
"sparse_factor_unavailable", since drawing from the inner Gaussian needs a triangular solve against the factor and the sparse solver exposes only the full solve; the R attach reports"no_fixed_effects","no_grid_weights","redispatch_failed", or the first cell’s own reason where nothing usable came back.
tulpa 0.0.163
auto_grid()coerces in place instead of throughas.numeric(), so a MATRIX-valued axis survives the mark (gcol33/tulpa#360). Two families store their axis as a matrix of pre-paired coordinates –mcar/miid’slogchol_grid,tgmrf’stheta_grid_built– and flattening one destroys the axis it is declaring, which left those families with no way to declare a default at all: a wrapper package computing its own coarselogchol_gridhad to choose between handing the driver a flattened vector and leaving the axis undeclared, where the provenance predicate reads it as a user pin.dim()anddimnames()now come through, and the marked matrix is carried by every consumer of the mark –.nl_grid_provenance(), the pinned/default predicate, the gcol33/tulpa#352 consumption check and.nl_block_axis_grid().A grid the COPY SPEC carries and the copy resolver does not read is refused or recorded rather than dropped in silence (gcol33/tulpaObs#192). gcol33/tulpa#352 walks the prior blocks; the copy spec is the other object the multi-block joint driver resolves an axis off, and nothing walked it.
.resolve_one_copy_spec()readsarm,blockandalpha_grid, and a numeric field beyond those now takes the same provenance split every unread axis takes: PINNED is refused, naming the field and the (sigma, alpha) parameterization the copy path integrates; anauto_grid()-marked or engine-default value is dropped and recorded on$axis_fields_dropped. The reported case was asigma_pos_gridfrom the retired (sigma_occ, sigma_pos) parameterization, pinned by the user and answered with a bit-identicallog_marginal..NL_COPY_SPEC_FIELDSis the read-field set, held against the resolver’s own body by a source lint.
tulpa 0.0.162
A node set that is an MCMC SAMPLE is named as one, and no longer borrows a cell partition’s outer half-cell (gcol33/tulpa#358).
support = "density"covered two geometries: a tensor grid, whose values are cell representatives of a partition with known spacing and whose extreme cell’s mass reaches half a spacing past its coordinate, andtulpa_re_cov_gibbs()’s equal-weight posterior draws, whose values are order statistics with an unknown tail beyond the largest of them. gcol33/tulpa#353’soutside = "extend"is derived for the first and only harmless on the second, and a caller reading the tag could not tell which it had. The kinds are now one table,.NL_SUPPORT, naming each one’s outer-edge policy exactly once –densityandmixedextend,sampleclamps, andmoment_rulecarries none because it never reaches the quantile read – with.NL_SUPPORT_KINDSthe single vocabulary every formal reads, so adding a kind is one entry rather than four hand-written lists. The producer names itself:.nl_node_support()mapsintegration = "sample"to the new kind, the Gibbs fit carries that tag, andtulpa_re_cov_nested()’s hand-written copy of the same producer-to-kind decision now calls the shared reader. Nothing reported moves – the policies differ only outside[1 / (2 n), 1 - 1 / (2 n)]and the backends report 0.025 / 0.5 / 0.975 – whichtests/testthat/test-support-sample.Rshows rather than asserts, on the dispatcher, on the whole derived-Sigmasummary table, and on a real Gibbs fit whose reported posterior is identical to the pre-change read recomputed on its own draws. A misspelled kind hard-errors instead of being translated.The
tulpa.kdiag.captureaperture publishes the importance log-ratios the REPORTED outer Pareto-k-hat was fitted on (gcol33/tulpa#356). It was written by.nested_is_pareto_k(), the scoring primitive, which runs once per CANDIDATE proposal – and the joint dispatch scores several per reported k-hat (each moment-matching pass, then the grid mixture, then the skew-normal rescue), keeping whichever gives the lowest shape. A candidate scored after the winner left its ratios in the aperture, so an external check refitting the GPD oncap$lrlanded on a different number from the fit’s own: a spatialoccu_cover()fit reported 0.7714820579 against 1.197156391 recomputed, withloo::psis()agreeing with the recomputation. The reported value was the correct one throughout – it is exactlytulpa_psis()on the grid-moment pass’s ratios, the proposal the dispatch selected, while the second moment-matching pass scored 1.197156391, lost the comparison, and was the last thing written. The primitive now returns its ratios onlrand publishes nothing; each backend publishes once, from the proposal it selected, through the single.kdiag_capture()helper, alongside thetail_pointsrequest that resolves the GPD tail size. Sotulpa_psis(cap$lr, tail_points = cap$tail_points)$pareto_kreproducesfit$pareto_kexactly on every backend that reports an outer k-hat –tulpa_nested_laplace(),tulpa_nested_laplace_joint()single-block and multi-block,fit_spde(),tulpa_re_cov_nested()– whichtests/testthat/test-kdiag-capture.Rpins. The defect was confined to the joint path, the only one that scores a proposal it can discard. No reported k-hat moves: the aperture consumes no RNG and the scoring is untouched.
tulpa 0.0.161
The inner-Laplace LOCATION term
gamma_1is computed, and the skew correction is applied about the centre it defines (gcol33/tulpa#354). Rue, Martino & Chopin (2009) Sec 3.2.3 carry two terms; this engine had only the cubic one, andsrc/inner_laplace_skew.h’s SCOPE note called the other blocked because its likelihood-curvature perturbation is diagonal only in the paper’s augmented representation. It is not blocked. Expanding the denominator log-determinant along the same conditional-mean curve givesgamma_1(i) = (1/2)[(1/sigma_i) sum_j l'''_j s_j u_{i,j} - gamma_3(i)], whose one new quantity iss_j = [A Sigma A']_jj, the marginal variance of the linear predictor – index-independent, so one pass per fit, and read off exact affineetadifferences plus the full solves the live factor already serves rather than by assemblingA. Verified against a central difference of the log-determinant on an independently written model (3e-11 relative) and against the paper’s own eq. (21) line 1 atA = Iover a coupled GMRF prior (1e-15, term for term). Bounded byINNER_ETA_VAR_MAX_SOLVES; a coupled or multi-process unit declines withmulti_eta_unit, never with a zero.The
control$skew_correctmarginal is now a NET GAIN over the uncorrected Laplace, which gcol33/tulpa#346 measured it not to be. The cause was the centre, not the location term alone:z + (g/6)(z^2 - 1)is the quantile function of a MEAN-ZERO variate, while RMC eq. (22) has meangamma_1 + gamma_3 / 2, so placing the reshaped variate at the Laplace mode assertedgamma_1 = -gamma_3/2. On the #346 fixturegamma_1is identically 0 and the whole missing centre was the mean the cubic term itself induces. Measured on the same 400 prior-predictive replicates: paired CRPS-0.01643att = -1.89against the uncorrected Laplace, essentially all of the-0.01662the exact posterior achieves, where the previous read scored+0.00775att = +3.54; SBC uniformity0.0833 -> 0.0329against an exact0.0290, with the PIT re-entering the simultaneous band; endpoint error reduction56.6% -> 77.4%. The correction also beats theshift onlycontrol arm, so the cubic term earns its place once the centre is right. The gate’s pin moved fromexpect_gt(dlt("skew_cf"), 0)toexpect_lt(..., 0), and the old read is kept as ano centrecontrol that reproduces the loss exactly.gamma_1is required: a coefficient without one declines withgamma1_not_computablerather than being corrected about an assumed centre. The default staysFALSE.
tulpa 0.0.160
- A fit whose supplied grid axis went unused says so through its own readers (gcol33/tulpa#355). gcol33/tulpa#352 records the drop on
$axis_fields_droppedwhen the axis was an engine default or anauto_grid()the resolved path does not read; nothing read it back, so such a fit reported exactly as one whose axis was integrated.diagnostic_summary()now carries the record and one sentence per dropped field – the field, the block, the path named with the same label the #352 refusal uses, and the axis that path integrated instead;print()carries the compactunused axis fieldsline; andsummary()carries the record as anaxis_fields_droppedattribute alongsideinterval_source/retained_mass. All three go through one reader (.tulpa_axis_dropped()) and one formatter, so a fit that used every axis it was given is silent in all three and the ordinary fit is unchanged. This is the read half of the gcol33/tulpa#293 rule that a silent-disable path needs a reason field.
tulpa 0.0.159
The hyperparameter-axis interval reaches the outer cells’ own edges instead of stopping at the extreme grid coordinate (gcol33/tulpa#353).
.nl_wtd_quantile()places the cumulative mid-masscumsum(w) - w/2at each cell coordinate, which leavesw_1 / 2of the mass below the first coordinate andw_n / 2above the last; the clamp had nowhere to put it and returned the extreme coordinate for every probability there. A grid’s values are cell representatives with known spacing, not order statistics, so the missing support is the outer cells’ own half-width:outside = "extend"mirrors it, inlogon a positive axis and in the value otherwise, and adensitysupport dispatches to it. Nothing inside[values[1], values[n]]moves, so a fit whose reported probabilities never reached the clamp is byte-identical. Measured over a prior-predictive experiment whose grid tiles the prior, the shipped read covered every truth inside its support and none outside it (cover95 + pit_extreme = 1.0000exactly at 4, 5 and 7 levels): 95% coverage goes from 0.743 to 0.989 against nominal 0.95, the PIT atom at 0 and 1 from 0.257 to 0, the paired CRPS improves at every resolution, and the read’s order of convergence to the exact posterior rises from 1 to 2. It does not make the read calibrated – the clamp was partly cancelling a separate interior over-width – anddev_notes/issue353/RESULTS.mdreports the instruments that come out against the change alongside those that support it.-
The coupled joint Newton takes a step from an indefinite start Hessian, and a fit that stalls reports the cause that stopped it (gcol33/tulpa#344). The occupancy mixture’s dark-cell term
log(psi (1-p)^J + 1 - psi)is not concave in(eta_occ, eta_det), so a data set with few detections has a negative curvature direction at the Newton startx = 0. The SPARSE joint loop conditions the Hessian there (joint_pd_step_solve); the DENSE one – which every small coupled fit runs on,n_xbelow the sparse threshold – did not, andpd_modenever even reached it, socontrol$hessian = "psd"was inert on that path. A plain Cholesky of an indefinite matrix returns a non-finite step, the line search accepts nothing, and the loop reported its START VECTOR as the mode: on the engine’s own coupled fixture, 32.5% of prior-predictive draws atprior_sd = 2, n_visits = 4returned(0, 0)withscore_maxafter 5000 iterations equal to its value after 1, and neither the budget nor the tolerance was binding.The two PD policies now live in one place (
src/joint_pd_step.h) and both loops are backends for them:pd_lm_escalate()is the smallest diagonal load making the factorization succeed (Nocedal & Wright, Numerical Optimization 2nd ed., Alg. 3.3),pd_eigen_clamp_solve()the clamped spectrum. WithHalready PD the first attempt succeeds, no load is added, and the step IS the plain Newton step, so every fit that factorized before is unchanged – the dense and sparse loops agree to 4e-16 on the mode and exactly onlog_marginalfor both an indefinite and a PD start. Measured over the issue’s own 80-replicate sweep, non-convergence goes from 32.5 / 22.5 / 13.8 / 7.5 / 3.8% (the five affected configurations) to 0% in all ten, each converged fit landing within 1.9e-06 of an independently optimized mode in a median of 4-7 Newton iterations.Three reports named the wrong layer for it, the gcol33/tulpa#293 shape one layer over.
inner_skew_declinedsaidbackend_unsupported, which is false – the same backend computesgamma_3on every converged solve of the same fixture;grid_fixed_declinedsaidblock_not_extracted, a retention step downstream of the real cause. Both now saynot_converged, which is also a reason in the shared Pareto-k vocabulary, and the three probe drivers settle it in one place (.inner_skew_attach_probe()) rather than each writing its own tail.$modesis kept – it is the solver’s record of where it stopped, and the warm-start chain and the refinement passes read it – but the per-cell-mode AVERAGE every coefficient report falls back on now reads only cells that reached a mode, so a stalled fit reportsNAwithinterval_declined = "not_converged"instead of its own start vector as an estimate. A non-PD Hessian at the returned point also withholds the stored precision and the fixed-effect block, whose inverse is not a covariance there.New
test-coupled-indefinite-start.Rcarries the sparse-detection fixture the suite lacked: every coupled fixture sat atb_occ = 0.2, b_det = -0.5, where the start Hessian is positive definite, which is why nothing reached the path. It establishes that it reaches the regime –lambda_min < -5read off the R log posterior, with the old setting asserted PD as the control – rather than reaching it by luck.
tulpa 0.0.158
A grid axis the resolved path cannot read is refused or recorded, never dropped in silence (gcol33/tulpa#352). A
sigma_gridon an icar block reached the multi-block driver, which integratestau_grid, and the field neither took effect nor produced any diagnostic – so a fixture believing it pinned a 2-node axis per block ran a 9-node default on a different parameterization, at an order of magnitude more cells. The two paths keep their parameterizations: the registry integrates the intrinsic families over the PRECISION and the joint areal backends over the field SD, which.NL_FAMILY_AXESalready recorded as deliberate. What changes is that the binding table now also says which fields each PATH reads (.NL_PATH_AXES,R/settings.R), so the check is one pass over the whole table rather than a rule per family, and a family whose drivers agree needs no entry at all. Both nested-Laplace front doors run it on the incoming prior, resolving the path per block (registry / single-block joint areal / copy). The verdict splits on the same provenance question every auto-recenter rescue asks: a PINNED unread axis is an error naming the field, the block, the path, the axis that path integrates and – for the one pair the engine itself converts, icar’stau = 1 / sigma^2– how to write the same grid in the axis that path reads; an axis that IS an engine default carries nothing a pin would add, so it is dropped and the drop is recorded on the fit as$axis_fields_droppedrather than left invisible (gcol33/tulpa#293). The refusal is symmetric: atau_gridhanded to the single-block joint areal backend was ignored the same way and is refused too. Six fixtures in this repo were in that position and now pin the axis they meant (test-inner-skew.R,test-checkpoint-universal.R,test-nested-laplace-spatial-recovery.R,test-nested-laplace-joint-svc-areal.R,test-svc-fold.R,test-audit-228-239.R). Newtest-nl-axis-consumption.R, which measures the declared conversion against a fit rather than asserting it from the source.The posterior-SBC driver splits the truth-draw and replicate RNG streams itself (gcol33/tulpa#350).
recov_posterior_sbc()derives two seeds and handsdraw_thetaone andsimulatethe other, so the obviousset.seed(seed)at the top of each callback is the CORRECT fixture and none of them carries an offset any more. Under the previous contract both callbacks got the same seed, and a fixture writing the obvious thing drew the replicate’s group effects and residuals from the very uniforms that producedtheta'– notp(y | theta'), and a non-uniform PIT with nothing wrong in the inference under test. The driver applies the660000Loffset every fixture used to apply itself, so the seeds a fixture sees did not move, and the #339 measurement indev_notes/issue339/was re-run under it.The #302 Cornish-Fisher marginal is scored over the whole posterior, and gated on the combined inner band (gcol33/tulpa#346). Its acceptance test scored total absolute error of the 2.5% / 97.5% quantiles, which is structurally blind to most of what the correction does: at a symmetric level pair the term
sigma (gamma_3 / 6) (z_p^2 - 1)takes the same value at both ends, so there the correction is a pure location shift of the interval with its width unchanged. Section 4 oftest-inner-skew-correction.Radds a whole-marginal gate on the gcol33/tulpa#335 harness (recov_sbc(),sbc_report(),sbc_crps_compare()), with ashift onlycontrol arm that separates the shift from the reshaping. Measured over 400 prior-predictive replicates read off one solve per seed: the endpoint score improves 56.6% while the paired CRPS against the exact posterior is a net loss of +0.00775 at t = +3.54, and the shift alone scores -0.0145, which is what a Gaussian at the exact mean and standard deviation achieves. The correction stays off by default; the missing piece is the location term, not the cubic one..nl_skew_correction_attach()now gates each coefficient on the combined inner band – the worse ofgamma_3’s band and the inner importance k-hat’s, the resolution gcol33/tulpa#304’s selector already reads – and records the k-hat, the combined band, areasonfrom a closed vocabulary and the whole-fit reliability verdict alongside the eligibility.The per-cell fixed-effect retention on the joint tier indexes the same grid as the weights, on every grid (gcol33/tulpa#345).
.nested_fixed_moments()reads$weights,$grid_modesand$grid_hessiansas three views of one grid, and a caller pairingweights[k]withgrid_modes[[k]]makes the same assumption with no guard at all..joint_attach_grid_fixed()built the pair cell by cell and wroteNULLinto the slot of a cell whose inner solve returned no usable block – which on an R list REMOVES the element instead of leaving it empty. An interior empty cell was invisible, since the next write re-extended the list; a TRAILING one left the pair one shorter than the grid, so the marginalizer’s length check declined and every fixed-effect estimate, standard error and interval on the fit came backNA. The slot is now skipped rather than assigned, which is what the marginalizer’s zero-weight skip and the gcol33/tulpa#342 renormalization were already written for. Adaptive refinement is what makes the trailing case reachable: it appends cells at the end of the grid, and an appended cell whose solve returns a non-finite marginal carries zero weight and no block.A joint fit with no coefficient table says why it has none (gcol33/tulpa#293’s lesson applied here). A retention holding no cell the weights put mass on – every block unusable, or an all-
NAweight vector – now recordsgrid_fixed_declined = "no_weighted_cell_block"instead of handing back a pair.nested_fixed_moments()silently returnsNULLon withgrid_fixed_declinedreportingNA.
tulpa 0.0.157
-
tulpa_posterior_draws()serves a single-block nested-Laplace fit (gcol33/tulpa#347). The posterior of such a fit IS the outer-grid Gaussian mixture.nested_fixed_moments()returns, so a draw is the same two steps the joint backend already runs – pick a cell by weight, then draw that cell’s Gaussian – and the two backends now meet at one allocator,.nl_mixture_draw(). What a draw covers differs by what the backend retained and travels on the result as ascopeattribute: the joint fit keeps each cell’s sparse precision and so samples the full latent vector ("latent"), while the single-block fit inverts each cell’s precision into the marginal fixed-effect block and releases the precision, so it samples that block ("fixed"). A grid that dropped a positive-weight cell samples the cells that remain and carries the sameretained_massprovenanceconfint()does (gcol33/tulpa#342). Measured against the retained mixture at 4e5 draws: the sampled mean and covariance reproduce.nested_fixed_moments()to 2.9e-04 and 3.1e-03 marginal-SD units, and the sampled 2.5% / 97.5% quantiles reproduceconfint()’s mixture-CDF bounds to 3.3e-03 marginal SDs. -
diagnostics()reports the reliability band on a fit that carries no draws (gcol33/tulpa#348). Draws are what the per-parameter mean / sd / ESS / rhat columns andn_drawsare computed from; the outer PSIS k-hat and its regime, the grid quadrature ESS, the inner-Laplacegamma_3and importance k-hat, and the combined verdict are all read off the fit and need none. The guard was on the wrong quantity, so a default single-block nested fit whosepareto_khad cleared the escalation threshold reportedNULL. The band is now returned with an empty body,n_draws = NA, and aparam_table_declinedreason that points attulpa_posterior_draws();print()shows the band and says the columns need a posterior sample. A fit with neither draws nor any band quantity (a plain Laplace fit, no outer grid to score) still returnsNULL, now saying both halves of why. -
posterior_sample()andtulpa_draws_array()name what a draws-less fit carries instead of returning a bareNULL(gcol33/tulpa#349). The note gives the backend, the posterior representation on the fit – the outer-grid mixture over the latent vector or over the fixed effects, per-cell modes with no retained covariance, a Laplace mode and precision, or posterior moments – and the accessor that samples it where one exists. The internal probes that read draws with a designed fallback (theposteriorinterop conversion, k-fold, power-scaling) go through the silent accessors, so a fallback does not narrate itself.
tulpa 0.0.156
control$max_grid_cellssets the multi-block outer-grid cell ceiling (gcol33/tulpa#343), defaulting to the 2048 that was hard-wired. The cap is a cost guard – each cell is one inner Newton solve – and a good default, but a converged tensor REFERENCE grid is the case a caller knowingly wants the expensive run: 4 axes at 7 levels is 2401 cells, so no cubic reference above 6 levels per axis could be built on a 4-axis fit, and the outer-grid line’s reference fits had to lift the package internal in-process. Both enforcement sites – the multi-block nested-Laplace dispatch and the joint multi-block dispatch, until now independent copies of the same comparison – go through one.nl_check_grid_cap()reading one resolved value, carried for the fit on a scoped option so every re-dispatch inside it (the k-hat re-evaluations, the refinement passes) enforces the caller’s ceiling. The error keeps each site’s remedy for the accidental blow-up and names the override for the deliberate run; the>50cell warning is unchanged.Posterior SBC joins the prior-predictive harness: calibration checked CONDITIONAL on an observed data set rather than averaged over the prior (gcol33/tulpa#339, after Sailynoja, Schmitt, Buerkner & Vehtari, Statistics and Computing 36:78 2026). The truth is drawn from
pi(theta | y_obs), a replicate frompi(y | theta'), and the PIT taken under the augmented posteriorpi(theta | y, y_obs)– ordinary SBC with the posterior aty_obsin the role of the prior, so #335’s simultaneous band, folded read, randomized discrete PIT and CRPS closed forms all carry over andtruth = "posterior_draw"is a proper-score experiment. It also scores what prior SBC on this engine cannot: the nested door puts no prior on the fixed effects, and an improper prior cannot be drawn from.The construction is now TESTED, not only used (
test-posterior-sbc.R). Run with the exact posterior at both stages on a gaussian random-intercept fixture it must be uniform, and both premises have negative controls that fail when broken – fitting the replicate alone instead of pooling takes the hyperparameter outside the band, and re-observing the observed regions with their own effects takes the intercept, the hyperparameter and the joint log-likelihood outside it while the slope survives.The marginal likelihood behind a log-likelihood rank is adaptive Gauss-Hermite, recentred at each region’s integrand mode, so the rank arm is available for every family rather than only the gaussian one that has a closed form. A fixed rule scaled by sigma places its nodes by the prior and stalls at 3.1e-03 by 64 nodes once beta is a couple of units from its estimate; the adaptive rule is exact on the gaussian closed form at any node count from 2 and converges geometrically elsewhere. Nodes from the Golub-Welsch eigendecomposition of the Hermite recurrence, no dependency added.
MEASURED,
dev_notes/issue339/: 15 configurations at N = 1000 includingoccu_cover, a pre-registered family-wise verdict rule, and a power curve (80% power at roughly a 10% over-dispersion or a 0.14-SD location bias; measured false-positive rate 0.0117 against a nominal 0.05). The shipped reliability band and the calibration check disagree in BOTH directions:pois_40, which the band callsreliable (both layers good)on an outer k-hat of 0.196, fails at p = 2.3e-13;binom_30, whose outer k-hat of 1.413 is well past the escalation threshold, passes at p = 0.17. The band is a screen, not a verdict.Single-sourced the response law:
recov_draw_y()in the recovery sweep now delegates tosbc_draw_y(), which sits beside its own log density, so a family cannot be simulated under one parameterization and scored under another. Bit-identical across all six families.
tulpa 0.0.155
- Simulation-based calibration and a strictly proper score join the fixed-truth recovery sweeps as posterior arbiters (gcol33/tulpa#335). Coverage at one or two nominal levels reads one or two points of the marginal CDF, so it cannot say whether an approximation is biased, over- or under-dispersed, or asymmetric, and #336 hit its resolution floor: over 200 paired seeds the mixture read and the collapsed-Gaussian read moved 0 or 1 coverage trials.
recov_sbc()draws the truth from the prior instead of fixing it and reads the whole PIT ECDF;sbc_crps()scores calibration and sharpness together. Both live intests/testthat/helper-sbc.R, test-side and tier 3, alongside the sweeps rather than replacing them. - The bands are SIMULTANEOUS and calibrated against an exact crossing probability, not simulated and not pointwise. At n = 100 a band holding each order statistic at 95% holds all of them together at 0.4471; the calibrated one needs a pointwise level of 0.0022. The recursion is validated against closed forms (
P(all U <= t) = t^n), brute-force simulation, measured simultaneous coverage, and the published Kolmogorov critical value, which its constant-width member reproduces to four figures. - Discrete PITs are randomized within their atom, so a rank read and a continuous marginal share one uniform reference and one band. Reading
rank / n_refagainst a continuous uniform is a silent miscalibration and is kept as a negative control. - CRPS is scoped in code, not only in prose: it is a proper POSTERIOR score only in a prior-predictive experiment, so
recov_sbc()records which experiment produced a result andsbc_crps_compare()refuses to rank a fixed-truth one, where the CRPS-optimal forecast is a point mass at the truth. - MEASURED, 2000 prior-predictive replicates of a gaussian random-intercept fixture whose exact posterior is available in closed form (the engine’s read tracks it to 1.3e-05 in the PIT). SBC separates the #336 mixture read from the collapsed Gaussian of the same two moments – simultaneous p = 0.44 against 1.9e-04 raw and 2.5e-04 folded on the intercept – where 200-seed coverage could not. The paired CRPS does NOT: t = 1.01, because the score is dominated by the first two moments and the two reads share those exactly. The three deliberately broken arms (SD mis-scaled either way, and the #332 residual-scale crossing) all land outside the band, and the crossing shows on the joint log-likelihood rank at ks = 0.17 while its intercept marginal stays inside.
dev_notes/issue335/RESULTS.md.
tulpa 0.0.154
-
.nested_fixed_moments()renormalizes the grid weights over the cells that retained a fixed-effect block (gcol33/tulpa#342). It normalized over the whole grid and then summed over the retained cells only, so a cell that carried positive weight and no retained block left the mean shrunk toward the origin by exactly the dropped mass, and the covariance misstated to match. A dropped cell now gives the moments of a grid that never held it. - What such a fit reports is the posterior CONDITIONAL ON THE RETAINED CELLS. The dropped mass is gone and no reweighting brings it back, so the incompleteness stays on the report:
masson the moments, and aretained_massattribute onconfint()/summary(), give the original retained share of the grid weight – 1 on a complete grid, below 1 on a repaired one. A reader tells the two apart from the fit alone. - The #336 mixture read serves a repaired grid. It declined there because the moments were formed under a weighting it could not reproduce; both are now the renormalized weights, so the components and the moments describe one posterior and
interval_sourcereports"mixture_cdf". The reachability is unchanged from #342: a healthy fit retains every cell, so no reported number moves on one.
tulpa 0.0.153
- A nested-Laplace fit’s fixed-effect credible bounds are the quantiles of the Gaussian mixture its outer grid defines,
sum_k w_k N(mu_kj, V_kjj), instead of being read off the single Gaussian matching that mixture’s first two moments (gcol33/tulpa#336). A mean and a variance are linear functionals and survive the collapse; a quantile is not and does not.estimate,std.error,vcov()and the debias selectors are the same moments they were – only the quantile step changed, so any difference in an interval is attributable to the marginal read and to nothing else. - The construction is not new to the package.
.nl_gauss_mixture_summary()already inverted this CDF by bisection, andranef()already reported the per-group posterior with it; the fixed effects on the same fit collapsed first..nested_fixed_moments()now returns the components alongside the two moments and.nl_fixed_interval()chooses the read, so there is one mixture summarizer rather than a second one written for the fixed effects. - Verified against three arbiters outside the quantile path: the defining CDF assembled by hand from the fit’s own retained cells; 400000 draws from
tulpa_posterior_draws(), which samples a cell by weight and then that cell’s Gaussian and so realizes the same mixture; and the reduction cases, where a Gaussian-equivalent mixture (one cell, or several with identical component means and variances at any weights) reproduces the previous interval exactly. A merely symmetric mixture is not one of those cases and is free to differ in width –0.5 N(-2, 1) + 0.5 N(2, 1)has a 95% interval near+/- 3.64where matching its moments gives+/- 1.96 sqrt(5). - The #302 inner-skew correction keeps its own read and is not composed with this one. Its
gamma_3is computed by re-dispatching the kernel at the fitted MAP cell, so a fit retains one value per coefficient and not one per cell, and the composed marginalsum_k w_k F^CF_kjis not identified by retained state. The two address different non-Gaussianities: this one across cells, #302 within the MAP cell.confint()andsummary()carryinterval_source("mixture_cdf"/"gaussian_moment"/"skew_map_cell") andinterval_declined, so a fit says which read produced its bounds and why. -
recov_sweep()scores all three reads off one solve per seed, so the collapsed Gaussian remains the baseline the #302 correction is judged against and the mixture read is a third paired arm. Measured over 60 seeds at 2 coefficients each: coverage does not move on the six-family configurations (binomial 114/120 both arms, poisson 111/120 both, 0 discordant seeds of 120 in each), and on the skewed small-group fixture the mixture arm covers 119/120 against 118/120 on 1 discordant seed. Coverage therefore cannot separate the two reads at any affordable seed count here, the same power limit gcol33/tulpa#331 hit on the fixed effects, so the width is what is measurable: the mixture interval is wider by 4.0e-04 and 4.2e-04 on average on those two configurations and by 5.4e-03 on the skewed one, positive on every seed. This lands as a consistency correction whose measured effect is small, not as a calibration gain – the collapsed Gaussian is a lossy compression of a posterior the engine had already computed, and the two agreed closely wherever the grid was near-Gaussian in the fixed-effect marginal.
tulpa 0.0.152
- Neither free outer cell rule is promoted, decided by coverage rather than by grid accuracy (gcol33/tulpa#331). 200 seeds at each of three resolutions, all four arms post-processed off one solve per seed so the comparison is paired. The placement rule contracts the atom set instead of correcting it – its
sigma_1width ratio is 0.4776 / 0.5840 / 0.4465, flat in resolution rather than decaying, because each atom moves a share of its own box and that share does not shrink as the boxes do – and covers 128 / 129 / 118 of 200 against the shipped 200 / 200 / 200. The mass rule never moves asigma_1trial and widens that interval by 4.1% to 9.8%. - The decisive number is that the placement rule is CLOSER to the fine grid and still loses: per-seed distance to the same seed’s 1296-cell width is 0.3576 against the shipped 0.4194, and it gives up 72 seeds of coverage. The two error directions do not cost the same, so a rule selected on distance to a finer grid can be selected against on calibration. That is what the issue was opened to establish. On the fixed effects coverage cannot separate the arms at any affordable N (1 discordant seed of 200 on the intercept, 0 on the slope), so only the width is a measurement there; the seed count was chosen from an exact sign-test power calculation rather than from the deficit the pre-gcol33/tulpa#332 fixture appeared to carry.
- A two-dimensional pre-refinement descriptor does not select a per-cell correction either (gcol33/tulpa#333), measured by one-cell intervention on the global read over 2520 cells from 216 fits. The mass and location descriptors do separate once the box truncation is applied (Spearman 0.8496 against 0.9495 in the unbounded metric), but the best label per quadrant buys +0.0000 over the single best label overall in 23 of 30 scored combinations, and where the direction is strongest it runs backwards. The issue’s central hypothesis is refuted: the four- and five-level grids occupy statistically indistinguishable regions of the plane.
- Those three negative results have one mechanism and the header now says so once rather than leaving it to be rediscovered: posterior mass dominates every level of this grid. A second, structural reason is recorded alongside it – the outer read is a weighted quantile, which couples cells through ordering and cumulative mass, so summed per-cell improvements exceed the whole-grid improvement by 8.48x to 68.58x and at five levels carry opposite signs. A perfect per-cell classifier would still not compose into a grid rule.
- A declined outer cell-mass or barycentre axis says which gate it fell at (gcol33/tulpa#334):
boundary,no_factor,cancellationand, for the barycentre,out_of_box, tallied per cell on both sides of the refinement gate.n_axes_declinedis unchanged and rides alongside. The reasons are taken at the same constants that already gate the refusals rather than re-derived afterwards, and each one is triggered by a real fixture rather than shipped as an unobserved string –out_of_boxturning out to be unreachable through the closed form by construction, since its error bound sits under the in-box slack.
tulpa 0.0.151
- The gaussian recovery fixture no longer fits at a quarter of its own residual variance (gcol33/tulpa#332).
recov_draw_y()drewrnorm(N, 0, sqrt(phi))– the residual-VARIANCE conventiontulpa()takes and converts at the boundary – while every fitter in the file goes to a direct door, which readsphias the residual SD.RESID_SDnow names the gaussian configurations in the door’s own parameterization, leaving the data-generating process byte-identical, and a new test pins the convention against the engine through the closed formse = phi / sqrt(Sxx)at threephivalues across both doors, so a future crossing fails loudly instead of silently rescaling. A suite-wide sweep found this fixture was the only crossing, in either direction. - Every gaussian coverage number in the recovery file is re-measured at 150 seeds. The local-CCD fixture’s slope covers 146/150 at nominal 0.95, against the 120/150 the crossed fixture produced; the interval is wider by 1.4079, against the sqrt(2) a halved variance predicts. The intercept is nearly unmoved (144/150), its posterior variance being the integrated RE-SD grid’s, of which the residual contributes under one percent, while the slope is a within-group contrast whose variance is entirely residual. Pooled coverage is 0.9667 refined against 0.9667 unrefined at 0.95. The refinement’s paired conclusion is unchanged; the rates it was read against were not the model’s.
- The local-CCD coverage gate’s per-coefficient floor moves 0.70 -> 0.85 (gcol33/tulpa#325). A floor set beneath an unexplained deficit cannot fail for the reason it was built to catch.
recov_fit_joint_phi_crossed()keeps the defect runnable and reproduces the old table exactly, so the new floor is demonstrated to catch it: a 40-seed draw at the crossed rate fails with probability 0.714, against 0.0010 spurious at the corrected rate. - The deficit was attributed by measurement before its cause was found, and the ladder is recorded because it is reusable. The outer grid was exonerated by replaying the same 150 fits under a materially different weight rule – one redistributing 0.6746 of the total outer weight – which moved the slope’s standard error by 0.04% and not one trial of 150. The inner Laplace was exonerated by
gamma_3being exactly 0 on every one of 300 gaussian fits (the log-likelihood is quadratic in eta, so the inner Laplace IS the conditional posterior) at realized importance efficiency 1.0000. - A cell’s mass can be integrated over its own box instead of read off a midpoint atom (gcol33/tulpa#326). The local quadratic in the offset from the cell’s coordinate is fitted from neighbour values the base grid already holds, so the estimator costs no inner solves; with a diagonal Hessian the box integral factorises into a product of
pnormdifferences, verified to 6.1e-14 againstintegrate()and an independent Simpson rule. The decline is per AXIS rather than per cell – the integral over a bounded box is finite at either sign of the curvature – so a cell convex on one axis keeps the exact factor on the others. - An outer cell’s atom can be placed at its own mass barycentre rather than at its coordinate (gcol33/tulpa#327), the doubly-truncated normal mean of the same local quadratic, verified to 2.4e-11 against
integrate(). Hyperparameter axes only: a fixed effect is marginalized from each cell’s own inner solve, so moving that cell asks whatbetawould have been at the moved point. - Neither rule changes a shipped fit. Measured at zero fits against a 1728-cell reference of the same model, correcting the mass alone makes the interval about five times more accurate and the location worse (median error 0.1158 -> 0.2028), while the location rule alone improves all three parts of the read at four levels and the pair wins at five. The ranking reverses with grid resolution, so which rule ships is a coverage question rather than a grid-accuracy one (gcol33/tulpa#331).
- The free box-mass ratio does NOT rank the refinement budget, measured over 2509 refined cells (gcol33/tulpa#328). It predicts the realized surplus well (sign agreement 0.9518, Spearman 0.940), but
|M_1 - M_0|factorizes asw_c |exp(r_c) - 1|and the two factors are separated by orders of magnitude, so the product is ordered by the integration weight before any predictor is consulted: the incumbent ranking captures 0.8173 of the realized correction in its top decile against an oracle’s 0.8231, and weighting it by the predictor moves it away. The two readings of “realized correction” order cells oppositely (Spearman -0.700). Recorded with the derivation, and with the scale hierarchy it is conditional on. The cells where the predictor fails are not the cellsmisfitflags (correlation -0.015), so shape and mass remain orthogonal. - The outer-grid harness replays the FIXED-EFFECT read offline (gcol33/tulpa#329).
outer_grid_dump()carries the per-cell modes and Hessians andouter_grid_rebuild_fixed()hands them to.nested_fixed_moments()itself, so a candidate weight rule is scored against a coefficient’s interval and not only a hyperparameter’s, at zero fits and through the engine’s own marginalizer. A fit that declined retention says so rather than returning NULL. -
outer_grid_weight_report()scores every part of a read against the noise floor (gcol33/tulpa#330). The verdict set is derived from the same part list the difference and the floor are built from, so a part cannot gain a difference and a floor without gaining a verdict – which is how the median, the one part the gcol33/tulpa#326 rule moves, had none.
tulpa 0.0.150
- The cross-cell estimator asymmetry in local-CCD refinement is documented as a known and instrumented property rather than left reading as an expected consequence (gcol33/tulpa#319). Weight conservation is the statement on a flat integrand; the integrand refinement is selected for is peaked, so a refined cell’s mass is re-estimated by a 25-node rule while its unrefined siblings keep the base grid’s single atom, and the refined cell’s share rises. The header now says so, points at
log_mass_ratioas the per-cell reading, and records what the asymmetry costs in coverage rather than in grid-internal metrics.
tulpa 0.0.149
- A CI-coverage gate now reaches local-CCD refinement (gcol33/tulpa#320). The joint recovery fixture builds one
iidblock, so it has one latent axis and.joint_local_ccd_engage()– which requires four – declined silently: every number measured on this path over four rounds came from grid dumps rather than from a coverage sweep.recov_fit_joint_local_ccd()is that sweep at four crossed groupings on a deliberately coarse four-level base grid, run twice on the same seeds with the refinement on and off, and a companion test asserts the refinement actually engaged rather than trusting the settings. - Measured, 150 seeds x 2 coefficients per level: pooled coverage 0.8800 with refinement on against 0.8800 off at nominal 0.95, and 0.7300 against 0.7267 at 0.80, against a standard error of 0.0126. What refinement moves is the width, in the direction gcol33/tulpa#319 predicts – the intercept’s mean 95% interval is 2.9% narrower – but that buys 0 of 300 trials at 0.95 and 1 at 0.80. Where it pays is the hyperparameter axis: the
sigma_1interval is more than fourfold sharper (0.2330 against 1.0590) with half the median bias (0.0245 against 0.0592) while still covering 149 of 150 against a nominal 0.95. These rates are superseded: they were measured on a fixture that fitted its gaussian arms at a quarter of their own residual variance (gcol33/tulpa#332). The conclusion held on re-measurement; the numbers did not. See 0.0.151. -
recov_sweep()takes the nominal level to judge at (z/level, defaulting to the 95% every existing gate runs at) and reports mean intervalwidth, so a second level and the width effect are read through the shared harness rather than a parallel one.
tulpa 0.0.148
- A locally refined cell now reports the ratio of the two estimates of its own mass it already carries (gcol33/tulpa#323): the coarse atom
Delta_c exp(ell_c)the base grid gave it against the refined cloudDelta_c sum_j delta_j exp(ell_j)its own nodes give it, aslog_mass_ratio = logSumExp_j(log delta_j + ell_j - ell_c)over the full node set including the centre. That comparison is the embedded-rule local error indicator classical adaptive cubature uses to decide whether a subdivided region’s estimate is comparable to its unrefined siblings’, and this grid evaluates both rules already, so it costs no inner solve. Recorded per cell onlocal_ccd_infowith the two masses it is formed from andmax_node_weight, the share the single largest node takes of its own cell’s refined mass, for refined and declined cells alike. - The centring score gains its curvature-scaled form (gcol33/tulpa#324). A central composite design identifies a full quadratic exactly, so the whitened Hessian sits in the same coefficient vector the gradient does, and
mode_gain = 0.5 g' (-H)^-1 gis the nats the quadratic model predicts the log-density gains by moving the expansion centre to the cell’s own fitted peak. The plain gradient normoffsetsays nothing about how sharply the log-marginal falls away in the direction it points, so two cells with the same norm and curvature an order of magnitude apart are displaced by very different amounts; the scaled form is comparable across cells and the unscaled one is kept for the cases the scaled one cannot be formed in.mode_gaindeclines to NA where-His not positive definite: a cell whose fitted quadratic is not concave has no interior peak to be displaced from. - The three scores are orthogonal and none of them gates:
misfitis non-quadraticity,offset/mode_gainare off-centring,log_mass_ratiois mass correction, andskew_maxreadsmisfitand nothing else.
tulpa 0.0.147
- The outer-grid dump / rebuild harness is in the test suite (
tests/testthat/helper-outer-grid-dump.R, gcol33/tulpa#322). A candidate construction for the outer integration weights is pure post-processing of a fit that already ran, soouter_grid_dump()writes the grid state (joint_grid,log_marginal,dnode,weight_kind, the axis tags and domains, the support the read was taken off, and the summary the fit shipped) andouter_grid_rebuild()re-reads the per-axis summary under any weight vector. The read goes through the engine’s own.nl_axis_quantiles()->.nl_summary_quantile(), never a second copy of it, and the round-trip assertion intest-outer-grid-dump.R– rebuild-with-own-weights equals the shipped read – is what makes an offline difference attributable to the weights alone. It holds exactly (0.000e+00) on a tensor grid, a global CCD and a locally refined grid. -
outer_grid_noise_floor()estimates the scale below which a difference between two reads is not resolved by the grid, as the spread of the read under a weight-preserving coarsening of each axis’s own atom set (consecutive atoms merged at their weighted mean carrying their summed weight). Total mass and each group’s first moment are exactly preserved, so only resolution is removed. On a one-axis dump with a Gaussian outer log-marginal the floor bounds the read’s true error against the closed-form quantiles at every resolution from 9 to 81 levels. - The joint multi-block driver records
dnodeon the fit beside the integration weights it was folded into. Recovering it afterwards is a division byexp(log_marginal), which loses the scale and is undefined on a cell whose inner solve returned no finite marginal.
tulpa 0.0.146
- The local-CCD cubic misfit score now reports the whitened gradient its own least-squares fit already estimated, and the refinement carries it per cell as
offset/offset_declinedonlocal_ccd_info(gcol33/tulpa#321). The score puts the linear term in its own design columns, so a cell whose outer log-marginal is a perfectly good quadratic that simply is not centred on the cell fits exactly and scores near zero however steep the gradient across it: passing it certifies that the design can represent the cell, not that the cell’s coordinate is a representative point of it.offsetis the standardized displacement of the cell’s own peak from the cell’s coordinate, in units of the marginal spread the whitening used, and nothing gates on it – a gradient across the cell is a cross-cell estimator question, orthogonal to the local shapeskew_maxreads.
tulpa 0.0.145
-
Local CCD refinement of the joint outer grid now keeps a refined cell’s node cloud only where the cell’s own outer log-marginal is close to the quadratic the cloud was placed from, and puts the cell back as its own mass atom where it is not (gcol33/tulpa#318). The refinement was a large win on an outer target that is quadratic in the transformed coordinate (summed absolute endpoint error against closed-form axis quantiles 7.3118 against 24.1142 for not refining, 48 configurations of an equicorrelated Gaussian) and a net loss on a skewed one (26.2467 against 23.1874 over 27 configurations of a Gaussian copula with Gamma(2) marginals, 42.8578 against 38.1609 over 48).
The mechanism is the cell’s own non-quadraticity, and it is measurable from the design rather than inferred. A central composite design identifies a full quadratic exactly, so the least-squares residual of the nodes’ measured log-marginals against intercept + gradient + Hessian in the whitened offset is the part of the cell the design cannot represent; the nodes are evaluated whatever the residual says, so the score costs no inner solve.
.joint_local_ccd_misfit()reports it as a standardized cubic magnitude on the same convention the inner-Laplacegamma_3uses, and on the Gaussian target it is identically zero in all 48 configurations while on every skewed family it exceeds 0.08.The threshold is
.NL_DIAG$gamma3_ok(0.5), one number for the inner band and this gate because both are a standardized third-order departure from the Gaussian the approximation was placed from. Where it belongs was measured: on an eight-family ladder (the Gaussian target plus Gaussian copulas with Gamma(1), (2), (4), (8), (16), (32) and (64) marginals, 48 configurations each bar 32 for Gamma(1)), 0.5 is the only threshold on the ladder 0.01 to 2 that improves or ties every family. Per family, gated against refining unconditionally: 7.3118 / 7.3118 on the Gaussian, then 36.3139 / 37.2185, 40.8376 / 42.8578, 31.9596 / 34.3650, 34.1830 / 36.8361, 44.4462 / 47.2740, 61.8421 / 63.1718 and 83.2492 / 83.2492 down the ladder; pooled 340.1435 against 352.2843, with 365.0831 for never refining. Lower thresholds score better pooled (0.175 gives 337.4142) by regressing on the two least skewed families. 399 of 1196 candidate cells are declined across the ladder, none of them on the Gaussian target.On the four-axis two-block fixture the gate is measurably neutral, which is what it has to be: over the 20 distinct refined configurations the summed absolute endpoint error against the converged
m = 13reference is 2.11091 gated against 2.12847 unconditional and 3.12915 unrefined, and the largest single-configuration movement is 0.00696 against that reference’s own 0.01716 endpoint noise floor. Its per-cell scores there run 0.053 to 3.822, and the design-dominatedm = 3configuration reads 0.126, so it keeps its cloud.control$local_ccd$skew_maxoverrides the threshold;$local_ccd_infogainsmisfit,skew_max,cells_declined,misfit_declinedandn_cells_declined, and a refinement whose every candidate declined leaves a plain tensor grid that reportstheta_interval_read = "density". -
A locally CCD-refined joint outer grid now says what its per-axis hyperparameter intervals were read off, and how much of the support underneath them is a quadrature design rather than posterior mass (gcol33/tulpa#317). It is the one node set carrying both kinds at once – a carried-over base cell holds the mass of its own cell, a refined cell’s replacement cloud holds a partition-of-unity share of its cell’s mass placed at the design’s radius – and it reports
integration = "grid", so.nl_node_support()read it as a homogeneous density grid and nothing downstream could tell. The support is now named"mixed", the fit carriestheta_interval_readandtheta_interval_design_mass, anddiagnostic_summary()surfaces the pair.The reported numbers are unchanged, on the measurement. Three replacement reads were scored against the converged
m = 13tensor reference (28561 cells) of the four-axis two-block fixture, whose own noise floor is 0.01716 on the endpoints and 0.03853 on the widths: splitting the read into a mass CDF plus a per-cell moment-matched Gaussian, collapsing each refined cell’s design block to one atom at its own weighted mean, and the gcol33/tulpa#308 moment read. Summed absolute endpoint error over seven base grids (m = 3 ... 9,design_mass0.930 down to 0.092) is 0.63446 for the shipped weighted quantile, 0.73159 for the collapse, 0.74464 for the split and 1.20402 for the moment read, against 1.00289 for not refining at all; over fourteen further configurations reached by varyinglocal_ccd$max_cellson the same fixture, 1.65810 / 1.86035 / 1.89038 / 2.82062. The collapse is ahead at the singledesign_mass = 0.930grid (0.12022 against 0.16409) and behind at every lower one, which is the pattern a fix has to avoid. On analytic outer targets whose axis quantiles are closed form the same ordering holds: atdesign_mass >= 0.5on an equicorrelated Gaussian (19 configurations) 3.18024 for the quantile against 15.72792 for the collapse and 17.80607 for not refining. The moment read wins on that target because a Gaussian moment match is its exact family there; on a Gaussian copula with Gamma marginals, correlated the same way, it is the worst of the four (39.93958 against 26.24674 over 27 configurations, both measured with the refinement engaged unconditionally; under the gcol33/tulpa#318 gate above the quantile scores 24.59641 there). tulpa_re_cov_nested()reports the median and 95% interval of every derived covariance quantity (sigma_i,rho_ij,Sigma_ij, in every block) from the moments its integration design reproduces, instead of from a discrete weighted quantile over the design’s node positions (gcol33/tulpa#308). A central composite design is a moment rule: its nodes sit where they reproduce the integrand’s first two moments and carry no probability mass of their own, so the cumulative design weight across them is not a CDF. The discrete quantile clamped an out-of-support probability to the extreme node, which made every reported interval exactly the design’s own extent – atkcovariance coordinates,theta_hat +/- 1.1 sqrt(k)posterior SDs, whose coverage is capped at2 Phi(1.1 sqrt(k)) - 1 = 0.729 / 0.880 / 0.943 / 0.972fork = 1 ... 4no matter how much data the model is given. The interval is now moment-matched on each quantity’s own coordinate (logfor a scale or a variance,atanhfor a correlation, the identity for a covariance) and mapped back, so scale intervals stay positive and asymmetric and correlation intervals stay inside(-1, 1). Measuredsigma_1coverage over 200 seeds per arm (poisson, 60 groups x 40 observations, nominal 0.95), varying only the random-effect block:k = 10.735 -> 0.950,k = 20.880 -> 0.945,k = 30.950 -> 0.945 (binomial SE 0.015 - 0.031). Themeanandsdcolumns are unchanged,tulpa_re_cov_gibbs()was never affected, and the tensor-grid layout (control$integration = "grid"), whose uniform cells do discretize the density, keeps the weighted quantile..nl_wtd_quantile()takes an explicitoutsidepolicy for a probability beyond the support’s own cumulative range. The default"clamp"is the cumulative-mass convention a sample uses and is unchanged;"na"withholds the number where the support is a quadrature design rather than posterior mass, so the clamp is a stated choice instead of a silent one.The nested random-effect-covariance recovery gate (
test-re-cov-recovery.R) is raised from 75% to the 85% its Gibbs sibling is held to. The 75% was the defect above, measured and accepted rather than diagnosed.-
Every remaining consumer that reads a median and interval off a CCD-integrated outer grid now takes them from the moments the design reproduces, the same way
tulpa_re_cov_nested()does since gcol33/tulpa#308. The three are the joint multi-block per-axis hyperparameter summary (theta_median/theta_ci_lo/theta_ci_hi, gcol33/tulpa#309), the inlinespatial()bar field’s per-blocksigma/rhoand its MCARSigmasummary (gcol33/tulpa#310), andspatial_range()/temporal_corr()(gcol33/tulpa#312). Confirmed before changing anything: on a real three-block joint CCD fit the reported upper endpoint equalled the node maximum on every axis at0.000e+00, and on a real MCAR fit four of the six derived quantities had BOTH endpoints exactly on the node extent.Measured coverage of the reported interval, gaussian response, 40 groups per factor and 1200 observations, varying ONLY the number of crossed random-effect blocks (which is the outer dimension
d), 200 seeds per arm, nominal 0.95:d = 30.9350 -> 0.9450,d = 40.9900 -> 0.9700,d = 50.9650 -> 0.9300 (binomial SE 0.007 - 0.018). Before, the mean interval width grew with the design – 0.3131 / 0.3560 / 0.3899 across the three arms, in a data regime that did not change – because the endpoints were the design’s1.1 sqrt(d)whitened-SD reach rather than a property of the posterior; after, it is 0.3241 / 0.3251 / 0.3261, flat to 0.6%.On the MCAR bar field, whose simulated
Sigmagives an explicit truth (8 x 8 lattice, 20 observations per cell, 150 seeds, nominal 0.95), every derived quantity’s coverage improves:sigma_10.9067 -> 0.9133,sigma_20.7467 -> 0.8333,rho_120.7533 -> 0.8400,Sigma_120.8667 -> 0.9267, pooled 0.8211 -> 0.8767 over 900 trials. It does not reach nominal; the residual is the inner Laplace’s attenuation of a spatial covariance at 64 units plus the outer Gaussian grid’s fit to a skewed log-Cholesky posterior, which this does not address. Each nested-Laplace hyperparameter axis names the DOMAIN its interval is formed on, read from the same per-axis registry the outer Pareto-k unconstrains with (
.joint_axis_domains()): a positive scale onlog, the BYM2 mixing weight onlogit, an unconstrained coordinate (a copyalpha, an MCAR log-Cholesky entry) on the identity. A proper-CARrho_car, whose support is the adjacency’s eigenvalue interval, has no domain the engine will guess, so its interval is withheld rather than reported as the design’s extent.ranef()on atulpa_re_cov_nested()fit reports the SAMPLED values for a random-effect coordinate the subspace debias selected, instead of the Gaussian mixture the fit had stopped using (gcol33/tulpa#314). The node mixture is reused rather than redrawn, so the group effects and the fixed effects marginalize one weighted node set, and asourcecolumn says per row which construction produced it ("sampled","mixture"or"mode"). A fit whoseScontains no random effect is untouched, consumes no random number, and reports what it reported before.The inner-k-hat identity test strips the
$skew_correctionrecord alongside$inner_*(gcol33/tulpa#313). Every entry of that record is derived from gamma_3 or from the switch itself, so it necessarily differs withcontrol$diagnose_skewon and off; the invariant the test asserts – that the diagnostic consumes no randomness and changes no non-diagnostic field – is unchanged.-
Every joint outer grid reports
weight_kind, one entry per cell, saying whether that cell carries the mass of its own cell or an in-cell design weight (gcol33/tulpa#311). A fit integrated by one rule reports one value throughout; a locally CCD-refined grid is the one support carrying both, and it now says so per cell instead of leaving a consumer to read one kind offintegration.local_ccd_infogainsn_design_nodesanddesign_mass, the share of the integration weight sitting on design-weighted nodes.The per-axis median and interval keep the weighted quantile on a refined grid, which is a measurement rather than an omission. Scored against outer targets whose exact axis quantiles are known in closed form, the refined grid’s quantile beats the unrefined grid’s own in 8 of 8 configurations on a diagonal-Gaussian target (
d = 4/5, 5/7 levels per axis, two peak sharpnesses), so declining it would withhold a number strictly better than the one the same summary reports one refinement earlier. On a target the moment rule cannot fit by construction (each axis carrying aGamma(k, 1)marginal on a log-tagged axis, 18 configurations overd = 4/5, 5/7/9 levels,k = 1/2/8), the moment read beats the quantile as often on an unrefined grid (12 of 18) as on a refined one (11 of 18), and refining improves the moment read in only 2 of 18 – so that advantage belongs to coarse grids and near-lognormal targets, not to the mixed weights, and moving a refined fit onto it is a separate question about density supports. The mixing is bounded by the refinement itself: each node cloud is clamped to its cell’s Voronoi half-box, so a refined cell’s mass is redistributed only inside the cell the unrefined grid had collapsed onto one point. -
Local CCD refinement scales each node cloud by the refined cell’s MARGINAL spread instead of its conditional one (gcol33/tulpa#316). The cloud’s scale came from a diagonal finite-difference stencil, and
1 / sqrt(-d2_j)is the spread along axisjwith every other axis held at the cell; the per-axis summary reports marginal spreads, and on a correlated outer posterior – a sigma-alpha copy ridge is one – the two differ bysqrt(H_jj (H^-1)_jj). The stencil now also differences the cell’s CORNER grid neighbours, which a cell interior on every axis always has and which the tensor base already evaluated, sosqrt(diag((-H)^-1))costs no extra inner solve. A cell whose corners are missing (a grid a previous pass spliced nodes into) or whose local-His not positive definite keeps the conditional scale.Only the scale changes. The design stays axis-aligned, the per-axis shrink to the Voronoi half-box and the node clamp are untouched, and so is the weight-conservation argument. That is deliberate: the summary reads a weighted quantile over the refined grid and on design weights a cumulative sum is not a CDF, so what it returns is close to the design’s own per-axis EXTENT. An axis-aligned design puts an axial node at
f_0 sd_jon coordinatej; a design rotated by the Cholesky factor of the same covariance puts it atf_0 L[j, k], and measured on an equicorrelated target with unit marginal SDs atrho = 0.8the per-axis extents are 2.200 / 1.760 / 1.765 / 1.918 times the SD – so a rotated design reports an interval that depends on the arbitrary order of the axes. Rotating was built and measured and is not what shipped.Scored against an equicorrelated Gaussian outer target on identity axes, whose axis marginals are standard normal whatever the correlation is, at four axes and three levels per axis (
design_massabove 0.99), reported 95% width as a fraction of the exact 3.91993:rho 0.0 0.5 0.7 0.8 0.9 before 1.0052 0.8587 0.6748 0.5543 0.3942 after 1.0052 0.9740 0.9540 0.9527 0.9569Mean absolute endpoint error at
rho = 0.8goes 0.58240 -> 0.06182. The target is quadratic, so the stencil is exact there: the recovered marginal SDs matchsqrt(diag(Sigma))to 1.1e-15 and the conditional ones1 / sqrt(diag(Q))exactly.On the package’s own 4-axis multi-block fixture, refit on axis ranges that bracket its posterior and scored against a converged
m = 13tensor grid (28561 cells, 583 s, refinement off), mean absolute endpoint error per base grid, with the reference’s ownm = 11againstm = 13movement as the noise floor (0.01716 on endpoints, 0.03853 on widths):levels m 3 4 5 6 7 8 total design_mass 0.931 0.289 0.222 0.254 0.169 0.121 before 0.1797 0.1533 0.1219 0.0616 0.0537 0.0469 0.6170 after 0.1641 0.1518 0.1219 0.0616 0.0537 0.0469 0.6000The improvement is concentrated where #316 is: at three levels per axis the reported mean width goes 0.67781 -> 0.71103 against a reference 1.09089, and since both sit below the reference that direction does not depend on the reference’s exact value. It is modest there because that fixture’s outer correlations are weak (the refined cell’s local correlations are -0.460 to +0.158) and because at four of the six base grids the estimated
-His indefinite, so the conditional scale is kept. Four other candidates were built and measured against the same reference – removing the shrink and clamp and deleting absorbed cells, calibrating the scale to the cell’s own mass share, collapsing each cloud back to a mass atom, and rotating by the Cholesky factor – and none of them beats this across the sweep.Also measured: the shrink to the Voronoi box, which #316 names as the mechanism, does not bind on that fixture at three levels per axis. The conditional scale there is (0.2120, 0.1034, 0.1954, 0.0388) against a
half / node_reachof (0.3197, 0.1818, 0.3197, 0.1420), and a candidate that removes the shrink and the clamp entirely returns bit-identical numbers. local_ccd_infogainscell_share, the share of the base grid’s integration weight each refined cell held before any node was placed. It is a different number fromdesign_mass, which is the share the refined region holds after: the replacement nodes sit nearer the peak than the cell’s own coordinate did, so refining raises it. Reading both separates how concentrated the base grid already was from how much the refinement concentrated it.A joint multi-block fit records which outer integrator the caller ASKED for and why the CCD did not run (gcol33/tulpa#315).
$integrationnames the integrator that ran, and.nl_node_support()keys the interval construction off it, so"grid"could not distinguish a tensor grid the caller chose from one a declined CCD fell back to; the reason existed and was thrown away outside averbosemessage.$integration_requestedcarries the request and$integration_declinedthe reason –NA_character_when nothing was declined, otherwise"axis_count","unguessable_axis","degenerate_axis","modefind_ridge","modefind_boundary","modefind_degenerate","modefind_failed","hessian_singular"or"hessian_not_pd". The cell-count warning drops its “setcontrol$integration = \"ccd\"” advice on a fit that already asked for one and was turned down, and names the decline instead.
tulpa 0.0.144
-
The subspace debias reaches the grid and joint nested backends (gcol33/tulpa#306, the follow-up to #304).
control$subspace_debiasis now accepted bytulpa_nested_laplace()– both the single-block kernels (icar / bym2 / car_proper / rw1 / rw2 / ar1) and the multi-block driver – and bytulpa_nested_laplace_joint()on both its single-block and multi-block paths, with the same settings and the same meaning as ontulpa_re_cov_nested().The selector costs nothing new here.
control$diagnose_skew(on by default) already re-dispatches the kernel at the fitted MAP cell and attaches both inner scores, so.subspace_bands()reads the per-index gamma_3 and inner Pareto-k-hat off the fit instead of paying for a probe of its own – the one thing it had to learn is to prefer the stored per-index k-hat over re-fitting the raw importance curve, which a nested fit does not retain. The correction itself re-runs the settled grid once with the sampler on, because the corrected shape is a property of each cell and which cell is the MAP is only known after the first pass. A corrected fit then reports$draws– each cell’s Metropolis sample for the selected coordinates, the rest from the Gaussian conditional given them, mixed by the grid weights through the same.re_cov_nested_beta_draws()the RE-covariance backend uses – instead of the Gaussian-mixture moments, and every coefficient-facing method reads them through the accessor it already used.Measured against the exact conditional posterior, computed by two-dimensional quadrature outside the engine. Both fixtures put the latent block where no observation reaches it, so the conditional posterior factorises into the fixed-effect target and an independent Gaussian and the quadrature is exact for the reported coefficients.
Grid backend, rare-event binomial logit (n = 120): total absolute interval-endpoint error 0.5229 -> 0.1883 (mean of 5 seeds, sd 0.0672), a 64.0% cut; the reported centre moves -2.526 -> -2.63 against an exact posterior mean of -2.635. S is the intercept, selected on an inner k-hat of 0.705 with gamma_3 = -0.375 against an exact skewness of -0.388.
Joint multi-block, the #300 coupled occupancy fixture: 0.6189 -> 0.163 / 0.341 / 0.313 over three seeds, the centre -0.240 -> about -0.15 against an exact -0.1437 and the scale 0.389 -> about 0.447 against an exact 0.479.
Coverage, with the exact posterior’s OWN coverage as the reference rather than the nominal level – the correction targets that posterior, so reproducing its coverage is the success condition and matching nominal is not. Grid backend, 400 seeds, per coefficient:
level coef exact plain corrected 0.95 beta0 0.9050 (.0147) 0.9750 (.0078) 0.9150 (.0139) 0.95 beta1 0.9300 (.0128) 0.9550 (.0104) 0.9225 (.0134) 0.80 beta0 0.7600 (.0214) 0.8225 (.0191) 0.7575 (.0214) 0.80 beta1 0.7950 (.0202) 0.8375 (.0184) 0.7800 (.0207) The corrected rate is within 0.52 SE of the exact posterior’s on all four; the plain Laplace sits 2.4 to 4.8 SE above it. Its apparently better agreement with the nominal level is an over-wide, mis-shaped Gaussian, not accuracy. Joint multi-block, 200 seeds with S pinned to both coefficients, same reference: 0.9500 / 0.9600 / 0.7900 / 0.7400 exact against 0.9598 / 0.9598 / 0.7940 / 0.7387 corrected and 0.9749 / 0.9548 / 0.8191 / 0.7739 plain (one seed’s fit reported an NA bound, so n = 199 for the fits and 200 for the reference).
Cost, over those sweeps: 0.0324 s (SE 0.0005) -> 0.1765 s (SE 0.0011) on the grid backend at mean |S| = 1.060, and 0.0662 s (SE 0.0012) -> 0.4019 s (SE 0.0045) on the joint one at |S| = 2, i.e. 5.45x and 6.07x. Roughly half of that is the second grid pass and the rest the sweeps themselves.
An EMPTY selection leaves every backend bit-for-bit identical to the plain path, asserted per backend (log-marginal, weights, modes, the per-cell fixed-effect pieces,
summary()andvcov()) in the newtests/testthat/test-subspace-debias-backends.R. The band selector under-flags on the coupled fixture, measured. On the #300 coupled occupancy fit both coefficients band
goodon both inner scores – gamma_3 reads 0.256 and -0.126, the inner k-hat 0.378 and 0.329 – while the exact skewness of the occupancy coefficient is 1.198, which isunreliable. gamma_3 recovers 0.21 of it there, below the 0.564 to 0.943 range #304 measured on the separable fixtures, and the inner k-hat over 256 one-dimensional draws does not separate that target from a Gaussian either. So on that fit the selector takes nothing andcontrol$subspace_debias$idxis what pins the set; the joint numbers above are the pinned run. Theokband floor is unchanged – #304 measured it, and one fixture where a lower-bound estimator undershoots harder than usual is a fact about the estimator, not a reason to move a threshold that was itself set on measurement.Threading. The sampler draws from R’s RNG, so a debiased outer grid is integrated serially whatever
n_threads_outerasked for, on both the single-arm driver and the sparse joint one. The cheap warm-start screen never runs the correction, and a joint cell whose inner solve took the s2z rank-1 or the PSD eigen-clamp path carries no usable factor to build the surface from and is left uncorrected – the same two pathsdiagnose_skewdeclines on, for the same reason.One request, one unwrap, one assignment point. The debias request travels through every nested kernel entry as ONE R list (
idxplus the sweep budget) rather than four parallel arguments;unwrap_debias()/DebiasRequest(src/laplace_spec_fit.h) turn it into the solver’s options at each entry, andrun_subspace_debias()(src/subspace_debias.h) guards, runs and records the outcome for all three Newton loops – the single-arm spec loop and both joint loops – so the “empty index set is a no-op” contract and the result mapping are written once.cpp_laplace_fit_multi_re()’s four #304 arguments are folded into the same list; pre-release, no shim.control$subspace_debiasis left refused atn_quad > 1ontulpa_re_cov_nested(), which is the honest answer rather than a gap. The adaptive Gauss-Hermite inner marginal integrates each group’s random effects out, so at the fitted point there is no conditional latent field: no joint precision to takeSigma e_ifrom, hence no Gaussian-conditional-mean surface and nothing for the sampler to move along. What could be corrected there is the outer optimum’s own Laplace approximation, which is a different construction on a different density and would not share this machinery.Reporting the SAMPLED values per group for a random effect the closure pulled into S is split out as gcol33/tulpa#314: the draws exist per node but are not recorded on the fit, so
ranef()still reports the Gaussian mixture there.Fixed:
tests/testthat/test-inner-pareto-k.Rasserted that a fit is bit-for-bit identical withcontrol$diagnose_skewon and off after strippinginner_*andtiming, which stopped holding when #302 added the gamma_3-derived$skew_correctionrecord two commits later. The record is diagnostic-derived, so it is stripped alongside them (gcol33/tulpa#313).
tulpa 0.0.143
-
The joint tier’s fixed-effect block is extracted inside each cell’s own solve (gcol33/tulpa#307). Filling
$grid_modes/$grid_hessianson a joint fit (#305) read the block off the cell precision, so the joint kernels ran withstore_Qinternally and the whole outer grid’s precision was alive at once between the kernel call and the extraction. Both joint Newton loops now take the request – the leading block size plus the field sum-to-zero groups, both fixed by the latent layout before the first solve – and return the block on theLaplaceResultre_covcontract, which the grid driver emits per cell. The dense loop builds one cell’s CSC, extracts, and releases it; the sparse loop reads the builder’s own CSC, so there the block costs no precision copy at all.store_Qis once again the caller’s own knob, passed straight through.Measured on an ICAR chain fixture (
n_fixed = 8, 40-cell grid, R heap high-water over three paired runs): peak 49.9 vs 51.5 MB atn_x = 408, 53.6 vs 58.2 MB at 2008, and 62.1 vs 71.9 MB at 6008 – the saving tracking the grid’s precision (1.63 / 4.07 / 8.54 MB, i.e. 41.7 / 104.2 / 218.7 KB per cell) and growing with the field where the retained block does not (868 bytes per cell at every size). Fit time is unchanged: 0.27 / 0.73 / 1.73 s against 0.31 / 0.72 / 1.75 s.The blocks are byte-identical to what
cpp_joint_inner_vcov_blocks()returns for the same cells – same bytes in, same routine – sosummary(),confint()andvcov()report exactly what they did in 0.0.142, and draws, modes, weights andlog_marginalare untouched. Local-CCD refinement no longer costs a joint fit its intervals (gcol33/tulpa#307). The node solves carry their own fixed-effect block through the splice alongside the inner modes, so a refined grid reports instead of recording
grid_fixed_declined = "local_ccd_refined". Refinement itself is unchanged.One extraction algebra behind all of it:
src/inv_block_extract.hholds the conditioning-by-kriging constraint correction and the diagonal-block extraction, both templated on a solve oracle.laplace_newton.h’sinv_block_layoutpath drives it against the live Newton factor;extract_inner_vcov_block_cell()drives it against a factorized cell with the constraint; the joint loops go through the latter.
tulpa 0.0.142
-
A joint fit reports uncertainty on its fixed effects (gcol33/tulpa#305).
summary(),confint()andvcov()on atulpa_nested_laplace_joint()fit reported the point estimates andNAfor every standard error and both bounds, on both the single-block and the multi-block path. The grid marginalizer.nested_fixed_moments()reads one representation –$grid_modesand$grid_hessians, the per-cell fixed-effect mode and marginal precision – and the joint driver stored neither, so a joint fit could not put an interval on any coefficient.Both joint drivers now fill that same pair through one shared helper,
.joint_attach_grid_fixed(), so the two tiers reach the one marginalizer rather than growing a second one. Both joint layouts stack every arm’s coefficients as a contiguous prefix of the latent vector, so the extraction is arm-aware by construction: it takes the whole1:n_fixedblock in one pass and reports each arm’s coefficients under its own name. The per-cell block comes fromcpp_joint_inner_vcov_blocks(), the joint tier’s existing per-cell inner-covariance extraction, so the reported covariance is the field-constrained one the fit’s owntulpa_posterior_draws()mixture is generated from. The reported covariance is the law of total variance over the outer grid and so carries both the within-cell curvature and the between-cell hyperparameter spread.Measured against references outside the engine. On the #300 coupled fixture, whose log posterior is written independently in R, the reported covariance matches the inverse numerical Hessian of that density at the mode to 1.4e-09 relative. A one-arm joint fit and the single-block fit of the same model – the same data, block and grid – now agree to 5e-08 on every coefficient and standard error, where the joint side previously produced
NA; that equivalence is asserted for poisson, binomial and gaussian. Over a multi-cell ICAR grid the mixture matches the independent R implementation of the same law-of-total-covariance (.joint_mixture_moments()) to 8e-17, and 200000 draws of the fit’s own posterior mixture reproduce its standard errors to 0.1%. CI coverage is judged by the existing recovery sweep with the joint fitter substituted rather than by a second harness.Retention is
control$keep_grid_hessians(defaultTRUE), and costsO(n_fixed^2)per cell: 860 bytes per cell atn_fixed = 8, unchanged as the field grows fromn_x = 408ton_x = 6008, with no measurable fit-time overhead (-0.1% over three paired runs). Reading the block needs the cell precision, which the kernels now keep during the fit and drop again unlesscontrol$store_Qasked for it; that transient is the existingstore_Qpeak, ~64 bytes per latent per cell, and gcol33/tulpa#307 tracks removing it by extracting the block inside the joint Newton loop.Draws, modes, weights,
log_marginaland the hyperparameter moments are bit-for-bit identical with the retention on and off – this adds reporting, not inference. Where the retention cannot be trusted it declines with a reason on$grid_fixed_declinedinstead of going quiet:"not_requested"when switched off, and"local_ccd_refined"when local-CCD refinement rewrote the outer grid after the cells were stored (local-CCD keeps precedence, so no existing fit changes). .nested_fixed_moments()skips a grid cell that carries no integration weight. A pruned cell with no retained block previously turned the whole marginalized covariance intoNA.
tulpa 0.0.141
-
The reliability band is now the debias SELECTOR: exact MCMC runs on only the misfit directions (gcol33/tulpa#304). Escalation used to be whole-fit and all-or-nothing (
tulpa_re_cov_nested->tulpa_re_cov_gibbs, or a grid refinement), with nothing saying which directions needed exact treatment, so every coordinate paid the sampler’s price including the ones the Gaussian already fits. The per-index inner diagnostics are already a map of exactly that.control$subspace_debias(defaultFALSE) bands every probed index, takesS= the misfit set, and corrects onlyx_Sby Metropolis withx_{-S}carried at its Gaussian conditional.The sampled surface is
x(u) = mode + V Sigma_SS^{-1} L uwithV = Sigma E_S– the q-dimensional generalization of the one-dimensional conditional-mean curve both inner diagnostics already walk, reusinginner_probe_column()rather than a second solve. The Gaussian restricted to it is exactlyN(0, I)inu, so the walk is spherical and the Laplace shaping lives in the coordinates. Random-walk Metropolis, not NUTS: each evaluation is one call of the Newton loop’s own penalized objective (O(N), no factorization, no derivative), so a gradient sampler would buy nothing and would need a derivative the loop does not expose along the surface.Selection is at the
okband, one step below theunreliableband the reporting layer flags on, becausegamma_3is a LOWER bound on the true skewness (0.564-0.943 of the exact value across the engine’s own fixtures), so selecting at the reported boundary would leave genuinely misfit coordinates uncorrected. The inner importance k-hat (#303), which needs no derivative and does not undershoot the same way, is folded in as the worse of the two – on the rare-event sweep below it is what bands 225 of 400 interceptsunreliablewhere the meangamma_3is only -0.597.MEASURED against an exact reference (Bernoulli random intercept at fixed RE SD, the group intercepts integrated out by Gauss-Hermite and
p(beta | y)marginalized on a grid): the exact intercept marginal ismean -4.0050, 95% (-6.5082, -2.2898); the Laplace Gaussian givesmean -3.4025, (-5.3153, -1.4897), total endpoint error 1.9930; the correction onS = {intercept}givesmean -3.7675, (-6.1446, -2.0611), endpoint error 0.5923 – a 70.3% reduction. Residual bias remains, as expected from a lower-bound skewness estimate and a Gaussian conditional.MEASURED against correcting EVERY coordinate, which is the question of whether a subspace is enough (200 seeds x 2 coefficients, Bernoulli random intercept, 60 groups of 3, at the true RE SD, against the exact quadrature marginal, nominal 0.95): plain Laplace 0.9050 (se 0.0147), subspace debias 0.9275 (0.0130) at a mean
|S|of 0.945 coordinates, full-S debias over all 62 latent coordinates 0.9225 (0.0134). Correcting about one coordinate recovers what correcting all 62 recovers, and costs 0.313 s against 0.461 s.MEASURED against the full Gibbs debias (400 seeds, rare-event binomial-logit with a random intercept, pooled over both coefficients): at nominal 0.95, plain Laplace 0.9738 (se 0.0057), subspace 0.8662 (0.0120), full Gibbs 0.8888 (0.0111) – subspace within 1.4 standard errors of the full debias; at nominal 0.80, plain 0.8738, subspace 0.7175 (0.0159), full Gibbs 0.7037 (0.0161), within 0.6 standard errors. Cost 0.468 s against the full debias’s 1.287 s, 2.75x cheaper. On the small-group binary RE fixture every probed coordinate bands
good(max|gamma_3|0.236 over all 122 latent coordinates),Sis empty, and the fit is the plain one.A second whole-fit sweep on a denser fixture – Bernoulli random intercept, 60 groups of 3 (n = 180),
beta = (-2.5, 1),sigma_u = 1, 200 seeds, all three backends at their defaults on the same data – does NOT reproduce that match, and the reason is worth stating rather than averaging away. Beta coverage pooled over both coefficients at nominal 0.95: plain nested 356/400 = 0.8900 (se 0.0156), subspace 359/400 = 0.8975 (0.0152), full Gibbs 376/400 = 0.9400 (0.0119) – a 2.2 standard-error gap, at 1.074 s against 8.123 s. The correction is not what falls short there. On the SAME 200 seeds its own layer, conditional coverage at the true sigma, goes 0.9050 plain -> 0.9275 subspace against 0.9225 for correcting every one of the 62 latent coordinates, so one coordinate recovers what all 62 do, at 0.313 s against 0.461 s. What is left is outer: the nested path’ssigma_1interval covers 150/200 against the Gibbs sampler’s 199/200 and its intercept interval is 26% narrower, and turning the correction on moves neither number. That is a different layer, and the two backends are not even integrating the same hyperprior (the conjugateSigma | bdraw cannot take the flat default); gcol33/tulpa#308 separates it.Against exact Gauss-Hermite quadrature on that fixture (24 seeds x 2 coefficients, max grid tail mass 8.4e-14) total absolute endpoint error is plain 24.2040, subspace 21.2363 (-12.3%), every-coordinate 7.7709 (-67.9%). So the band-selected subspace recovers the full correction’s COVERAGE while recovering about a fifth of its endpoint accuracy: it puts the interval in the right place without fully fixing its shape. Closing that remainder is what the coupling closure below would do, and only by growing
Sto nearly the whole coupled block.The COUPLING CLOSURE (grow
Sby the precision-graph neighbours whose partial correlation with a member exceeds a threshold) is implemented and was measured both ways rather than assumed, which is what the issue asked for. At the default threshold it changes nothing: against the exact marginal it moves the endpoint error 0.5923 -> 0.5651, a difference of 0.027 against a combined seed standard error of 0.038, and across the 400-seed sweep it fires on 163 seeds yet leaves coverage identical on 1572 of 1600 seed-coefficient-levels. The reason is that the partial correlations between a fixed effect and the random effects only run about 0.09 to 0.25 on these models, so a threshold in the usual “strong coupling” range never bites.Lowering it far enough to bite does move the finer metric, and that is worth stating precisely rather than glossing: on a 14-coordinate fixture the total endpoint error against the exact marginal falls 4.43 -> 1.21 only once the threshold reaches 0.05, at which point
|S|has grown to 13.4 of 14 – the full debias wearing a different name rather than a subspace one. So conditioningx_{-S}on the Gaussian does NOT reproduce the exact marginal endpoint for endpoint; it removes about 70% of the Gaussian’s endpoint error at|S| = 1and the rest is not cheaply recoverable by growingS.On the arbiter the issue actually names – interval coverage – that residual does not show: at 200 seeds the
|S| = 1correction and the all-62-coordinate correction cover 0.9275 and 0.9225, indistinguishable. Coverage is the coarser of the two metrics, and the closure is off by default because nothing measured here asks for it. It stays available asclosure = TRUEor an explicit threshold.Sis recorded on the fit assubspace_debias(selected indices, the per-index band table they were read from, what the closure added, and the per-node acceptance), so the escalation is auditable rather than implicit. An emptySis not a special case of anything: the sampler is never entered, no random number is consumed, and the fit is bit-for-bit the plain Laplace fit – asserted on both the solver and the front door. One random-walk Metropolis definition, not two (
src/rwmh.h). The starting scale2.4 / sqrt(d), the Roberts-Gelman-Gilks target acceptance, the Robbins-Monro burn-in adaptation and the accept test were written out inline in the covariance Gibbs sweep and would have been written out again for the subspace debias. They are now one set of primitives both consume.rw_accept()draws its uniform unconditionally so a sweep consumes exactly one uniform per test whatever the ratio is, which is what keeps the migrated Gibbs sweep’s RNG stream unchanged.
tulpa 0.0.140
-
gamma_3is now consumed, not only graded: the inner-Laplace marginals can be skew-corrected (gcol33/tulpa#302). The cubic term was computed, banded and printed, and nothing read it – so the inner layer was nested approximation with no debias, the position this engine is designed against, one layer in from where that argument is usually made.summary()andconfint()on a nested-Laplace fit run withcontrol$skew_correct = TRUEnow report Cornish-Fisher marginal quantiles at each coefficient’s owngamma_3, gated to thegood/okbands, and the Gaussian quantiles everywhere else.$skew_correctionrecords the per-coefficientgamma_3, band and eligibility; askew_appliedattribute onsummary()/confint()records what was used at the requested level. Wired throughtulpa_nested_laplace()and bothtulpa_nested_laplace_joint()paths. (At this release a joint fit recorded the correction without showing it, because the joint driver retained no per-cell fixed-effect Hessians for the grid-marginalized covariance; gcol33/tulpa#305 supplies them in 0.0.142 and the correction applies there.)Rue, Martino & Chopin (2009) Sec 3.2.3 fit a skew normal here, under three constraints – mean
gamma^(1), variance 1, third log-density derivative at the modegamma^(3). Two of those inputs exist in this engine and one does not:gamma^(1)comes from their denominator expansion, which is diagonal only in their augmentedx_j == eta_jrepresentation (src/inner_laplace_skew.hcarries the reason). A skew normal fitted on the cubic term alone is therefore a different construction from theirs, and its attainable skewness saturates at|skewness| ~ 0.995with the shape parameter diverging as that bound is approached – inside the very band the correction is gated to. The Cornish-Fisher expansion is the quantile-side inverse of the same Edgeworth seriesgamma_3is the leading term of, is linear ingamma_3so it does not saturate, and returns quantiles directly.The correction is skewness-only and therefore partial, which is measured rather than asserted. Against exact quadrature quantiles of rare-event binomial-logit posteriors it cuts total absolute endpoint error from 2.4931 to 1.3837 (44.5%), improving both endpoints in every case. On CI coverage over a small-group Bernoulli random-effect fixture (N = 48, 200 seeds x 2 coefficients) it is directionally right and immaterial: nominal 0.95, Gaussian 0.9650, corrected 0.9600; nominal 0.80, 0.8050 vs 0.8075; nominal 0.50, 0.4950 vs 0.5000 – every difference inside one standard error. Two reasons the coverage gain is smaller than the marginal gain:
gamma_3is a lower bound on the true skewness (0.875-0.943 of it on the cases above), and a biased Laplace mode stays biased because the location term is not computed. The correction is therefore OFF by default (.NL_DIAG$skew_correct); the coverage measurement does not justify defaulting it on. Draws, modes, weights and every other field the solve produced are bit-for-bit unchanged either way – this is post-processing on the reported quantiles.New:
.nl_skew_marginal(),.nl_skew_by_fixed(),.nl_skew_correction_attach()(R/laplace_diagnostics.R),src/cornish_fisher.h/.cpp,tests/testthat/test-inner-skew-correction.R, and a paired corrected-vs-Gaussian coverage gate intest-nested-laplace-recovery.R.
tulpa 0.0.139
-
gamma_3now scores coupled multi-predictor likelihoods instead of declining on them (gcol33/tulpa#301). The cubic Edgeworth term assumed a log-likelihood that is a separable sum of one-eta terms, so every unit reading several linear predictors at once – a zero-inflation mixture’s (count, zi) pair, aCellCouplingSpeccell’s arms (tulpaObs’soccu_cover) – had no per-eta third derivative and came backNaNfor good. The expansion is unchanged; only the contraction widens, tosum_units sum_{a,b,c} T^{abc} u^a u^b u^cwithTthe unit’s third derivative in its linear predictors anduthe eta response toSigma e_i. The separable case is the one-coordinate special case of it.Tis never materialised (src/curvature3_contract.h). Partition the unit’s coordinates intoKblocks and the contraction equalssum_a d/ds [u' L''(e + s u^(a)) u]ats = 0, because moving along blocka’s slice of the direction differentiates exactly that block’s coordinates. Each term is one central difference of the Hessian the likelihood already returns for the Newton solve, so the whole tensor costs2Kextra evaluations per unit and no storage, at any block sizes. For aCellCouplingSpecthat Hessian is the analyticCellDerivsblock, so this is one finite-difference layer on an exact quantity, not a difference of a difference.The step is scaled PER BLOCK off that block’s own eta magnitude, matching the eta-space step the scalar working-weight fallback takes. Measured against a five-point third derivative of the cell log-density: identical to a single global step while the arms share an eta scale, and 1.8x more accurate once one arm’s
|eta|is 67x the other’s. The contraction is symmetrised over index permutations; for this block decomposition that is algebraically the plain sum (the three relabelings coincide), so it buys robustness at a block whose own quotient could not be formed rather than variance reduction.Verified against the exact posterior, not asserted: on the coupled two-arm occupancy fixture the engine’s
gamma_3reproduces the same quantity computed independently in R – the third derivative of the exact log posterior along the same conditional-mean curve – to 8e-4 relative, and the zero-inflated Poisson to 5e-4. Held against the two-dimensional quadrature of the same posteriors it has the right sign and undershoots, closely where the skewness is small (0.86 and 0.93 of the exact value at|skew| ~ 0.11-0.13) and by about half where it is moderate (0.299 of an exact 0.530). That last case is pinned in the suite because it has a consequence:gamma_3is a LOWER BOUND on the skewness, and there it bands “good” where the exact value bands “ok”. A coupled Gaussian cell, whose Hessian is constant, reads exactly0.The scalar single-coordinate path is untouched: byte-identical across seven fixtures on both the family-enum and the spec entry (
identical(), max absolute difference exactly 0), verified against a build of the preceding commit. "coupled_likelihood"is retired from the inner-skew decline vocabulary and from.INNER_SKEW_STRUCTURAL– coupling several processes in one likelihood no longer describes anything permanently unscorable. What remains is"curvature3_unavailable"(a spec that ships no way to reach a third derivative) and"coupled_arm"(a coupled fit for which no cell tensor could be built at all). Every decline still returnsNaN; one unreadable cell takes the whole contraction toNaNrather than silently understating the sum.New
cpp_cell_coupling_curvature3()exposes the contraction at one cell, outside any solve, so a registered spec’s tensor can be checked against a direct numerical third derivative of its own log-density and the step policy measured rather than asserted (tests/testthat/test-cell-curvature3.R).
tulpa 0.0.138
-
The inner Laplace layer now has a likelihood-agnostic reliability number (gcol33/tulpa#303).
gamma_3scores the inner Gaussian by expanding the joint log density along the Gaussian conditional-mean curve at a probed latent index, which needs a per-observation third derivative – so a coupled multi-process likelihood (a ZI mixture, tulpaObs’soccu_cover) declines permanently and the fit has only the outer k-hat, which scores a different layer.inner_pareto_kwalks the SAME curve and simply evaluates the joint density along it: the inner Gaussian is an importance proposal for the exact conditional posterior, and the Pareto-smoothed shape of that ratio scores the approximation directly. No likelihood derivative anywhere, so it answers wherever a mode was found.It runs on the probed subspace, not the field. Importance sampling degrades with dimension on its own, so a k-hat over all
n_xcoordinates would reportn_xrather than the approximation; one dimension per probed index keeps every sampling problem 1-D and makes the number directly comparable to thegamma_3for the same index. The engine returns the draws and the joint log density at them (src/inner_laplace_is.h); the Pareto fit is the existing shared.nested_is_pareto_k()core, which now accepts an injected draw matrix, so there is one importance-sampling k-hat in the package rather than two. The conditional-curve solvev_i = Sigma e_iis extracted tosrc/inner_laplace_probe.hand shared with the cubic term; neither refactorizes.A Pareto shape index is scale-free – it describes the SHAPE of the importance-weight tail and says nothing about its size. Measured on the engine’s own fixtures at 256 draws: a gaussian-family coefficient, where the inner Laplace is EXACT and
gamma_3is exactly 0, reads k-hat 0.19 / 0.26 at importance efficiency 1.000, and a balanced binomial intercept (N = 500, S = 230,gamma_3= -0.007) reads 0.640 at efficiency 0.99998. Both are noise on a proposal that needs no correction. The k-hat is therefore banded only on probed indices whose realized efficiency falls below.NL_DIAG$inner_k_material_ess(0.995); the raw shape is reported either way, andinner_pareto_k_uniformrecords that no index carried a correction worth describing.The two inner scores agree where both compute. Across a binomial-intercept skewness ladder ((N, S) = (500, 230), (500, 60), (100, 3), (20, 2), (15, 1)),
|gamma_3|runs 0.007 to 0.897 and the importance efficiency falls monotonically with it (0.99998, 0.9962, 0.850, 0.807, 0.634 – Spearman 1.00); the tail shape follows at Spearman 0.90, and the band verdicts agree rung by rung. On the coupled fixture, wheregamma_3is NaN for every index, the arm with the larger exact posterior skewness (0.53 vs 0.13 by direct quadrature) is the arm with the lower efficiency (0.983 vs 0.997)..tulpa_combined_reliability()folds the inner layer’s two scores into one band – the worse of them where both computed, the one that did where only one did – so a fully coupled fit reads “reliable (both layers good)” instead of “inner Laplace not assessed”. Reported throughdiagnostics(),print.laplace_diagnostics()anddiagnostic_summary(); declines carry a reason from the same closed vocabulary the outer k-hat uses.The draws are engine-owned and deterministic rather than taken from R’s stream, so requesting the diagnostic leaves a fit bit-for-bit unchanged and the reported k-hat does not flap with the seed. Cost is one joint-density evaluation per draw per probed index – no factorization – which is why the budget is a fixed engine constant rather than the outer diagnostic’s
k_samples, whose draws each cost a full inner Laplace solve. Fixed:
.tulpa_inner_k_reliability()reads its fields with[[. On a declined fit the only field carrying theinner_pareto_kprefix is the reason string, which$would partial-match into the k-hat.
tulpa 0.0.137
The engine can now test its own coupled likelihood paths (gcol33/tulpa#300).
CellCouplingSpechas been virtual-dispatched per cell since the joint driver gained a coupled branch, but every genuinely non-separable implementation lived downstream in tulpaObs, so the cross-arm scatter, the dense-pair allocation and the per-cell derivative contract were only ever exercised by a consumer. A minimal coupled likelihood is now registered here as a test fixture:test_occupancy_mixture(src/test_cell_coupling_occupancy_mixture.h), a two-arm occupancy mixture whose cell density ispsi prod_v Bern(y_v | p_v) + (1 - psi) 1{no detection}. A cell with a detection factorises; a cell with none puts the occupancy state and every visit inside one logarithm, sod^2 log p_cell / d eta_occ d eta_detand the cross-visit second derivatives are nonzero. It writes both dense cross blocks ((occ, det)and the(det, det)self block) rather than taking the rank-1 self-cross shortcut, so a third-derivative tensor has an explicit Hessian to difference, and it declares those two throughdense_cross_pairs()while omitting the one-row occupancy self block.cpp_cell_coupling_evaluate()exposes what a spec actually writes. The inner Newton chains each spec’s eta-space derivatives through the design and scatters them immediately, so nothing a spec computes was visible from R. This export drives any registered spec at one cell and returns the cell log-density, the per-arm gradient, the per-arm negative-Hessian diagonal and every dense cross block, with the same buffer-allocation policy the kernel applies (pairs read from the spec’s owndense_cross_pairs(), rank-1 descriptor supplied). It is the surface a finite-difference check of a spec’s analytic derivatives runs on.The exact-quadrature ground truth reaches the coupled case.
test-inner-skew.Rheld the separable scalar reference: integrate the exact posterior on a grid and holdgamma_3against its central moments. The same construction is now carried to two dimensions over the coupled fixture’s intercept-only conditional posterior, with three things asserted rather than assumed – the two-dimensional quadrature reproduces the trusted scalar reference on a product posterior, the R density agrees cell by cell with what the compiled spec evaluates, and the grid is converged under widening and refinement. The fixture’s exact marginal skewness is 0.53 on the occupancy intercept and -0.13 on the detection intercept, so a coupled cubic term (gcol33/tulpa#301) checked against it has something to be wrong about. The joint kernel’s current behaviour on it is pinned alongside: every probed index returns NaN with the reason"coupled_arm", never a silently-wrong 0.New tests:
tests/testthat/test-cell-coupling-occupancy-mixture.R(the per-cell contract at tier 1 – value against the closed form, gradient against a difference of the value, the full cross-arm Hessian against a difference of the gradient, the coupled/factorising branch split, the declared dense pairs, the grad-only path; then at tier 2 an end-to-end joint fit landing on the exact mode of the posterior it claims to solve, a spatial ICAR fit whose cross-arm curvature is measured nonzero at its own fitted mode, and dense-versus-sparse agreement) and four blocks intests/testthat/test-inner-skew.R. Shared scaffolding is intests/testthat/helper-coupled-fixture.R.
tulpa 0.0.136
-
The joint nested-Laplace grid no longer returns numbers that depend on what else the machine was doing. Two identical fits could disagree in their last bits, because two inputs to the coupled-cell scatter’s partition were read from live machine state rather than from the problem:
The scatter splits its per-cell loop into
Cchunks and reduces them in a fixed chunk order, which makes the reduce independent of which thread ran each chunk.Citself, though, came fromteam / act, whereactwas a count of the solves in flight at that instant (an atomicfetch_add). The chunk count sets the chunk boundaries, the boundaries set the summation order, and floating-point addition is not associative – soCmoving with the machine’s load moved the answer. It is now read from the cell index:n_grid - k_gridbounds how many peers a cell can have and estimates the same tail width from grid geometry alone.n_outeralso replacesomp_get_num_threads(), so an OMP dynamic team adjustment cannot move it either.The outer width
n_outerwas clamped against a liveavailable_ram_bytes()reading, so the same model fitted twice in one session could resolve different widths (and therefore different partitions) depending on what the box had allocated in between. Both memory readings are now taken once per session. The model-dependent term is still computed per call, so a larger model is still clamped harder; only the machine-state term is frozen.
No parallelism is given up for this. The chunks are dispatched as OpenMP tasks, so however many threads are genuinely idle still drain them – only the partition is pinned, never the number of workers executing it. In the bulk of the grid the budget is 1 exactly as before, so those cells stay serial and allocate no partial buffers.
tulpa_nested_laplace_joint()reportsn_outer, the outer width the solve actually ran at after the memory clamp. When two fits of one model report different widths, that is the explanation for a shift in their last bits.
tulpa 0.0.135
-
A prior block missing its required fields now errors instead of segfaulting the session (gcol33/tulpa#299). Each
.NL_REGISTRYentry declares, per dispatch path, the fields its converter indexes; the shared.nl_check_block_fields()checks them at the four boundaries that feed the kernels (.nl_dispatch(),.nl_block_axis_grid(),.nl_block_spec_for_cpp(),.joint_block_spec_for_cpp(), plus the single-block joint packer). A block naming a field wrongly – a typo, a stale name after a rename, a block copied from a different family – used to reach the C++ side as a zero-length vector, which the kernels index with no bounds check; it now raisesprior block 'icar' is missing required field(s): spatial_idx, adj_row_ptr, adj_col_idx, n_neighbors.A field present but empty counts as missing, since that is the same out-of-bounds read. The per-branch presence checks that had accumulated in the joint converter are replaced by the shared one, so the declaration is the single source of truth;test-nl-required-fields.Rwalks every registry entry on every declared path dropping one field at a time, and lints both converters’ sources so a field read unconditionally by a branch but left undeclared fails the suite.
tulpa 0.0.134
-
fit_st_nested()’s auto-recenter no longer switches itself off when a grid knob is set to the engine’s own default value (gcol33/tulpa#294). The spatiotemporal rescue guarded on the PRESENCE of any oftau_lower,tau_upper,n_grid_spatial,n_grid_temporal,n_grid_rho,rho_lower,rho_upperincontrol, socontrol = list(n_grid_spatial = 4L)– 4L being the default – returned at the first guard and left a railed grid railed. That is gcol33/tulpa#293 one level down: a wrapper package exposing its ownn_gridargument, defaulted to the engine’s value, threads it through on every fit and disabled the rescue for all of them. A knob is now a PIN only when its value differs from.nl_st_default()and it carries no [auto_grid()] mark, the same provenance question the three grid-vector rescues ask.Pinning is also PER AXIS rather than all-or-nothing:
tau_lower/tau_upperhold the two precision axes (they build both),n_grid_spatial/n_grid_temporalone each, andn_grid_rho/rho_lower/rho_upperthear1autocorrelation axis. A pinned axis keeps exactly the nodes its knobs built and is named in the newouter_grid_pinned_axes; the rest are recentred as usual. Only when EVERY axis is pinned does the rescue decline outright. auto_grid()now marks a scalar knob or a prior specification, not just a grid vector. One front door for “this value is my default, not the user’s choice”, across the three shapes that question arises in.The auto-recenter’s second-attempt PC prior is no longer suppressed by a
prior_sigmathe caller merely supplied (gcol33/tulpa#297). The escalation that exists for a runaway, near-separation mode engaged only whenprior_sigmawasNULL, so a wrapper stamping aprior_sigmaof its own turned attempt 2 into a second geometry recenter while the fit still reportedouter_grid_recenter_attempts = 2as if the full escalation had run. The suppression is now decided by provenance – anauto_grid()-marked spec, or one equal by value to the engine’s ownPC(U = 3, alpha = 0.01), is a default and does not hold the prior back – and when a genuine pin does suppress it the fit carriesouter_grid_prior_declined = "prior_pinned".The outer Pareto-k now says WHY it declined (gcol33/tulpa#295). Roughly two dozen distinct decline paths all arrived as the single value
pareto_k = NA, and the print method admitted as much (“outer diagnostic not run or proposal degenerate”). “You turned it off”, “this family’s support can never be scored”, “the outer Hessian came back non-finite” and “the weights carry no mass” are not interchangeable, and a batch readingpareto_kacross many fits could not tell a permanent structural limitation from a live signal about the fit. Every decline now carries a reason from a closed vocabulary infit$pareto_k_declined:"not_requested","not_applicable","unguessable_axis"(naming the axis, e.g. car_proper’srho_car– read the quadrature ESS instead, permanently),"draws_too_few","grid_too_small","no_varying_axis","degenerate_proposal", and"internal_inconsistency"(an engine bug, whichdiagnostic_summary()now WARNs on). Wired through the joint single- and multi-block paths, the registry path, the SPDE grid and CCD paths, and the shared IS cores; surfaced bydiagnostics(),print.laplace_diagnostics()anddiagnostic_summary().The inner-Laplace
gamma_3diagnostic now says why it declined too (gcol33/tulpa#296).gamma_3never returns a silently-wrong0(gcol33/tulpa#272), but itsNaNcarried no reason, so a structurally unscorable model – a coupled multi-process likelihood such as tulpaObs’soccu_cover, which this formula can never score – printed ascontrol$diagnose_skew = FALSE, attributing an impossibility to a knob the user had most likely left at its defaultTRUE. The reason now travels from the point of decline:build_spec_curvature3_fn()reports"coupled_likelihood"/"curvature3_unavailable"through an out-parameter rather than a second predicate that could drift from it, the per-arm oracles travel as aJointCurvature3Oraclescarrying"coupled_arm"and which arms it applies to, and the R side adds"not_requested","no_probe_indices","backend_unsupported"and"solve_failed". Reported on the fit asinner_skew_declined/inner_skew_arms_declined– the latter also on a PARTIALLY scored joint fit – and read back by the combined verdict, which now distinguishes a layer that was not assessed from one that is unscorable by construction (for those models the outer k-hat is the only reliability number available, permanently).Fixed:
.nl_inner_skew_at_theta()guarded its probe withreturn(NULL)written inside atryCatch()expression (gcol33/tulpa#298). That returns from the ENCLOSING function, so a fit hitting any of those guards had the wholeresreplaced byNULLby a diagnostic that was only meant to decline. The probe is now its own function.
tulpa 0.0.133
Every engine default now lives in one file (
R/settings.R). A default outer hyperparameter axis used to be written where it was consumed, so the same numbers appeared in several places at once: the field-SD axisexp(seq(log(0.1), log(3), length.out = 5))in five (the three single-block joint areal backends, the multi-block copy-block axis builder, and the bym2 / iid registry entries), the copy-coefficient axisc(0, exp(seq(log(0.1), log(3), ...)))verbatim in two, the wide intrinsic-precision axis in three,k_samples = 200Lin five, and the reported Pareto-k usable threshold0.7in seven..NL_GRIDnow holds one entry per DISTINCT default axis (keyed by what the axis measures –field_sd,gmrf_tau,gp_lengthscale,copy_alpha, … – so families that integrate the same quantity share the entry and move together), with.NL_RECENTER,.NL_ST_GRIDand.NL_DIAGalongside it for the auto-recenter policy, the spatiotemporal driver’s own grid, and the diagnostic thresholds. Every call site reads an accessor (.nl_grid_axis(),.nl_grid_par(),.nl_recenter(),.nl_st_default(),.nl_diag()); no number is restated anywhere else. All 20 default axes and every registrydefaults()closure are byte-identical to what they produced before, verified node by node..NL_FAMILY_AXESbinds family + path -> axis, so a new family cannot be half-registered. The registry’s per-familydefaults()closures and the auto-recenter’s axis-provenance check (#293) now read the SAME binding, and a plain Cartesian default is one line (.nl_fill_family_axes(p, "bym2")) instead of a hand-writtenis.null()/expand.grid()block per family. This closes a gap the #293 fix left: provenance carried a hand-maintained list of two fields (sigma_grid,tau_grid), so an engine default on any OTHER axis (gp_var,phi_gp_grid,ar1_rho,mo_lengthscale, …) coming back in through a wrapper’s prior was still read as a user pin. Every defaulted axis is now covered.typenarrows the comparison to the axis that one path-and-family lays – passed explicitly, never inferred fromblock$type, since a joint areal block carriestype = "icar"while itssigma_griddefault comes from the joint path and the icar REGISTRY entry defaults a precision axis instead.Two source-level tests keep the defaults from re-scattering: a geometric axis over literal bounds outside
R/settings.Rfailstest-settings.R, as does a restated Pareto-k threshold ork_samplesdefault.test-settings.Ralso pins every axis to its exact nodes – these numbers are the engine’s behaviour on any fit that does not name its own grid, so changing one is deliberate enough to update a test..default_tau_grid()and.nl_default_sigma_axis()are gone; call.nl_grid_axis("gmrf_tau")/.nl_grid_axis("field_sd"). Pre-release, so no shim.
tulpa 0.0.132
-
The #289/#290/#291 auto-recenter now fires for wrapper-package fits: axis provenance replaces field presence (#293). The rescue’s guard was
!is.null(prior$sigma_grid)– “the caller named a grid, so it is an override”. A wrapper package that computes the engine’s own default axis itself (tulpaObs’soccu_cover()does, because it also derives the copy arm’s amplitude axis from that vector and hands the same axis to several blocks) writes a non-NULLsigma_gridon a fit where the USER named nothing, so every such fit looked pinned and the recenter never ran.SIGMA_GRID = "auto"was inert for exactly the fits it exists to rescue.Provenance is now explicit, in one predicate (
.nl_axis_is_pinned(),R/nested_laplace_auto_grid.R) that all four rescues share – the joint single-block, the joint multi-block copy block, the standalone registry (icartau_grid/bym2sigma_grid) andfit_st_nested(). An axis counts as a DEFAULT (recentre-able) when it is absent, when it is marked with the newauto_grid(), or when its node set is exactly the engine’s own default axis for that field – a grid identical to the default carries no information a pin would add. Anything else is a pin and is never moved.-
auto_grid(x)/is_auto_grid(x)(exported) let a wrapper package declare an axis it defaulted rather than one the user chose. The marker is an attribute, recorded and stripped once at the front door (.nl_grid_provenance()), so no downstream consumer ever sees an attributed numeric. -
control$auto_recenter = FALSE(new, ontulpa_nested_laplace(),tulpa_nested_laplace_joint()andfit_st_nested()) is the opt-out that integrates any grid exactly as given, the engine’s default axis included. -
res$outer_grid_recenter_declinedreports why a"fixed"placement stayed fixed:"grid_not_collapsed","axis_pinned","no_usable_curvature","auto_recenter_disabled","grid_knobs_overridden","refit_failed". An inert rescue was previously indistinguishable from one that was never needed, which is how #293 went unnoticed through #289 -> #292. - Axis-name resolution is shared (
.nl_axis_alias()): the same axis is spelledsigmain a single-block grid,b<k>.sigmain a multi-block one, andthetawhen a single-axis vector grid is coerced for the regime diagnostic. The multi-block rescue matched only the prefixed spelling and the registry rescue carried a hard-codedvalue_axis_namefor the coerced one; both now go through the alias set, plus the “a lone log-tagged axis IS the family’s scale axis” fallback. - The default field-SD axis is single-sourced as
.nl_default_sigma_axis()(it was copy-pasted across the three single-block backends, the multi-block copy-axis builder and the bym2 registry default).
Contract change: handing the engine’s own default axis in explicitly used to hold the grid fixed; it now recentres like the defaulted axis it is (
test-nested-laplace-registry-auto-grid.Rupdated, and that fit’s k-hat now agrees with the defaulted-grid fit’s on a collapsed grid). Usecontrol$auto_recenter = FALSEto hold a grid where it is. -
tulpa 0.0.131
-
The 0.0.130 auto-recenter now also engages under the default
diagnose_k = FALSE, and coversfit_st_nested()’s spatiotemporal grid (#291, #292). Two gaps left open by #289/#290:- The joint driver’s rescue only fired when
control$diagnose_k = TRUE, because it readpareto_k_mode_u/cov_u– fields only the full outer Pareto-k diagnostic populated. Production batch runs defaultdiagnose_k = FALSE, so a collapsed fit stayed railed regardless of the auto sigma grid..joint_attach_pareto_k_placement()now computes the same (mode, covariance) via the same.joint_pareto_prepare()the full diagnostic scores its proposal from, and runs whenever the grid has collapsed onto an edge, independent ofdiagnose_k. -
fit_st_nested()’stau_spatial x tau_temporal [x rho]tensor grid was the one nested-Laplace family #289/#290 left out, since it had no mode-find machinery to reuse..st_auto_grid_rescue()(R/fit_st_nested_auto_grid.R) adds one: a box-constrained L-BFGS-B mode-find (finite-difference gradient, no analytic one available from the compiled kernel) over the unconstrained per-axis coordinate – log for the two precision axes,qlogis((rho+1)/2)for ar1’s autocorrelation – seeded at the collapsed grid’s own highest-weight cell, then a refit on a grid recentered at the mode. Same trigger as every other family (pareto_k_regime == "collapsed_edge"), one attempt only, bounded 6 nats past the default axis on each side, and declines whenever a grid-construction knob (tau_lower/tau_upper/n_grid_*/rho_lower/rho_upper) was set explicitly.
- The joint driver’s rescue only fired when
tulpa 0.0.130
-
Outer hyperparameter grids auto-recenter on a collapsed boundary instead of railing silently (#289, #290). Every nested-Laplace family built its outer grid from a fixed default axis in original coordinates (e.g. bym2/icar/car_proper’s
sigma_grid = exp(seq(log(0.1), log(3), length.out = 5))). A fit whose field-SD posterior mode sat above the top node collapsed every outer weight onto that boundary node (pareto_k_regime = "collapsed_edge"), silently – on Michael Glaser’s 78 real EVAoccu_coverfits, 10 railed the 5.0 sigma ceiling.The fixed grid is now a starting axis, not a ceiling: a fit that collapses onto a boundary re-centers via the mode-Hessian its own outer Pareto-k diagnostic already computes (
R/nested_laplace_auto_grid.R) and refits, reusing that curvature rather than running a second optimizer. An explicitsigma_grid/tau_gridalways wins – auto-recenter only engages when left at its default. Wired through:- the joint driver’s single-block backends (bym2/icar/car_proper) and multi-block copy blocks, with a second attempt composing a light default PC(U=3, alpha=0.01) prior on sigma for a genuinely unidentified (near-separation) mode that geometry alone cannot settle;
- the standalone
.NL_REGISTRYpath (icar’stau_grid, bym2’ssigma_grid), one recenter attempt, reusing the joint path’s generic axis-tagging and FD-Hessian machinery; -
fit_spde()’s explicitmethod = "grid"path, which now attaches the samepareto_k_regimediagnostic (visibility only –fit_spde()’s defaultcontrol$methodis already"ccd", the mode-Hessian path, so"grid"is a deliberate opt-in the fix respects rather than overrides).
Byte-stable when the mode already sits inside the old default axis (a no-op branch, exercised by regression tests in
test-nested-laplace-joint-auto-grid.Randtest-nested-laplace-registry-auto-grid.R). car_proper (itsrhoaxis is unguessable, same limitation the existing outer-k-hat diagnostic already has) and MCAR (log-Cholesky axis geometry, a materially different recentering problem) are out of scope;fit_st_nested()’s spatiotemporal grid got the diagnostic only, since it has no existing mode-find machinery to reuse – tracked as #291.
tulpa 0.0.129
-
temporal_gp()now reaches a fitter (#287). The constructor was exported, documented, and carried atulpa()worked example, buttulpa()rejectedtype = "gp"by name,validate_temporal_gp()had no caller, and nothing inR/orsrc/populatedTemporalGPDataor setTemporalType::GP. The C++ was not the gap –tulpa_priors_temporal.hhas carried a complete templated temporal-GP prior, in both parameterizations, the whole time. What was missing was the marshalling.tulpa(y ~ x, temporal = temporal_gp("t"), mode = "hmc")now fits: the spec is validated at the front door,build_sampler_model_inputs()acceptstype = "gp"and fillsTemporalGPDatafrom the unique time instants, and the field’s two hyperparameters are sampled jointly with it. They are named too –log_sigma2_temporal_gp/logit_phi_temporal_gprather thanparam[3]/param[4]. The field is sampler-path only (there is no nested-Laplace kernel laying a grid over a dense T x T Gaussian), and it cannot yet share a fit with a spatial orlatent()block; both now say so. -
temporal_gp(cov =)selects a kernel (#288).cov,nuandperiodwerematch.arg-validated, documented with their closed forms, carried on the spec object – and read by nothing. The live density hardcodedexp(-dt/phi), socov = "gaussian"andcov = "periodic"silently fit an exponential field: a misspecified prior with no error or warning.New
src/temporal_gp_kernel.hholds the covariance templated over the scalar type, so the sampled(sigma2, phi)can be autodiff variables – which is why the old plain-double kernels could never have been wired here. The exponential kernel (equivalently Maternnu = 0.5) is an Ornstein-Uhlenbeck process, so it keeps the exact O(T) Markov recursion and its numbers are unchanged; Matern 3/2 and 5/2, Gaussian and periodic have no finite-dimensional state-space form and are evaluated from a dense T x T Cholesky, in both the centered and non-centered parameterizations.Matern is offered at
nuin {0.5, 1.5, 2.5} only – the smoothnesses with a closed form – and anything between them is now rejected at construction rather than quietly run as exponential.test-temporal-gp-frontdoor.Rasserts the five kernel configurations DISAGREE (a test asserting they agree would have passed before this), that Maternnu = 0.5reproduces the exponential fit to the bit, and that the periodic kernel tracks its period.
tulpa 0.0.128
-
The nested-Laplace entry points no longer each carry their own fingerprint and skew boilerplate (#286).
cpp_nested_laplace_*was already well factored on the part that matters – every entry builds its latent blocks and hands them to a shared kernel – but the plumbing wrapped around that call was copied per model.The structural fingerprint is the one that punished a mistake quietly: it keys the grid checkpoint, so a copied block that folds the wrong structure produces a checkpoint that MATCHES across runs it should not, and a resumed run then reuses cells computed under different inputs. New
tulpa::NlFieldIdentity(nested_laplace_checkpoint.h) names each structural group once –areal(),nngp(),hsgp(),temporal()– and each entry point chains the groups it carries:const std::uint64_t struct_seed = tulpa::NlFieldIdentity("st_icar") .areal(n_spatial_units, adj_row_ptr, adj_col_idx) .temporal(temporal_type, n_times, cyclic, temporal_idx) .seed();Fold order is part of the fingerprint, so the optional members (the BYM2 mixing scale, the standalone temporal field’s group count) sit in the slot they have always occupied and every seed is unchanged. New
test-nl-field-identity.Rchecks that bit for bit against the folds the entry points used to write by hand, for all eleven field models, and pins that each structural input still moves the seed.The 1-based-to-0-based
skew_idxconversion had nineteen copies acrosssrc/; all now call thetulpa::unwrap_skew_idxthat already existed for it inlaplace_spec_fit.h. The five spatiotemporal entries returned their temporal axes through a repeated pair of lines carrying anar1-only conditional; that isnl_attach_temporal_gridsnow.No behaviour change: the entry points shed 225 lines of plumbing, and the fingerprint values and returned lists are identical.
tulpa 0.0.127
-
1015 lines of unreachable C++ removed from
src/(#284). Four headers and a set of functions nothing called. Each was checked by grepping the whole tree for the symbol, then by compiling all 95 translation units after the deletion – nothing referenced any of it, so the change cannot alter behaviour.Deleted whole:
hmc_latent_grad.h(a closed component superseded byhmc_latent.h’sapply_first_zero),hmc_tvc_autodiff.handlog_post_car_proper_det.h(never#included anywhere), andhmc_temporal_gp.h. That last one WAS included, byhmc_sampler.h, but every symbol in it was unreachable: its namespacetulpa_temporal_gpis named nowhere but its own opening and closing brace. The live temporal GP istulpa_priors_temporal.h, which is templated for autodiff and writes the exponential-kernel state-space recursion and the non-centred transform inline; the deleted header was a plain-double implementation that could not serve the gradient modes the sampler uses. Its fivetemporal_cov_*kernels were a second, untested copy of covariance math the canonical templatedtulpa_svc::compute_covalready holds, pinned bytest-cov-kernel.R.Deleted in place:
gp_nngp_gradient_w_analytical(hmc_gp_gradients.h),multiscale_gp_log_lik(hmc_gp_log_lik.h), andhmc_tvc.h’s dead hyperparameter-prior and gradient block (log_prior_tau_pc,log_prior_rho_uniform,log_prior_rho_beta, the finite-differencerw2_gradient,parse_tvc_structure). The live TVC priors are intulpa_priors_tvc.h(PC prior on log-tau viapc_prior.h, Uniform(-1, 1) on rho) and the live gradients are the analytic ones inhmc_tvc_grad.h.SelectedInverse::atwas reported as dead and is not:implicit_diff.h:196calls it asH_inv.at(...), which a search for the qualified name misses. It stays.
tulpa 0.0.126
-
The small-dense Cholesky core takes its storage layout as a required argument (#285).
linalg_fast.hshipped two triangular-solve pairs on opposite conventions with names that said neither:chol_forward_solve/chol_back_solveindexed row-major with an explicit leading dimension,tri_solve_lower/tri_solve_upper_transposeindexed column-major withnas the stride. The two are related by transposition, so a factor handed to the wrong pair does not crash, does not produce NaN and trips no dimension check – it solves against the transpose and returns a plausible vector. #283 was that mistake on a cuSOLVER factor, and it corrupted every NNGP fit with 51 or more locations while staying finite and ordinary-looking.There is now one implementation of each solve, templated on
tulpa_linalg::TriLayout, and the layout is a required template argument rather than something a call site inherits from argument order:tri_solve_lower<TriLayout::RowMajor>(L, n, ld, b, y)andtri_solve_lower_transpose<...>.chol_factor_lowerandnngp_moments_from_chol– the producer and the consumer that must agree with the solve – carry the same parameter, so a call site states the whole convention it is asserting.chol_log_detdoes not: the diagonal sits ati * ld + iunder both. The layout folds at compile time, so the emitted arithmetic and its summation order are unchanged.tri_solve_lower’s old column-major body had no callers and is gone; the one caller oftri_solve_upper_transpose(the dense mass matrix’s momentum draw, which reads an Eigen factor and so really is column-major) now says so. Newtest-tri-solve-layout.Rstates the contract – each routine reads the lower triangle of the matrix its declared layout spells out of the buffer – and checks it on matched and mismatched buffers, including the cuSOLVER-shaped one whose opposite triangle still holds the input.
tulpa 0.0.125
-
The batched CUDA Cholesky returned a column-major factor that every consumer read row-major (#283).
batch_nngp_scatterhands the NNGP neighbour-covariance factorizations tocuda_batched_choleskyonce the batch reaches 50 locations. cuSOLVER writesL[i][j]at offsetj*k + iand leaves the opposite triangle holding the input;chol_forward_solve/chol_back_solveindexi*k + j. The accepted “factor” was therefore the input covariances with a Cholesky diagonal – finite, ordinary-looking, and wrong. Every NNGP fit with 51 or more spatial locations on a machine with usable CUDA was affected; below 50 the CPU path ran and was always correct. On a 150-point unit-square fixture (exponential covariance,sigma2 = 0.9,phi_gp = 0.4) the conditional variances were off by up to 0.28 in absolute terms and 47 of 150 nodes were pushed onto the 1e-10 variance floor, against a true minimum of 2.5e-02. After the fix both sides of the dispatch threshold agree with an independently computed reference to 3e-16 and nothing floors. -
The GPU factor is now verified before it is accepted (#283). The batched call also ignored cuSOLVER’s per-matrix
infocodes, so a non-positive- definite neighbour set came back as a partially written factor that looked valid.cuda_batched_choleskynow reads them, andbatch_nngp_scatterchecks the returned factor for one batch element against the CPU factorization before using the batch, falling back to the CPU for all of it on mismatch – one extram^3factorization against 50+ matrices. The fallback now also restores the original covariances, which a partial GPU write had been corrupting. -
test-nngp-prior-scatter.Rstraddles the 50-location dispatch threshold and compares conditional variances against a from-scratch reference, so the CPU and GPU paths are held to the same answer. A fixture that stays under 50 locations exercises only the path that was already right.
tulpa 0.0.124
-
The NNGP prior scatter is checked against the matrix it claims to build (#278).
apply_nngp_full_prior_denseandapply_nngp_full_prior_sparsewere documented as the same math in different containers, and a measuredlog|H|gap of 2.9e-03 between them put that in doubt. AssemblingLambda = (I - A)' D^-1 (I - A)independently from the(alpha, cv)the batched scatter returns settles it: both reproduce it to ~1e-16 RELATIVE at every neighbour-set size from 2 to 10. The gap was an ABSOLUTE difference on matrix entries of magnitude 1e13. The dense twin is deleted – it had no callers onceblocks_require_sparse()pinned NNGP to the sparse Newton path – and the survivor is held to the definition by the newtest-nngp-prior-scatter.R, which also pins the gradient to-Lambda w. -
Where the 1e13 comes from was tracked to #283.
nngp_moments_from_cholfloors the conditional variance at 1e-10, and 47 of 150 nodes hit that floor on the reference fixture atnn = 8, putting1e10onLambda’s diagonal and takingcond(Lambda)to numerically infinite. That conditioning, not a defective Hessian, is what stalls a Newton solve at largenn. The floor turned out to be a symptom rather than the cause – see 0.0.125, where the broken CUDA factor behind it is fixed and nothing floors on that fixture.
tulpa 0.0.123
-
The last standalone Newton loop is gone (#282). #277 left
cpp_laplace_fit_spde_precomputed– the fixed-hyperparameter fit behind the fractional/rational SPDE path – on its own solver, and with itspde_run_single_fitandlaplace_newton_solve_sparse. It is now a one-cell run of the same joint multi-block driver every other SPDE/GP entry takes:make_spde_block_precomputedseeds the block’sSpdeQBuilderfrom the CSC.spde_rational_assemble()hands it, makesprep()the0.5 log|Q|normalizer only (the precision does not move with the cell), and leaves the latent uncentred – the auxiliary weightsxare not the fieldu = Pr x, and the proper SPDE prior already identifies the constant mode. Everything else –obs_indices, the H pattern, both prior scatters,log_prior– is shared with the FEM entries through the newspde_assemble_block. The path now inheritsgamma_3, grid-cell checkpointing and the per-cellscore_max/convergedreporting instead of needing a wiring pass each time.laplace_newton_solve_sparseandspde_run_single_fitare deleted. -
The precomputed SPDE log-marginal carries the RE prior normalizer (#282). The bespoke loop’s log-prior omitted
0.5 G (log tau_re - log 2 pi), so a fit with an iid RE block reported a marginal that was not comparable acrosssigma_re(a 3.4-nat error atG = 6,sigma_re = 0.7). The shared driver supplies it. The mode, the fitted linear predictor and the field are unchanged – measured across nine rational-SPDE fixtures,etaagrees to 3e-07 and the penalized objective to 1e-08, with two fixtures reproducing bit-for-bit; the residual movement is confined to the auxiliary-weight directions the rational precision leaves unidentified.
tulpa 0.0.122
The SPDE FEM assembly builds the operator order it was asked for (#280).
SpdeQBuilder::rebuild()branchedif (alpha == 1) ... else <alpha 2>, so everyalpha >= 3was assembled asalpha = 2with no error – a user asking for a smoother Matern field silently got thenu = 1operator.init()now takes the operator order and builds the chainM_0 = C,M_1 = G,M_j = G (C^-1 G)^(j-1);rebuild()is the binomial expansion ofQ = tau^2 K (C^-1 K)^(alpha-1)over it, so one loop covers every integer alpha and the sparsity pattern widens with the order instead of everynureusing thealpha = 2stencil. The analytic marginal-SE mirror.spde_precision_Q()carries the same expansion.alpha = 2is reproduced term for term, sonu = 1results are unchanged.(range, sigma) -> (kappa, tau)carriesnuin both coordinates (#279). The C++ conversion hadnuenteringkappabut nottau, which is thenu = 1special case; the general d = 2 relation istau = 1 / (sqrt(4 pi nu) kappa^nu sigma)(Lindgren, Rue & Lindstrom 2011), as the R side already used. Atnu = 2the oldtauwas ~14x too large, so a requestedsigmamapped to a different marginal SD, and the single fit and the nested integrator disagreed with each other. The conversion is now one function,spde_range_sigma_to_kappa_tau()insrc/spde_qbuilder.h, shared by the block factory and the implicit-differentiation entry.spatial_spde(nu = 0)is refused at construction (#281). The Matern parameterisation is degenerate there –kappais 0 andtauinfinite – so a fit could only report an infeasible cell (log_marginal = -Inf, no Newton iterations) with nothing saying why..validate_spde_nu()now requiresnu > 0and names both broken quantities.Joint-hyper NUTS refuses an integer
nu != 1. Its non-centered transform differentiates thealpha = 2assembly, so higher orders would have been sampled asnu = 1; the fixed-hyper sampler and the nested-Laplace path both assemble any integer alpha. Newtest-spde-nu-general.Rchecks the compiled assembly against an independently builtK (C^-1 K)^(alpha-1)atalpha = 1..4, the conversion against the closed-form marginal variance, and anu = 2field end to end.
tulpa 0.0.121
cpp_laplace_fit_gpandcpp_laplace_fit_spdeare now one-cell runs of the shared machinery (#277). Both were fixed-hyperparameter spatial kernels carrying their own Newton loop, so a feature added to the joint-multi driver had to be wired into them separately – which is what thegamma_3pass (#273) ran into. Each is now a thin wrapper: the samemake_single_arm, the samemake_nngp_block/make_spde_block, the same driver at one grid cell, reading the result back throughnl_grid_cell_to_result_list(). The equivalence is exact, asserted attolerance = 0intest-laplace-spatial-gp-spde-equiv.R, andgamma_3is now inherited rather than wired.The SPDE single fit no longer reports an off-mode mode. The mesh field is sum-to-zero centred after the Newton loop. The bespoke path centred it without moving the removed constant into the intercept, which shifts
etaaway from the mode the loop found, and then re-evaluatedlog_marginaland the Hessian there: a converged fit reported a fixed-effect score of ~0.47. The driver folds the constant into the arm intercept, soetais preserved. Fixed effects andlog_marginalmove for every fixed-hyperparameter SPDE fit; the mesh field is unchanged.NNGP is pinned to the sparse Newton path. Its prior scatters only into the sparse builder, and the dense route disagreed with it – measurably at
nn = 5, and atnn = 8a 300-iteration non-convergence returninglog_marginal = NaNagainst a 23-iteration convergence.blocks_require_sparse()(latent_block.h) now reads that requirement off the blocks: a block whose prior has onlyadd_prior_sparseforces the sparse path, instead of each caller rememberingforce_sparse. This closes the silent case where the dense path called an absentadd_priorand contributed nothing at all. NNGP is the only block whose dispatch changes; MCAR, HSGP-MO and the latent factor are already non-INDEXED_SINGLEand were forced sparse before.Nested fits report per-cell solve health.
log_det_Q,score_maxandconvergedjoinlog_marginal/n_iter/modeson every grid-driver result, so a grid fit can see which cells settled instead of only that the run finished.cpp_nested_laplace_nngp()accepts anoffset, which the generic driver already read offParsedArm;make_spde_block()takes its axes as(kappa, tau)directly viadirect_kappa_tau, so a fit handed the operator parameters does not round-trip them through the Matern conversion.fit_spde()withnu = 0now reports an infeasible cell (log_marginal = -Inf, no iterations) rather than iterating on a degenerate precision. The Matern parameterisation has no(kappa, tau)atnu = 0(kappa = sqrt(8 nu) / rangeis 0 andtauis infinite); this is the verdictcpp_nested_laplace_spde()has always returned there.
tulpa 0.0.120
-
Outer
pareto_kno longer over-flags collapsed-grid fits (#276). On a sharp hyperparameter posterior the outer grid collapses onto ~1 cell, the existing grid-mixture rescue cannot engage (its few bumps cover worse than the Gaussian), and the k-hat is left scored against a SYMMETRIC Gaussian proposal on a right-skewed variance-component marginal. A symmetric proposal against a skewed target has a heavy importance-ratio tail whatever the integration’s quality, so the k tracked the grid collapse and the marginal’s asymmetry rather than the fit. Surfaced by a collaborator’s 78-speciesoccu_coverrun in which 42/78 species binned as “problematic/unreliable” on a bare k threshold while their point estimates were sound.Three changes, all on the joint nested-Laplace backend (single- and multi-block paths alike):
-
A skew-normal proposal rescue. After the Gaussian / grid-mixture dispatch, a k-hat still above the good band is re-scored against a product of univariate skew-normals in the chosen Gaussian’s whitened coordinate, matched to the target’s PSIS-weighted mean, sd and skewness – estimated from the pass’s own draws, so it costs no extra inner solves and is automatically located and scaled where the target is. Adopted only if it strictly lowers the k-hat. Because a skew-normal has GAUSSIAN tails on both sides it can absorb asymmetry but never a heavy tail, so the rescue cannot launder a real tail problem:
test-outer-skew-rescue.Rasserts both directions, including a skewed HEAVY-tailed target on which the rescue is built, scored and rejected.Engagement is screened for significance, not just magnitude. A sample skewness has standard error
sqrt(6/n)(~0.17 at 200 draws), so a bare magnitude floor fires on noise: measured on a GAUSSIAN outer target, an unscreened floor adopted the skew proposal in 18% of RNG states, a two-SE screen in 5%, and the shipped three-SE screen in 0%. The screen reads the importance weights’ effective sample size, not the raw draw count. A grid-regime classifier.
pareto_k_regimereports"spread"/"collapsed_interior"/"collapsed_edge", withpareto_k_grid_edge_axes/pareto_k_grid_edge_sidesnaming the axes a collapsed mode sits against and on which side. Below two effective grid cells no axis carries resolved spread, so the outer integration has degenerated to a point evaluation andpareto_kis scoring a mode-Gaussian stand-in for the hyperparameter marginal rather than an integration. An interior collapse is benign; a boundary collapse is actionable (the grid may be too narrow). Axes the grid pins to a single value are excluded – pinned, not at a boundary. Read off stored weights, so it is attached even withcontrol$diagnose_k = FALSE.The context is surfaced, so a downstream bare-k threshold is not the whole story:
diagnostics()gainsouter_regime,grid_edge_axes,grid_edge_sidesandouter_skew_maxattributes plusouter_regime/outer_skew_maxsummary columns,print.laplace_diagnostics()prints the marginal’s skewness and a one-line reading of a collapsed regime, anddiagnostic_summary()raises a WARN with the widen-this-axis recommendation on an edge collapse.
The
pareto_kband itself is left to speak for the number it reports: the fix is the number, not a verdict override.sn_match()remains the single source of truth for the cumulant inversion; the proposal path adds only a vectorized sampler and log-density (sn_cdf()/sn_quantile()route through Owen’s T by quadrature per point, which is right for a few reported quantiles and unusable for hundreds of proposal draws). -
tulpa 0.0.119
-
gamma_3wired through the SPDE / GP bespoke Newton pair (#273 item 3).cpp_laplace_fit_gp,cpp_laplace_fit_spdeandcpp_laplace_fit_spde_precomputedare standalone, fixed-hyperparameter single fits (laplace_mode_gp()/spde_run_single_fit()) that route through their own Newton implementation rather than the joint-multi driver #272/#273 item 1 already wired – the nested “nngp” / “spde” registry entries integrate hyperparameters via the shared joint-multi machinery instead and were unaffected. Both the dense branch (laplace_newton_solve/run_spde_laplace) and the fully sparse CHOLMOD-only branch (laplace_newton_solve_sparse,n_x >= SPARSE_THRESHOLD) now acceptcompute_skew/skew_idx, matching the icar/bym2/car_proper/hsgp kernels’ existing surface. #273 item 2 (the coupled non-separable cubic-term derivation) remains open.
tulpa 0.0.118
gamma_3wired through the joint multi-block dispatch (#273). The inner-Laplace skewness diagnostic #272 shipped for every single-arm kernel and the joint driver’s single-block backends, but explicitly left the MULTI-block joint path (nested_laplace_joint_multi.R, used when a fit carries a per-group RE / trend field / arm-specific field block) unwired..nlj_multi_inner_skew_at_theta()closes that gap: the multi-block counterpart of.nlj_inner_skew_at_theta(), re-dispatching the SAMEcall_kernelat the fitted MAP grid cell withcompute_skew = TRUE(the C++ kernel already accepted the parameter; only the R-side threading was missing). Same defaults as the single-block path: every arm’s fixed-effects coefficients scored by default,NA(not a silently-wrong0) for a non-separable coupled arm.Combined outer/inner reliability verdict no longer conflates “not assessed” with “good” (#274).
.tulpa_combined_reliability()collapsed an unassessed inner layer (gamma_3 not computable for a given backend or likelihood) into the same verdict string as a genuinely good inner layer whenever the outer layer was flagged, so a batch consumer reading thereliabilitystring off many fits couldn’t tell “outer bad, inner genuinely fine” from “outer bad, inner never checked”. Every combination naming an unassessed layer now says so explicitly (“… not assessed”), symmetric in both layers (an unassessed OUTER layer – e.g. a multi-block, multi-axis grid that declines Pareto-k rather than apply a guessed support transform – gets the same honest treatment).Fixed a crash in the joint multi-block dispatch on a malformed per-arm index vector (#275, found while testing the #273 fix). A block’s per-arm
spatial_idx/temporal_idx/obs_idxentry shorter than that arm’s actual observation count (in particular, an empty vector for an arm the block is meant to “skip”) was read out of bounds by the C++ kernel’s per-arm index closure, crashing the R session instead of raising an error. There is no supported “this block excludes arm k” shorthand via a short/empty index vector – every arm needs a matching-length index vector for every block; a block’s contribution to an arm is excluded via that arm’sfield_coef = 0instead..multi_block_per_arm_idx()now validates every per-arm entry’s length across all 7 call sites (icar/bym2/car_proper, mcar, rw1/rw2/ar1, iid, miid, tgmrf, lf) and raises a clear error naming the block, the arm, and the expected/actual counts.
tulpa 0.0.117
-
Inner-Laplace skewness diagnostic: score the layer outer Pareto-k-hat doesn’t cover (#272).
pareto_kscores the OUTER hyperparameter-grid integration around a fixed inner Laplace; it read as a whole-fit verdict even though the inner Gaussian approximation to the latent-field conditional posterior is a separate, unscored layer – anoccu_coverbatch flagged 42/78 species “unreliable” on outer k-hat alone when their point estimates, governed by the healthy inner layer, were fine.gamma_3(src/inner_laplace_skew.h, the leading-order Edgeworth skewness estimate from Rue, Martino & Chopin 2009 Sec 3.2.3’s cubic correction, generalized from their augmented representation to tulpa’s generaleta = compute_eta(x)and to the joint multi-arm case) closes that gap: opt-in (control$diagnose_skew, defaultTRUE) and computed with one extra deterministic Newton solve at the fitted MAP grid cell, scoring every arm’s fixed-effects coefficients by default (control$skew_idxextends it). Declines toNA– never a silently-wrong0(“perfectly Gaussian”) – for a likelihood the formula cannot score (a coupled multi-process spec such as zero-inflation or tulpaObs’soccu_cover, or a family with no registered third derivative); this also caught and fixed a real bug in the diagnostic as first staged, where an entirely absent oracle silently summed to0 / sigma_i^3 == 0instead ofNA.diagnostics()/print.laplace_diagnostics()report a combined whole-fit verdict naming which layer degrades, if either does. Wired through every single-arm nested-Laplace kernel (icar/bym2/car_proper/temporal/nngp/hsgp/the ST variants/SPDE) and the joint driver’s single-block backends; validated intests/testthat/test-inner-skew.Ragainst a direct numerically-integrated exact posterior skewness (a rare-event binomial intercept), not just shape checks. Known remaining scope (joint multi-block wiring, a genuinely coupled-arm cubic-term derivation, the SPDE/GP bespoke large-nNewton pair) tracked in #273.
tulpa 0.0.116
-
Stale test fixed, no engine bug (#271).
test-tulpa-entry-nested.R’s “more than one random-intercept term alongside a block errors” test asserted a restriction gcol33/tulpa#265 (0.0.113-era, commit cd80b95) deliberately removed: every(1 | g)term on the nested-Laplace +latent()path now becomes its owniidblock integrated on the outer grid, so N random-intercept terms beside a block are no different in kind from one – the same changetest-smoother-re-integrated.Ralready covers beside a smoother. #265’s commit updatedR/tulpa.Rbut never touched this test, so it kept asserting the oldstop()and started failing with aNULLcondition once the guard it expected was gone. Bisected past the #267 auto-mode change #271 suspected as the cause:auto_select_mode()checkshas_latentbeforehas_re, so a model combining RE terms with alatent()block already routes tonested_laplace, notre_cov_gibbs, regardless of #267. The test now asserts the current, intended behavior (routing,re_block_index, and bit-exact equivalence to the direct multi-blocktulpa_nested_laplace()call).
tulpa 0.0.115
-
The joint Hessian sparsity pattern now covers a latent block reached by only one side of a coupled arm pair (#270).
HessianPatternGuard(introduced after the tulpaObs v0.0.101 pin, so this had never been checked) caughtoccu_cover()dropping 10592-124160 nonzero contributions per fit whenever a coupled ICAR field met either a correlated random-slope RE block private to the detection arm, or its own detection-arm beta under the rank-1 s2z fold path (fields aboveTULPA_S2Z_DENSIFY_MAX, default 256 units).The per-cell cross-Hessian scatter multiplies EVERY active dof of one coupled arm’s row – beta, RE, and any latent-block dof that row’s
idx/obs_indicesresolves to – against every active dof of another (or the same) coupled arm’s row sharing the cell.build_joint_hessian_pattern’s cross-arm section only ever registered the beta/RE part of that product; a block reached by just one side (a private random-slope block, or a field the other arm’sfield_coef = 0decouples) had no pattern entry for its cross term against the other arm’s beta/RE/latent dofs, even though the scatter produces a real nonzero value there whenever the coupling spec’s cross-Hessian for that arm pair is nonzero. A new section walks each coupled cell’s per-arm active-dof union (mirroring the scatter’s owncollect_coupled_row_latentsresolver) and adds the missing cross entries, scoped per cell so it costs no more than the scatter already does. Two regression tests reproduce both shapes on tulpa’s own bivariate test-coupling spec (test-cell-coupling-cross-hess.R), independent of tulpaObs.
tulpa 0.0.114
-
mode = "auto"no longer conditions a random-effect term’s SD at 1 (#267).tulpa(y ~ x + (1 | g))on the default mode reportedsd = 1, source = "conditioned", while the same term with a slope added ((1 + x | g)) had its whole covariance inferred – the richer model was handled better than the plainer one.auto_select_mode()took no random-effect argument at all, so a mixed model fell through to the same Tier-1 default a plain GLM reaches.autonow routes any random-effect term – intercept-only or slope – to the exact Metropolis-within-Gibbs covariance debias (re_cov_gibbs), which already treats a scalar(1 | g)as the degeneratec = 1covariance block, so no special-casing by term shape was needed. An explicitmode = "laplace"/"mala"/ … still conditions onsigma_re(defaulting to 1) when the caller names it directly – only theautodefault changes. Themessage()that reported the conditioning is now awarning(), so a script that promotes warnings sees it (same sub-issue as #265). -
One hyperprior convention for a random-effect covariance’s scale, chosen (#268). The nested-Laplace path integrates every scale axis (
icar,rw1,rw2,ar1’stau,iid) flat inlog(theta), by construction of the grid and its softmax weighting;tulpa_re_cov_nested()/tulpa_re_cov_gibbs()’sSigmaestimate carried a PC + LKJ prior by default. The two paths put different priors on the same statistical object, undocumented in either direction, so the RE SD from one backend was not the RE SD from the other.tulpa_eb()andtulpa_re_cov_nested()now share ahyperpriorargument,"flat"(default) or"pc_lkj":"flat"matches the nested-Laplace convention everywhere else in the engine;"pc_lkj"opts into the weakly-informative PC + LKJ priorre_cov_pc_lkj_prior()builds, still available on request.tulpa_re_cov_gibbs()’sSigma | bconjugate draw cannot go fully flat – an improper prior is not a valid target for that step – so it keeps its existing minimal-proper Inverse-Wishart default (prior_df = n_coefs + 1), documented as the closest analogue. Seevignette("priors").
tulpa 0.0.113
-
A random-effect term on the nested path has its SD integrated instead of conditioned at 1 (#265).
tulpa(y ~ s(x) + (1 | site))reportedsd = 1, source = "conditioned"– on a formula whose smoother hyperparameter was integrated, the random effect was the one variance component the fit never estimated, and 1 is the value nobody supplied. There was no argument combination that estimated it:mode = "eb"was overridden by the smoother redirect (#266) and the RE-covariance integrators are reached only when a term carries slopes.Each
(1 | g)now becomes aniidlatent block, so its SD is one more axis of the outer grid beside the smoother’stau. The block type, its integration and its recovery already existed (the #86 coupled field + RE capability); only the front door was not using it, and.tulpa_fitter_args()’s own comment already named this as the intended treatment. Consequences:- Several RE terms now work. The path previously refused more than one outright (“supports at most one random-intercept term”); each term is its own block.
-
sigma_resupplied explicitly still conditions, as the one-pointsigma_gridtheiidregistry entry documents – conditioning is the degenerate case of the same path, not a second one – andVarCorr()still labels itconditionedrather than claiming the data produced it. -
VarCorr()reports the integrated posterior’s median, not its mean: a variance component at few groups is right-skewed, so its mean sits above its bulk by construction. -
ranef()reports every group. The RE blocks are appended LAST, which is what makes their latent segment addressable as the trailingsum(n_groups)columns without re-deriving any other block’s width – a second source of truth for something the driver already knows. Its exact-tail-width guard cannot fire once the RE shares the latent vector with a field block, so without this the accessor would have returned the empty table #264 just removed. - A random slope beside a smoother still refuses, now naming the reason (an
iidblock has noZdesign) and pointing at the backends that do fit a slope covariance.
Recovery is asserted as coverage, not as a point tolerance. The grid integrates the SD under a prior flat in
log(sigma), the convention every nested scale axis uses; that does not shrink, so at G = 15 the posterior is wide and its point summary runs above the truth (mean of medians 1.02 against 0.9 over 6 seeds) while the 95% interval covers the truth 6/6. Asserting|est - truth|would encode the +0.12 as the target. Whether the engine should carry one hyperprior convention across the nested and RE-covariance paths is #268, deliberately not bundled here: a PC prior on theiidaxis alone would trade the cross-path inconsistency for one inside a single fit.
tulpa 0.0.112
-
An explicit
modeis no longer silently overridden by a structural redirect (#266).tulpa(y ~ s(x) + (1 | site), mode = "eb")fittednested_laplaceand reportedselection_reasonas thoughmodehad been"auto", so nothing on the fit recorded that the requested inference method had been swapped. The redirect machinery now carries that:-
select_inference_mode()marks a selection explicit (anything butmode = "auto") and keeps the literal request, so a later redirect can name what it overrode. -
.sel_redirect()records the override insel$overriddenand appends a clause to the reason. The clause is re-appended to whatever reason the selection ends up carrying rather than only the one that recorded it: each redirect replacessel$reason, so in a chain (slopes then a smoother) the later one used to drop the statement and leave the fit looking as though nothing had been overridden. Only the first override is recorded, since the request the user actually made is the one worth naming. -
tulpa()warns, and stamps the machine-readablefit$mode_overridden(requested+ the backend it would have used). Awarning()rather than amessage(), so a script that promotes warnings, or a chunk that traps them, sees it.
The warning is opt-in per redirect site (
notify =), because two different things were being conflated. A smoother sending aneb/agqrequest to the nested kernels takes away the random-effect SD those two would have estimated, and warns. A random slope undermode = "laplace"has no scalarsigma_reto condition on, a temporal field has no conditional-Laplace kernel, and an SPDE field redirected to thespdebackend is the same mode and tier reaching its own integrator – those are documented routes for the structure, not capabilities taken away, so they are recorded on the fit without warning on every fit. -
Dead
src/hmc_spatiotemporal.hremoved (#261). Unreachable: its only include washmc_sampler.h, no external call site referencedtulpa_spatiotemporal::, everything in it lived inside that namespace, and it was insrc/rather thaninst/include/tulpa/so noLinkingToconsumer could reach it either. It had also diverged from the live path (src/tulpa_priors_st.h) in two places that were fixed only on the live side: it overstated the cyclic RW1/RW2 rank by one, and hardcodedrank_space = S - 1against the #241 component fix. Itsst_sum_to_zero_penaltyduplicated the live one. A reader grepping for the ST rank found the wrong copy first, which is the shape that made gcol33/tulpaRatio#12 possible.
tulpa 0.0.111
-
ranef()reports the per-group posterior on both RE-covariance backends (#264). It returned a 0-row data frame for a fit from either integrator – on exactly the fits whose free covariance over correlated slopes is the point, and indistinguishable from a model carrying no random effects at all. Both backends do hold the per-group information, and both now report it:-
tulpa_re_cov_gibbs()samplesbin its Metropolis-within-Gibbs sweep and threw the draws away at the end of each sweep. The compiled sweep now records them (fit$re, one column per (block, group, coefficient), row-aligned with thebetadraws so a row is a joint state), andranef()summarizes them as the exact posterior: mean, SD and 2.5%/97.5% quantiles.posterior_predict()picks the same draws up through.re_draws_mat(), so its replicates carry the random-effect uncertainty instead of falling back to a population-level linear predictor. -
tulpa_re_cov_nested()has a Gaussian per-group posterior at every integration node. Each node’s conditional mean and marginal variance are retained (fit$re_nodes/fit$re_var_nodes, the inner solve now being asked for its covariance blocks), andranef()reports the exact moments and quantiles of the weighted mixture of them – so the interval carries both the within-node curvature and theSigmauncertainty. The interval inverts the mixture CDF rather than assuming normality around the mean: a mixture over a skewedSigmaposterior is itself skewed. New.nl_gauss_mixture_summary()is that summary (verified against Monte Carlo, and against the mixture CDF at its own returned quantiles); it is the continuous counterpart of the discrete.nl_wtd_quantile().
The adaptive Gauss-Hermite inner marginal (
control$re_cov = "aghq", orn_quad > 1) integrates each group out by quadrature and so forms no per-group posterior at all. It now says that, with the two modes that do report one, rather than returning the empty frame:ranef()errors on the statedfit$ranef_unavailablereason.?ranefdocuments what each backend reports and why. -
tulpa 0.0.110
tglmm()andtgam()are removed;tulpa()fits both model classes. The two doors dispatched throughtulpa()and returned a byte-identical fit, so they carried no engine of their own: what they added was a signature missingspatial/temporal, a refusal of the structures outside their model class, and a subclass that unlocked a richerprint(). The first two are reach removal on a call that would otherwise fit the model correctly, and the third is now driven by the fit instead (below).R/doors.R, the.TULPA_DOORSregistry, and thetulpa_glmm/tulpa_gamsubclasses are gone; the GLMM and GAM formulas are unchanged, sotulpa(y ~ x + (1 + x | g))andtulpa(y ~ s(x))fit exactly what the doors did. The README now shows both.A fit reports the structure it carries, not the function that produced it (#262). The random-effect covariance and the smoother table were printed only for a fit that came through a door, though the metadata behind both is attached by
tulpa()itself (fit$smooth_termsatR/tulpa.R,VarCorr()on anytulpa_fit).print.tulpa_fit()now ends in.print_re_section()+.print_smooth_section(), each silent when the fit has no such structure, andplot(fit, type = "smooth")draws the fitted curves for any fit carryings(...)terms.print.tulpa_nested_laplace()composes the generic fit body after its own hyperparameter report, so a smoother fit reports its fixed effects too – that tier’s print method had replaced the generic one rather than extending it, so those were missing entirely.VarCorr()reportedsd = 1, conditionedfor a fit that integrated Sigma (#263)..varcorr_from_sigma()read only$Sigma, whiletulpa_re_cov_gibbs()/tulpa_re_cov_nested()report the posterior mean under$Sigma_mean, so resolution fell through to the conditioning fallback and invented asigma_rethe user never supplied – on the very fits whose free covariance is the point. Both fields are now read, with[[rather than$: a gibbs fit carriesSigma_drawsalongside, making$Sigmaan ambiguous partial match there while it would silently resolve on a nested fit. A conditioning fit is still labelledconditioned.
tulpa 0.0.109
-
The intrinsic RW rank is exported, so a linking package can consume it instead of carrying its own copy (gcol33/tulpaRatio#12).
rw1_rank()andrw2_rank()move fromsrc/hmc_temporal.hinto the exportedtulpa/sum_to_zero.h, alongside the augmented rank they feed. They already encoded that a cycle-graph Laplacian still annihilates only the constant, so a cyclic RW1 has rankT-1and a cyclic RW2T-1as well; a consumer computingcyclic ? T : T-1inline overstates both by one and biases thetauposterior.tulpa_temporal::rw1_rank/rw2_rankkeep resolving, so the engine’s own call sites are unchanged.
tulpa 0.0.108
-
An areal field’s component partition is now set with its adjacency, so a consumer cannot leave the field unidentified (gcol33/tulpaRatio#19). The sum-to-zero augmentation that makes an intrinsic ICAR / BYM2 prior proper iterates over the field’s connected components, and a default-constructed
GraphPartitiondescribes zero nodes and reports zero of them. A caller that assigned the CSR adjacency alone therefore got an augmentation that pinned no direction at all, whilen_spatial_componentskept its own default of 1 and the rank normalizer went on crediting+0.5 log taufor the pin that was never applied.Nothing looked wrong from R.
icar_center_fieldstill centres the field on its way into eta, so the linear predictor stayed invariant and the field’s shape and the fixed effects came out right; only the level was loose. On tulpaRatio’s 8-unit chain the reportedmean(phi_spatial)was +200.9, -342.8 and +423.1 at three consecutive seeds against a legacy level held within 0.02 of zero, andtau_spatialsat at 16.89 against 11.90 with a seed-to-seed spread of 0.42 – the variance component had a different posterior, not just a different origin. A chain also spends its adaptation and treedepth on a flat direction, which is what makes the field’s convergence diagnostics meaningless while it does.ModelData::set_spatial_adjacency(n_units, row_ptr, col_idx, n_neighbors)now sets the CSR arrays, the partition and the component count together; the engine’s own loader uses it at both of its sites, andcompute_param_layout()rejects a field whose partition does not describe its adjacency, alongside the existing PC-range-anchor guard. After the fix the same three seeds report a level of -0.003 and atau_spatialof 12.05, inside the legacy spread. ABI 39 -> 40. No struct layout or callable changed; the bump makes a consumer built against 39 report “rebuild required” at first NUTS use rather than tripping the new guard.
-
The inner Laplace solve now reaches stationarity at a large random-effect scale (gcol33/tulpa#259, gcol33/tulpa#260). With #255 fixed, solves at a moderate scale arrived; at a large one they still stopped short, returning a joint score of 1e-04 with
converged = TRUEwhile the stationary point sat 1e-05 away in relative terms. Nothing silently wrong shipped – the settled-mode gate in.laplace_exact_core()refuses the exact outer gradient there – but the fits did not arrive. They arrive now: the worst residual over the reported configurations drops from 3.4e-04 to 4.7e-10, and each returned mode agrees with the observed-curvature stationary point to 1e-12 relative or better.The cause is neither a cycle nor a conditioning floor but plain local divergence. The Newton weight is the working (expected / quasi-likelihood) one wherever it differs from the observed curvature, so the error map near the mode is
I - Hw^-1 Ho, which contracts only while every eigenvalue ofHw^-1 Hois below 2. On theneg_binomial_1fixture atphi = 6,sigma_re = 5the largest is 2.23: the iterate walks away from the mode geometrically, losing a few parts in 1e9 of objective per step, and the stall test then reads the growing step as a floor and stops.inverse_gaussian(Fisher weight1 / (phi mu)) does the same, so this is not one family’s quirk; families whose Newton weight already is the observed curvature cannot reach it at all.No acceptance rule reading the objective can fix that. The penalized log-posterior is stationary at the mode, so a step gains about
decrement / 2while the objective’s own accumulation noise is8 eps |obj|; on these fixtures the two cross at a joint score near 1e-6, which is exactly where every affected solve stopped. Tightening the test makes it worse – an Armijo sufficient- decrease condition rejects genuine progress as noise and left the worst residual at 1.4e-05, three of the reported rows unimproved and one cleanphi = 0.5solve regressed from 4.9e-10 to 5.7e-07.So the steering moves to the Newton decrement
g' H^-1 g, which is already computed every iteration, is the affine-invariant distance to the mode, and keeps full relative precision exactly where objective differences are noise. Below the near-mode gate the scale the line search opens with (newton_trust_scale) halves whenever the decrement grew – the previous step overshot – and relaxes back toward 1 by 1.5 whenever it fell. A solve whose decrement never grows below the gate holds the scale at 1 and takes exactly the trial sequence it always did, so every converging fit, the whole #255 fixture (81 iterations atphi = 4, 323 atphi = 6) and the rational-SPDE case the stall path exists for are unchanged. Newton convergence reads the proposal, not the damped step. Both the tolerance and the stall test now key on
max|delta|rather thanmax|step_scale delta|. Convergence is a property of the iterate –H^-1 gis small exactly whenxis stationary – whilestep_scaleis the line search’s choice about how much of that proposal to trust, and conflating them fails in the dangerous direction: a search that had to damp a large proposal to nothing has not arrived, it has failed to move. With the trust factor able to open at2^-20this is reachable rather than hypothetical, a proposal of 1e-6 damped to the floor otherwise clearing a 1e-12 tolerance. Solves that take the full step are unaffected, since the two quantities coincide there.Two probes make the mechanism testable from R rather than inferable from where a fit landed:
cpp_newton_trust_probe()replays the damping schedule over a supplied decrement sequence, andcpp_newton_converged_probe()returns one convergence verdict at a given accepted step scale.dev_notes/probe_inner_stationarity.Rgainscurvature(the eigenvalues ofHw^-1 Hoat the mode, i.e. how far the working weight understates the true curvature) andbigscale(the residual table above).
tulpa 0.0.106
-
The Newton stall test no longer reads slow convergence as a converged mode (gcol33/tulpa#255).
newton_converged()carried a rescue path for an ill-conditioned Hessian, whereH^-1amplifies the gradient’s rounding residual into a spurious step andmax|delta| < tolcan never fire. It detected that as a Newton decrement that had stopped halving – but the decrement shrinks as the square of the convergence rate, so any solve converging linearly at rate 0.707 or slower looked identical to one that had hit its conditioning floor.neg_binomial_1is that solve: its quasi-likelihood Newton weightmu / (1 + phi)sits far below the observed curvature at largephi, and the measured rate reaches 0.707 betweenphi = 3(0.57) andphi = 4(0.70). Those fits stopped 30 iterations in at a joint score of 7e-04 instead of 1e-11, reportedconverged = TRUE, and cost the exact outer gradient five digits (relative error 1.0e-04 against a central difference of tulpa’s ownlog_marginal, against 1e-10 atphi <= 3).What separates the two cases is whether the step is still shrinking: a converging solve sets a new shortest step every iteration however slowly, while one at its conditioning floor bounces around it (on the rational-SPDE fixture the step wanders over 3e-6 .. 4e-5 with per-iteration ratios from 0.25 to 7). The stall test now runs on that, so the affected solves are left to finish – 81 iterations at
phi = 4, 323 atphi = 6– and the gradient agrees to 1e-10 across every family andphiindev_notes/probe_phi_gradient_families.R. The rational SPDE case the path exists for is unchanged (converged,n_iter9, field correlation 0.96), and so is every solve that was already tripping the step criterion. Every Laplace solve reports the residual it achieved, not just whether its stopping rule fired (gcol33/tulpa#255).
LaplaceResultcarriesscore_max, the largest absolute component of the joint penalized score at the returned mode, surfaced on the R side asfit$score_max.convergedanswers a different question, and the difference matters because the log-marginal feels a mode error quadratically while its theta-gradient feels it linearly. Read off the final scatter every driver already performs, so it costs one pass over the latent vector. The threeLaplaceResult-returning exports that hand-rolled their own result list (the SPDE Laplace pair and the implicit-diff gradient) now go throughlaplace_result_to_list(), which is what makes a new diagnostic reach all of them.The exact outer gradient declines a mode that did not settle (gcol33/tulpa#255).
.laplace_exact_core()keeps only thelog|H|path because the joint score atx_hatis zero; a solve that stopped short leaves behind exactly the term the derivation discards. The reported residual is mapped through the same inverse the mode motion travels (max|dx| <= ||H_true^-1||_inf max|score|), compared against the latent scale, and refused above 1e-6 – which bounds the outer gradient at ~1e-5 relative, four orders under the 1.0e-04 above. Both sides of that threshold are measured: settled solves swept over five families andn = 50 .. 18000span 3.4e-16 to 2.1e-11, solves stopped short span 8.8e-05 to 1.2e-02. The refusal is signalled as atulpa_unsettled_modecondition carrying the residual, the implied mode error and the iteration count, so a direct call says what to change while the outer optimizers collapse it into one warning per fit rather than one per trialtheta.A declined gradient no longer lets the outer optimizer report its own starting value as the estimate (gcol33/tulpa#255). When the exact gradient is unavailable at a trial
theta,optim()receives a vector of zeros, which it cannot distinguish from a stationary point – so a refusal on the first step ends the search there andphiis reported back unchanged from where it started. The gradient-driven branch oftulpa_re_cov_nested()now discards that run and restarts derivative-free, the same response it already had to an outright optimizer failure, and says so in the warning.Grid and chain checkpoint files carry a new payload field, so the format magic is
TLPACKP2. A file written by an earlier version errors with the existing “pointcheckpoint$pathat a fresh path” message instead of being misparsed.
tulpa 0.0.105
The zero-inflation refusal names every family the gate admits (gcol33/tulpa#250). The kernel guard stops on
compiled_zi_supported(family)but restated the supported set beside it as three families, where the gate admits seven – it omittedneg_binomial_1and both zero-truncated bases, which are exactly the hurdle models. A caller who reached the error for some other family was told a hurdle on a truncated base was unavailable. The message is now built from the gate:discrete_mass_families()is the candidate listhas_discrete_mass()tests membership in, andcompiled_zi_supported_families()runs the gate over it.A mistyped
TULPA_S2Z_DENSIFY_MAXno longer forces the Woodbury path (gcol33/tulpa#251). The override was read withatoi(), which maps every unparseable string to0– and0is a meaningful setting here, forcing the rank-1 storage on every intrinsic field.=tureor=256atherefore changed which algorithm ran for the rest of the session, with nothing in the output saying so. It is parsed withstrtol()against the end pointer now: anything that is not a whole non-negative int is treated as unset, so the documented default applies. Both storages remain exact, so no fitted value moves. The other threegetenvknobs insrc/are presence or single-char tests whose unparseable case already lands on the default.A warm start whose source supplies the wrong number of SDs is refused (gcol33/tulpa#252).
.build_warm_start()errors when a source fit’s block does not match the sampler’s layout, but two branches tooks_t[1]and carried on – seeding every coefficient of a term from the first coefficient’s SD, for both the mass scale and thelog_sigma_restarting value, and reporting that as a warm start. A single SD still broadcasts (the scalar-term case); any length other than one or the term’s coefficient count now stops with the wording the coefficient placement already used.get_mode_backends()derives its mode list fromINFERENCE_TIERS(gcol33/tulpa#254). Its error message restated the names its siblingselect_inference_mode()already builds from the registry. Correct today, stale the moment a tier is added.
tulpa 0.0.104
-
The dispersion convention follows the base family, not the spelling (gcol33/tulpa#256).
phiis the residual VARIANCE for the normal families while the compiled kernels parameterize by the SD, and the conversion tested the family name for exact equality. Every<family>_<link>spelling –gaussian_log,gaussian_inverse,lognormal_log– therefore reached the kernel with the variance where the SD belongs, so a caller asking for a residual variance of 0.8 was fitted at 0.64. The four R -> kernel boundaries (tulpa_laplace(), thetulpa()front door,fit_spde()’s working weights, and the SPDE predict path) now share one pair of converters resolving the base family through.family_base(), the way every other consumer already did.It went unnoticed because nothing downstream distinguishes the two under the canonical link:
dW/detais identically zero forgaussian_identity, so a wrongphicancels out of the mode motion and the curvature channel. It stops cancelling forgaussian_log, whose observed-minus-working delta became nonzero in 0.0.103. Changes fitted values for existing suffixed-normal fits. The second dispersion reaches the random-effect covariance paths (gcol33/tulpa#257).
tulpa_eb()andtulpa_re_cov_nested()takephi2and thread it through the pilot fit, both inner solves and the exact outer gradient, which hard-codedNA_real_.family = "t"had been fitting at the compiled default of four degrees of freedom whatever the caller meant – reporting an ML-IIphiconditional on a df nobody chose – andfamily = "tweedie"could not be fitted on those paths at all, failing as a generic inner-solve error because its variance power had no way to arrive. The power is now required by name, aphi2supplied for a family that carries none errors instead of being ignored, andtulpa(phi2 = )forwards tomode = "eb"and the nestedSigmaintegrator. Supplyingphi2 = 4fortreproduces the previous default bit for bit, which is what pins the threading as faithful rather than merely different. The backends carryingphi2are derived from the registry (.phi2_backends()) rather than restated in the refusal message.
tulpa 0.0.103
-
estimate_phicovers every front-door family that has a dispersion (gcol33/tulpa#247). It was offered for twelve and carried a derivative for four; the other eight hit a hard refusal at fit time.lognormal,t,neg_binomial_1,beta,inverse_gaussian,beta_binomialandtweedieare now registered, so.dispersion_families()is exactly the set of front-door families carrying aphi. Each new entry finite-differences against the.FAMILY_OPSlikelihood, score and weight it claims to differentiate (~1e-9), and the assembleddm/dlog_phimatches a central difference of the Laplace log-marginal to the same order.gaussianandlognormalnow share one set of derivatives read at their own residual – the lognormal is the same normal density on the log scale plus a dispersion-free Jacobian. Tweedie’s log-density derivative reads the mean event count off the SAME compound Poisson-gamma enumeration that produces the density, so the two cannot be taken over different truncations of the series.An exact gradient says the optimizer walks the right surface, not that the maximizer of that surface is the generating dispersion, so each new family additionally recovers its
phifrom simulated truth over several seeds (test-eb-dispersion.R). Measured mean estimates against truth:lognormal1.44 / 1.5,neg_binomial_12.11 / 2.0,beta8.32 / 8.0,inverse_gaussian0.48 / 0.5,beta_binomial8.08 / 8.0,t1.21 / 1.2,truncated_neg_binomial_22.96 / 3.0, with the random-effect SD recovered at 0.68-0.74 against 0.7 throughout.tweedieis the exception, and not for a reason of its own: the EB path never threadsphi2, so it cannot be fitted there at all (gcol33/tulpa#257). -
The observed curvature is registered across the family registry. Whether a family’s dispersion derivatives assemble into the right gradient turns on one quantity: the mode-motion channel
q_eta = (dW/deta) s. It is right when either the working weight carries no eta (gaussian, lognormal, gamma, t) or the mode-motion solve is on the TRUE curvature – which needs the family’s observed curvature-l''(eta), and only six families had one. Adding the mu-space ladderdgrad_mu_dmu/d2grad_mu_dmu2alongside the existinggrad_mucloses that generically:-l'' = -(L'' u^2 + L' u1)covers every family on the generic route in one branch, with explicit branches forbeta_binomial,tweedieandt. Verified against a numerical second derivative of the log density for seventeen family/link pairs.This is what unparks
beta, whose derivatives had been kept unregistered since its assembled gradient landed ~1e-4 off the objective. It also makes the exact mode Jacobian and the marginal fixed-effect precision available across the registry, where before they were declined forgamma,beta,inverse_gaussian,beta_binomial,tweedieandt. -
The closed phi Hessian covers both families that were on the stencil, plus two more (gcol33/tulpa#248). It was registered for two of the four families that carried the phi gradient.
gammahad been deferred on the grounds that its Fisher working weight differs from its observed curvature; the operative condition is narrower than that – the weight is free of eta, which zeroes every channel the two inverses could differ on – and it holds.lognormalandtjoin on the same terms.truncated_neg_binomial_2needed the border itself fixed. It differentiatesuand the phi mode motion, both formed on the true-curvature inverse, while readingHinv– correct only where the two weights coincide, which is what the old registration gate quietly relied on. The border now carriesdH_true/dpsi, which needs the phi-derivative of the observed-minus-working correction as well as the eta-derivative the random-effect block already had; a family in that position suppliesdobs_weightand.family_dphi2()withholds the whole entry without it, rather than pairing two different inverses. The second mode derivative’s right-hand side reads the same correction: the score’s eta-derivative is the OBSERVED curvature, and the two differ for exactly these families.The truncated family’s three weight derivatives are one chain rule over
f(a, a_e)and the shape’s own derivatives, the same partialscurvature_deta2_for_familycarries, sodweight,dweight2and the mixeddweight_detacome from a single derivation instead of three. The bordered Hessian matches a central difference of the exact gradient to ~1e-9 for all six registered families.neg_binomial_1keeps the differencing stencil. The compiled zero-inflation gate no longer rides on the observed curvature alone.
compiled_zi_supported()was defined ashas_observed_curvature(), which was the right answer only while the count families were the only ones carrying one: the mixture’sy = 0branch reads the log density at zero aslog P(Y = 0), which for a continuous family is a log-DENSITY – finite and plausible for gaussian, infinite for gamma or beta. It now also requires a discrete base. The R front door reads the compiled predicate instead of keeping its own list of families, so the two cannot drift; zero-inflatedbeta_binomialis admitted by that (it has both an atom at zero and, now, an observed curvature).
tulpa 0.0.102
-
Out-of-pattern Hessian writes are detected instead of silently discarded (gcol33/tulpa#249).
SparseHessianBuilder::add()dropped any contribution whose(row, col)was absent from the registered sparsity pattern, and the cached-slot fast paths did the same behindif (slot >= 0). Nothing counted it. What survives a drop is a Hessian too small in one entry – finite, positive definite, correctly shaped – which the Newton step, the Laplace log-determinant, the marginal standard errors and the exact outer gradient all inherit, so the only symptom is a recovery test drifting, the hardest signal here to attribute. The invariant had already broken twice: unequal / non-contiguous areal components (#241) and weighted-entry blocks (#242).src/hessian_pattern_guard.hadds the drop counter, thescatter_slot()helper that single-sources every guarded slot write (14 sites across the dense-basis, indexed-cache, joint-batch and sum-to-zero scatters), andHessianPatternGuard, which snapshots the counter and raises. Detection is a counter rather than a throw at the write because the scatter runs inside OpenMP parallel regions, where anRcpp::stopescaping the structured block isstd::terminate– the constraint that also makesLaplaceResult::start_infeasiblea flag. The guard is a pure snapshot, so a driver nested inside another measures its own window without disturbing it. Placed inrun_nested_laplace_grid(covering the single-block and both joint paths), the batched joint driver,spde_run_single_fitandcpp_laplace_fit_gp.Only a NONZERO discarded contribution counts. The scatter index caches resolve whole cross products up front – every (beta_j, RE_g) pair of an arm – and legitimately hold -1 for pairs no observation ever touches; those slots are written with a structural zero, which changes nothing whether it lands or not. Counting at index-resolution time instead would fire on every real fit.
The shipping kernels scatter entirely inside their registered patterns: the suite runs with the check armed and zero drops, so this lands as a regression barrier rather than a bug fix. New
test-hessian-pattern-guard.Rcovers the counter on both write paths, the raise, the zero-versus-nonzero distinction, and a real fit.
tulpa 0.0.101
-
portable_math.hcompiles for downstream packages on macOS again (bugfix). The header returnsstd::pairfromportable_digamma_lgamma()but included only<cmath>and<limits>. libstdc++ reaches<utility>transitively through those, so Linux and MinGW builds were fine and tulpa’s own sources were fine everywhere – they include<utility>ahead of it. Apple clang’s libc++ does not, so any package that includes the header first failed to compile:no template named 'pair' in namespace 'std', which took tulpaObs’s macOSR CMD checkdown atcount_grouped_oracle.o. The header now includes<utility>itself. Header-only change, no behaviour anywhere.
tulpa 0.0.100
Warm-starting the sampler from a cheaper fit of the same model.
New:
-
estimate_phi = TRUEnow coverstruncated_neg_binomial_2, and covers it alongside a zero-inflation process when the base is zero-truncated (a hurdle). The family was listed in.PHI_FAMILIESbut had no entry in.FAMILY_DPHI, so the dispersion was refused for it outright. Its three derivatives are the untruncated ones plus the phi-derivative of the retained massP(Y > 0); the weight differentiated is the EXPECTED formVar(y | y > 0), which is what the Newton solve buildsHfrom for this family – the opposite choice fromneg_binomial_2, whereHcarries the observed curvature. Each finite-differences against the registry to ~1e-9 overphiin [0.5, 25], and the assembleddm/dlog_phimatches a central difference of the Laplace log-marginal to ~1e-9.Under a mixture the refusal now tests the BASE for zero-truncation rather than testing for a mixture at all. A hurdle’s zero branch is
log(pi)and carries no dispersion, so the base family’s derivatives are the mixture’s once masked aty = 0; genuine zero inflation on an untruncated base keeps the refusal, since its zero branch depends onphithroughP(Y = 0)as well. Verified to ~1e-8 against the two-process log-marginal on a fixture with 35% zeros, andphi_hatis consistent – 2.921, 2.871, 2.998 as the count arm grows 750, 2269, 5057 (12 seeds each, standard error 0.144, 0.091, 0.065). -
The closed-form outer Hessian now carries every mixture the exact gradient does – hurdles and genuine zero inflation alike – where all of them previously fell to the gradient stencil. The curvature block is
-Hess(log density), so its nine second partials are the FIVE distinct fourth derivatives of one scalar, indexed by how many of the four derivatives are ineta. Newmixture_curvature_deriv2()returns those five; a hurdle is the case where the three mixed ones vanish, so one assembly covers both and the hurdle’s terms multiply by zero rather than being special-cased.The coupled
y = 0branch differentiatesD = pi + (1 - pi) P(Y = 0)a fourth time, which is the only genuinely new input: it needsP(Y = 0)’s fourth log-derivative and so the second eta-derivative of the observed curvature, whichhas_zi_curvature_2nd_derivative()gates on. That gate is no longer narrower than the gradient’s.The five fields are checked along every route to them: most are reachable by differentiating two or three DIFFERENT third-order fields, in different directions, and all routes agree to ~1e-11 – which is what tests the
-Hess(log D)identification rather than the arithmetic. Against a central difference of the exact gradient the assembled Hessian reaches 1.5e-9 (poisson, 53% zeros), 4.1e-9 (neg_binomial_2, 57%), 5.2e-9 (binomial, 60%), and 3.3e-11 under a hurdle – the last unchanged to every printed digit by the generalization. The closed outer Hessian also covers the families whose Newton weight is not their observed curvature, which unblocks
truncated_neg_binomial_2on the one-process path andhurdle_nbinom2on the two-process one. The assembly formsuon the observed-curvature inverse but was differentiating it through the working-weight inverse – the same thing only where the two weights coincide. Pairing them needsdH_true/dtheta, and so the newobs_curvature_delta_deta_for_family(): for a zero-truncated NB2 the differenceW_obs - wdifferentiates to the NB2 curvature derivative plusd3 log P(Y > 0)minus the truncated working one, all three of which the registry (andtruncation_shape()’sd3a) already supplied. It finite-differences againstW_obs - wto ~4e-10, and the assembled Hessian reaches 2.3e-09 one-process and 3.4e-09 under the hurdle.-
estimate_phi = TRUEnow runs alongside GENUINE zero inflation as well, and the closed Hessian’s dispersion border runs under any mixture. A hurdle’s zero branch islog(pi)and carries no dispersion, which is what let the base family’s registry stand in for the mixture’s; genuine zero inflation has zero branchlog(pi + (1 - pi) P(Y = 0, phi)), which carriesphithroughP(Y = 0)and couples it to BOTH linear predictors – so the mode motion solves against both scores, and the explicitdW/dphiruns over the whole 2 x 2 block.The sixteen fields that needs are sixteen CALLS on one engine, not sixteen formulas: the mixture density is
q(z) B(eta, phi) + C(z)in every branch, so every mixed partial oflog Dfollows from the multivariate cumulant-from-moment recursion over the alphabet (eta, z, phi), exact at any order and any mix of directions. The base registry covers the rows the mixture leaves additively separable and the engine covers the coupledy = 0rows; the two are summed over disjoint rows, and the engine returns exact zeros wherever the registry applies (everyy != 0row, a hurdle’sy = 0rows, a family with no free dispersion).Anchored on
zi_loglik/zi_score_eta/zi_neg_hessianand the curvature engines, all written independently of this one: 40 field checks agree to 1.9e-10, with the two shared fourth-order names each reached from BOTH members of their pair. The assembleddm/dlog_phimatches the two-process log-marginal to 5.6e-09 and the bordered Hessian a difference of the exact gradient to 2.1e-10, on a fixture with 57% zeros. The one-process path is not a second assembly: it is this one with the six z-direction fields at zero, and it expands term for term to the expression it replaces. -
neg_binomial_1is covered throughout, on the one-process closed Hessian and under zero inflation. Its observed curvature is-s + r^2 (psi'(r) - psi'(y+r)), sod(W_obs - w)/detacarries a tetragamma and the next rung a pentagamma; both are now inportable_math.halongside the existing digamma / trigamma pair, in the same recurrence-plus-asymptotic form. The first rung finite-differences to 2.8e-10 and agrees with the same formula over R’s ownpsigammato 6.5e-10; the one-process closed Hessian reaches 3.5e-11.The same missing derivative was also what kept the family off the mixture curvature gate, so that gate now asks for the property it needs – the observed curvature’s eta-derivative, being the registered working-weight one plus the correction – rather than naming families.
beta_binomial,tandtweediestay out, having no observed form at all. Under zero inflationneg_binomial_1’s third- and fourth-order mixture fields reach 2.2e-11 and 6.3e-11, and its closed Hessian 2.5e-11.Still on the stencil, for a stated reason rather than by omission:
estimate_phiforneg_binomial_1in any model, zero-inflated or not, since the family has no.FAMILY_DPHIentry at all – a separate gap from the one closed here. Weighted-entry intrinsic fields – separable multivariate CAR (
mcar) and the areal / temporal varying-coefficient blocks – are now hard-constrained by the same augment-and-centre identification the uniformly-seen fields moved to in 0.0.99, instead of the old soft sum-to-zero pin (gcol33/tulpa#242). MCAR carries the augmented precisionSigma^-1 (x) Q_aug(Q_aug = Q + sum_c 1_c 1_c'/J_cper field, coupled across fields bySigma^-1and folded by the sparse solver), so its Sigma-dependent normalizer takes the fulln log|Sigma^-1|per field. A weighted field’s constant aliases with the coefficient on the covariate it rides on, not the intercept, so the centerer folds the field’s global level into that column –svc_beta_offsetfor a single field, per-fieldfield_beta_offsetfor MCAR, or-1for a covariate with no fixed-effect counterpart (the level is then left in the field for the augmentation to identify). A declared column is verified against the design so a wrong alias cannot silently shift eta.-
tglmm()andtgam()are named front doors ontotulpa(): contract-narrowing views, not new engines. Each carriestulpa()’s signature minus the arguments its model class cannot use (spatial,temporal), requires the structure that defines the class in the formula (tglmm()a random-effect term,tgam()ans(...)smoother), and refuses the structures outside it with a pointer totulpa()rather than fitting them. Dispatch, tier selection, backends and everytulpa_fitaccessor are unchanged; the fit is byte-identical to the same call throughtulpa().The returned fit carries
"tulpa_glmm"/"tulpa_gam"ahead of"tulpa_fit", soprint()adds the section that model class is read for – the random-effect covariance (withVarCorr()’s estimated / sampled / conditioned label) or the smoother table – andplot()on a GAM draws the fitted smooths. A door’sprint()composes the backend’s own report rather than displacing it, so a nested-Laplace GAM still shows its integrated hyperparameters, grid size and outer Pareto-k.Doors are registry-driven (
.TULPA_DOORS): a new one is an entry plus a stub, andtest-doors.Rasserts each signature stays exactlytulpa()’s minus its withheld arguments, so a new statistical argument ontulpa()cannot leave a door silently stale. tulpa_check_control(control, allowed, where)is exported. It is the control-list name check every front-door fitter already ran internally, promoted so consumer packages validate their owncontrol = list()surface against the same rules instead of reimplementing them. tulpaRatio’stratio()is the first caller.tulpa(warm_start = )seeds the NUTS sampler from a Laplace or empirical-Bayes fit:"eb"or"laplace"fits one first, or pass an existing fit from either mode. Chains start at that mode instead of the origin. Chains after the first are dispersed around it, so the between-chain spreadrhat()compares against is not collapsed by a shared starting point. Only the NUTS/HMC backends take one – the rest error rather than sample from the default start and report a fit that answers a different question.It is offered as a capability, not as a speedup: over 10 seeds, redrawing both the data and the sampler seed on a crossed random-intercept Poisson model, warm and cold were indistinguishable in effective sample size per second (paired Wilcoxon p = 0.32 and p = 0.49, with the source fit’s own cost charged to the warm runs). Single runs of that comparison disagree with each other by 2-3x, because
min(ESS)is an order statistic over parameters. Whether it pays on harder geometries than this one is untested.cpp_tulpa_glmm_layout()reports the parameter layout the sampler will use – block spans, per-term random-effect shape, and the column names – without sampling. The warm start places values by index against it rather than reconstructing the sampler’s naming convention, so the layout stays the single source of truth for where a parameter lives.tulpa_sample_glmm(warm_start = )andcpp_tulpa_sample_glmm()’sinit/inv_metric_diagare the engine-level entry points.initis one row per chain. The inverse-mass diagonal is assembled but not supplied by default: the kernel reads every entry as a posterior variance, and today the random-effect entries would be prior variances and the variance components would have no estimate at all.return_joint_hessianandtulpa_eb(marginal = TRUE)now supply both, so composing it properly is the follow-up.-
tulpa(estimate_phi = TRUE)estimates the family’s dispersion instead of conditioning onphi, which then supplies the starting value.log(phi)joins the empirical-Bayes maximization as one further coordinate carrying the exact derivative of the Laplace log-marginal, so it is the ML-II estimate: the hyperprior covers the random-effect covariances and the dispersion enters unpenalized.fit$phiis the estimate andfit$phi_estimateddistinguishes it from a conditioned value. Available undermode = "eb"and for the families whose dispersion derivative is registered; any other mode errors rather than fitting at the starting value under a name that says otherwise.This is what makes a Gaussian GLMM comparable to
lme4::lmer(REML = FALSE). With the residual variance held at its default the outer maximization absorbs the mismatch into the random-effect scales: on a nested(1 | g1/g2)fit the inner term came out at 0.10 against lmer’s 0.31. With the dispersion free the same fit reports 0.32, a residual SD of 0.5266 against lmer’s 0.5260, and fixed effects within 0.006 of an lmer standard error. -
Reference tests against external maximum-likelihood implementations of the same likelihoods:
lme4,glmmTMB,betareg,psclandMASS::glm.nb(glmmTMBandpsclare new inSuggests). Every count family, the zero-inflation and hurdle mixtures, beta regression, and the random-intercept / nested / correlated-slope structures are checked against a second implementation on identical data.Conditioned on the reference’s own dispersion and given a diffuse fixed-effect prior, the Laplace mode is the MLE of the same likelihood, so the tests assert agreement to a thousandth of a reference standard error rather than to a fraction of one. The dispersion half of each likelihood is checked separately by where the profile log-marginal peaks. A parameter-recovery test against a simulated truth cannot separate a likelihood bug from sampling noise at finite N; these can.
-
Zero inflation and hurdle mixtures reach the random-effect backends.
ziformulais now carried bymode = "eb"andmode = "re_cov_nested", andtulpa_eb()/tulpa_re_cov_nested()takeX_ziandzi_prior_sddirectly, so the random-effect covariance is estimated or integrated under the mixture rather than under a model missing it. Their inner solve istulpa_laplace(), which already carried the second process, so the mixture changes that solve and not the covariance coordinates the outer objective searches over. The mode is[beta | beta_zi | random effects]and both fixed blocks are reported:coef(),vcov()andconfint()returnncol(X) + ncol(X_zi)entries, the zero-side ones namedzi_*.The exact outer gradient follows the mixture. With two linear predictors the per-observation curvature is a 2 x 2 block over the count predictor and the zero predictor, and both move with the mode, so
d log|H| / d thetapicks up all six of its partials rather than the singledw/detathe one-process path uses.laplace_family_zi_curvature.hsupplies them in closed form, contracted against the three linear-predictor (co)variances.cpp_family_has_zi_curvature_derivative()gates it, and the gate is narrower than zero inflation itself: an untruncatedy = 0branch differentiatesP(Y = 0)a third time, which needs the eta-derivative of the OBSERVED curvature, and only the families whose working weight already is that have it registered. A hurdle base is zero-truncated, so itsy = 0branch is flat in the count predictor and needs no third derivative.Refused rather than fitted as a different model:
n_quad > 1(the adaptive Gauss-Hermite inner marginal runs through a single-predictor compiled oracle),estimate_phi = TRUE(the registered dispersion derivative is the base family’s, while the mixture’s zero branch depends on phi throughP(Y = 0)as well, so the two describe different objectives), andmode = "re_cov_gibbs"(its conditional carries no second process).
Fixed:
-
Continuous GP / NNGP spatial fields (
spatial_gp()) fitted with exact NUTS under-recovered the field: the centered parameterization sampled the field jointly with its(sigma2, phi)hyperparameters – Neal’s funnel – and a diagonal mass matrix stalled in the neck, so the posterior-mean amplitude collapsed toward zero andsigma2was under-estimated regardless of warmup (gcol33/tulpa#243). The sampling path now uses the non-centered parameterization by default (samplez ~ N(0, I), reconstructw = f(z, sigma2, phi)), wiring the field’s likelihood gradient back to(z, log_sigma2, log_phi)through the hand-derived NNGP forward/backward in an arenacustom_backward; the stored draws are transformedz -> won the way out.spatial_gp(parameterization = "centered")restores the old path.The same transform is now available on the NNGP spatially-varying-coefficient (
spatial_svc(parameterization = "noncentered"), per term) and multi-scale (spatial_multiscale(sampler = "noncentered"), per scale) fields, which the three blocks reach through one shared per-term applier rather than a copy each. The HSGP variants of all three are already spectrally non-centered and are unchanged.SVC keeps the CENTERED default, unlike GP. The issue that prompted this work expected SVC to share the funnel but recorded that as untested; measured on the
test-svc-nuts-frontdoor.Rrecovery fit (Poisson, n = 120, same data / seed / budget for both arms), the centered path is already funnel-free – 0/700 divergent, field correlation 0.939, sd ratio 0.662, mean treedepth 6.3 – and it fits in 163s where non-centered needs well over ten times that for the same answer. The funnel that motivated the GP flip was measured on a weakly identified field, which is where non-centered earns its cost; a well-identified response does not pay it. Reach for"noncentered"when the field is weakly identified or the centered fit reports divergences.Correspondingly there is no new SVC amplitude test:
test-svc-nuts-frontdoor.Ralready asserts the same sd-ratio band plus a divergence guard on well-identified data, which is the correct design for that quantity – on weakly identified data a posterior mean shrunk toward zero is the right answer, not evidence of a funnel, so a sd-ratio contrast there measures shrinkage rather than geometry.The multiscale transform is verified by finite differences against the analytic backward on both scales, but its end-to-end amplitude test is skipped against gcol33/tulpa#244: that block’s range prior is still a Uniform behind a hard
-INFINITYwall (the defect #144 fixed for GP and SVC, which multiscale escaped only by being unreachable), and it leaves 82-88% of post-warmup draws divergent whichever parameterization is used.spatial_multiscale()reaches exact NUTS through thetulpa()front door for the first time: the prior and its parameter layout existed, but no spatial-type branch ever built the sampler inputs for it, so the path was unreachable. Both scales’ fields are reported asgp_local[i]/gp_regional[i]draws alongside their own(sigma2, phi). It has no nested-Laplace kernel, somode = "structured"still routes it to the conditional Laplace path (which rejects it);autoroutes it to exact NUTS, the integrator that fits it. The samplers started every coordinate at the origin, which puts a spatial range at
phi = 1. That is inside the support of every PC-range block, but the multi-scale block declares hard range bounds, so bounds excluding 1 left the chain starting at-Inf– it never moved and returned an all-zero field at a 100% divergence rate. Each bounded range now starts at the geometric mean of its own bounds; a caller-suppliedinitis left untouched, and unbounded blocks are unaffected.A Laplace fit made with no
beta_priorreported standard errors that omitted the prior its mode was found under. The compiled kernels apply a built-inN(0, 100^2)ridge whether or not one is supplied, but the reported curvature added a penalty only when the argument was present, so the default fit’svcov()described a different posterior than its point estimate.-
Fixed-effect standard errors on a nested-Laplace fit carrying an RW1 / RW2 temporal field – or any
s(...)smoother, which is an RW2 over the binned covariate – were wrong for the intercept, which was reported at the fixed-effect prior SD (100) regardless of the data. On a 400-observation RW1 fit the intercept SE was 99.998 against an exact-MCMC value of 0.047.An intrinsic field’s constant null direction is jointly unidentified with the intercept. The spatial (ICAR / BYM2) and sampler paths identify it by augmenting the field precision during the solve, so the level sits under the field’s own tau; the temporal Laplace kernels never did. They identified the level only by centring the mode afterwards and folding the removed mean into the intercept – which fixes the point estimate and leaves that direction flat in the Hessian the marginal covariance is read from. Point estimates, field shape, log-marginals and every other coefficient were unaffected, which is why nothing else moved: on the same fit the slope SE was already correct to 2%.
The RW1 / RW2 kernels now carry the same augmentation, over the field’s global constant (the between-group level contrasts of a panel field stay improper by design, matching the sampler). The pin is registered for the solver to fold in rather than stored, so an RW chain’s Hessian stays tridiagonal / pentadiagonal; both storages are exact and agree to five significant figures. The augmentation itself is now one helper shared with the ICAR kernels, whose numbers are unchanged.
-
Fixed-effect standard errors on a conditional-Laplace fit (
tulpa_laplace()) carrying an intrinsic areal field were understated for the intercept: 2.3x at 10 areal units, 2.7x at 40, 3.0x at 120 for ICAR, and about 1.3x for BYM2 (whose structured half carries the field). Reported intervals were correspondingly too narrow.summary()/vcov()/confint()marginalize the field out of the joint Hessian by reconstructing the field precision in R. That reconstruction augmented the ICAR structure with a unit11', where the kernel it is meant to mirror augments with1_c 1_c'/J_cper component – a factorJtoo much weight on the constant direction, which is exactly the direction the intercept is aliased with, and no per-component handling on a replicated field. The reconstruction now matches the kernel and follows the component splitfor_each_icar_componentwalks. Slope standard errors are unchanged to five decimals, the pin being orthogonal to covariate contrasts.The reconstruction is now pinned against the kernel’s own exported
log_prior_icarrather than against a second R statement of the convention: the previous reference encoded the same unit11', so it agreed with the helper by construction and could not have caught this. -
The sum-to-zero augmentation of an intrinsic areal field (ICAR / BYM2 / MCAR) pinned each connected component’s constant over an equal-size contiguous split of the field,
n / n_componentsnodes per component. This is correct for a single connected map and for aspatial(by = )replicate over the block-diagonalI_L (x) Q(equal-size, contiguous copies), but wrong for a genuine disconnected map whose components are unequal in size or not contiguous in the node ordering (a mainland plus islands, the islands sorted among the mainland cells – the standard US-counties layout). The wrong node sets shifted both the fitted field and its variance component, on the Laplace and the exact-NUTS paths alike, and the marginal intercept SE with them.Components are now taken from the adjacency graph itself: the connected- component partition (
inst/include/tulpa/graph_components.h, one DFS computed once per fit and carried onModelData) drives every consumer – the Laplace gradient / Hessian / pattern / log-prior, the autodiff NUTS prior, the MCAR block, and the R marginal-SE reconstruction – so each component’s constant is pinned over its actual nodes. The rank-1 sum-to-zero fold the sparse solver uses (Woodbury step and block-Schur log-determinant) now carries a node-index list, so a component that is too large to densify folds exactly whether its nodes are contiguous or not. A connected graph and an equal-size replicate are the trivial partition and stay byte-identical.ModelDatagrewspatial_partitionand the ABI version is 37; consumer packages thatLinkingTo: tulpamust be rebuilt. control = list(n_warmup = )was silently dropped on the sampler backends.tulpa()’s control surface is the union over the backends it dispatches, son_warmup(which the NUTS-SPDE driver reads) passed the front-door check, and the subset that narrowscontrolfor the chosen fitter then discarded it becausetulpa_sample_glmm()spells the same knobwarmup. Warmup silently stayed atn_iter / 2while appearing to honour the request. Control knobs are now canonicalized through one alias table, so a fitter that reads the other spelling still receives it and an explicit value always wins.
An exact analytic gradient of the joint-field Laplace log-marginal with respect to the random-effect covariances, and the outer optimizer and marginal correction rebuilt on top of it.
New:
-
tulpa_laplace(return_joint_hessian = TRUE)returnsH_joint, the full joint posterior precision of[beta | random effects]at the mode, as a symmetric sparse matrix. Previously only its fixed-effect Schur complement (H_beta) was reachable, so nothing outside the kernel could differentiate the quantity the Laplace approximation takes the determinant of. -
dw/detafor every compiled family (laplace_family_curvature.h): the eta-derivative of the curvature the Newton system uses. For poisson, binomial, neg_binomial_2 and the truncated families that is the true third derivative of the log density; for the families whose Hessian carries an expected or working weight it is deliberately the derivative of THAT weight, since it is the weight the objective is built from.cpp_family_has_curvature_derivative()gates it, mirroringhas_observed_curvature().
Changed:
- The outer optimization over the random-effect covariances now runs BFGS against the analytic gradient where one is available, instead of Brent (
k == 1) or Nelder-Mead (k >= 2). Measured 1.6x fewer inner Laplace solves for a scalar block and 2.8x for a correlated one; the gain grows with the number of hyperparameter coordinates, since the simplex is what scales badly. Falls back to the derivative-free path for a family with no exact curvature derivative, for the AGHQ inner marginal (n_quad > 1, a different objective), and if a gradient-driven run fails outright. -
tulpa_eb(marginal = TRUE)builds its correction from the analytic gradient: the mode JacobianJis now closed-form rather than finite-differenced, andH_thetacomes from differencing an exact gradient (2ksolves) rather than second-differencing the objective (1 + 2k^2solves). Same correction, 2 to 2.8x fewer solves.
Notes:
- The Fisher-identity gradient the AGHQ path supplies is not a substitute at the Laplace case: it omits the term that flows through the mode into
log|H|, which is wrong by 9 to 58 percent on the checked cases. It vanishes only for a Gaussian response, where the curvature does not move with the linear predictor. Derivation and numerical confirmation indev_notes/laplace_exact_gradient.md.
Identification of intrinsic fields:
- Intrinsic fields are now identified by AUGMENTING the precision rather than penalising the field’s sum:
Q_aug = Q + sum_c 1_c 1_c' / J_c, with the field centred on its way into the linear predictor. The constant direction then carries the field’s own precision and never reaches the likelihood – a free draw that integrates out – instead of being held by a stiff penalty the sampler still has to traverse (gcol33/tulpa#241). This is what INLA’sconstr=TRUEand Stan’ssum_to_zero_vectordo. Applied to ICAR and BYM2 on both the exact-NUTS and Laplace paths, and to the multiscale temporal trend and seasonal arms. On the reference ICAR fit the correlation between the field sum and the intercept goes from -0.996 to 0.035, the intercept SD from 0.31 to 0.026, and the field sum sits at thesqrt(J/tau)the augmented prior predicts. - The rank moves with it.
Q_augfills one direction per pinned component, so ICAR – whose null space is exactly its component constants – becomes full rank and the normalizer takesJ log tau. Carrying the augmentation while keeping the deficient rank would bias the variance component low. The rank helper takesrank(Q)and the number of directions filled rather than the field length, because that identity is not universal: a non-cyclic RW2 also has a linear null direction a sum-to-zero augmentation does not touch, so it stays deficient by one. -
temporal_multiscale()is reachable fromtulpa(mode = "hmc"). The block’s layout, prior and gradient all existed but nothing in the package set the flag they key on, so it could only be driven from a consumer package. Its parameters are named (trend[i],seasonal[i],short_term[i], and the matching scales) instead of positional. Recovery through the front door:cor(trend, truth) = 0.97on a 36-point RW1 trend.
Fixed:
- The multiscale temporal Gibbs sampler discarded the level it removed when centring the seasonal arm, and never centred the trend at all – unlike every spatial Polya-Gamma kernel, which folds the removed mean into the intercept so eta is unchanged. Discarding it shifted eta every sweep, lagging the intercept behind the field and inflating the variance. The intercept is now identified, which the recovery test previously could not assert.
- Every ICAR block factory on the Laplace paths left the connected-component count at its default of 1, so a disconnected field (what
spatial(by=)produces) was pinned once over the whole vector and normalized at rankJ - 1, while the sampler and the Polya-Gamma kernels derived the true count from the same adjacency. The count is now derived there too. - The sum-to-zero fold carries an optional dense coupling over its rank-1 vectors, so a Kronecker-structured field can express the augmentation its precision implies (a multivariate CAR block’s
Sigma^-1 (x) 11'/Jcouples fields, which independent rank-1 terms cannot represent).LatentBlock::centerreports where each removed constant belongs rather than assuming the arm intercept, since a block reached through a per-observation weight aliases with that covariate’s coefficient instead. -
tulpa_laplace(return_hessian = TRUE)assembled the marginal fixed-effect precision by hand asX'WX - X'WZ (Z'WZ + D^-1)^-1 Z'WXfromglmm_weights(). That form reaches ONE linear predictor, so it sliced the random effects out of the mode atncol(X)– inside thebeta_ziblock when a zero-inflation process is present – and it carried the R-side Fisher weight rather than the curvature the kernel found the mode under, measured 1.8% apart onneg_binomial_2. It is now the Schur complement of the joint curvature over the random-effect block, corrected to the observed weight for the two families whose Newton weight is not it. The joint Hessian already carries the fixed-effect prior and the random-effect penalty the mode was found under, so nothing is added back. - The exact outer gradient’s
dW/dthetachannel formed its mode-motion solve on the working-weight inverse. That channel isv_r' (dx_hat/dtheta), and the mode follows the true stationarity condition, so it is governed by the observed curvature – the same inverse the mode Jacobian already used. The two coincide wherever the working weight is the observed one, which is why a poisson or gaussian check could not see the difference; ontruncated_neg_binomial_2the inverses differ by ~9% and the gradient was off ~0.8%. - The closed-form outer theta-Hessian declines for
neg_binomial_1andtruncated_neg_binomial_2instead of returning an inexact value. Itsdu/dthetadifferentiatesuthrough the working-weight inverse whileuitself is now formed on the observed-curvature one, and forced on it misses the exact Hessian by 2.6e-2 and 2.7e-4 respectively. Those two families take the gradient stencil instead – a central difference of the exact gradient, 2k gradient evaluations rather than the1 + 2k^2objective re-solves of the full fallback – which is checked against a second difference of the objective. -
marginalis a formal argument oftulpa_eb()rather than one of its control knobs, so the front door had no route to the hyperparameter-uncertainty correction.control$marginalontulpa()/tglmm()now forwards to it, and passing it incontroltotulpa_eb()directly errors rather than being accepted and ignored.
tulpa 0.0.97
The soft sum-to-zero constant that identifies intrinsic latent fields, moved onto the reference idiom and single-sourced (#241).
Fixed:
- The soft sum-to-zero penalty on intrinsic fields is now derived from
sd(sum phi) = kappa * nwithkappa = 0.001(Morris et al. 2019; the constant brms and the Stan ICAR case study use), via the new exportedtulpa/soft_sum_to_zero.h. The sampler’s ICAR and BYM2 branches previously applied a flat precision of0.01, i.e.sd(sum phi) = 10, which leaves the field’s constant direction free and aliased with the intercept. On a 36-unit ICAR binomial fit against a known intercept of 0.5, over 5 seeds:cor(sum(phi), intercept)moves from -0.996 to -0.037,sd(sum(phi))from 11.1 to 0.036 (=kappa * n, the target), and the intercept posterior SD from 0.310 to 0.027. Correlation between the posterior-mean field and truth is 0.989 either way: the penalty pins the level without shrinking the pattern. - The sampler’s ICAR branch pinned one direction for the whole field while its rank normalizer already used
J - n_components. It now pins one direction per connected component, matching the rank term and the Laplace path, so a disconnected graph (spatial(by =)replicated CAR) is fully identified. - The multiscale temporal trend (RW1/RW2) and seasonal (cyclic RW1) arms carried no sum-to-zero term at all. Both are intrinsic and both enter the same linear predictor, so each had a null direction aliased with the intercept and with the other. Both are now pinned; the proper short-term arm (AR1/IID) is not.
- The spatiotemporal, TVC and SVC penalties took the constant as a defaulted argument that call sites filled with a bare literal. The precision is now derived inside each penalty from the length of the sum it pins, so no call site can pass a kappa where a precision is meant. The interaction margins take their own constants rather than sharing one (a space margin sums
Sterms, a time marginT). The SVC penalty is written on the sum rather than the mean; the two forms are the same penalty, related by a factor ofn_obs.
Internal:
-
for_each_icar_component()moved fromlaplace_spatial_priors.cppinto the sharedicar_kernel.h, so both engines walk the null space identically. - The MCAR block factory’s own copy of the constant is gone; it takes the shared per-component precision.
test-mcar-prior.R’s reference now reads the same helper the C++ does rather than hard-coding the value.
tulpa 0.0.96
A settable prior on the zero-inflation coefficients, and the correctness fix that finding it turned up.
New:
tulpa(zi_prior = list(sd =))sets the prior SD on the zero-inflation coefficients,beta_zi ~ N(0, sd^2); the default is 2.5. One scalar applies to the whole block and the mean is fixed at 0, matching what the compiled kernels carry – ameanentry is refused rather than silently dropped, and the list form leaves room for a per-coefficient prior later.sd = Infremoves the penalty.tulpa_laplace()takes the same value as the scalarzi_prior_sd.The prior is worth setting rather than accepting: where a ZI design level contributes no zeros the likelihood is monotone in that level’s coefficient and alone would send it to
-Inf, so the prior is what makes the mode exist.zi_prioris how that scale is now chosen.
Fixed:
The Laplace path applied the zero-inflation prior only when the caller also supplied
beta_prior. Those govern different blocks –beta_prioris the count block – so the ZI coefficients silently carried a prior SD of 2.5 with a fixed-effect prior present and the weak built-in 100 without one, while every sampler path applied 2.5 unconditionally. The sameziformulatherefore fit a different model depending onmode, and on an unrelated argument. The prior is now applied whenever the ZI block exists. A second edge in the same branch: supplying onlybeta_prior$meanleft the precision vector empty, so the guard that resized it skipped the ZI tail.ziformulaontulpa()andX_ziontulpa_laplace()appeared in the usage blocks with no\item, an undocumented-argument check warning carried since the zero-inflation port. Both are now documented.The hurdle-factorization test compared a joint fit’s zi block against a standalone binomial without matching their priors, and absorbed the resulting gap in a 1e-4 tolerance – the same prior discrepancy described above, hidden rather than caught. Matched, the factorization is exact to solver precision, so the two zi assertions are now pinned at 1e-9, and a companion test repeats the identity at a prior far from the default to show it is a property of the likelihood and not of the default scale.
tulpa 0.0.95
One diagnostic front door, selected by draws provenance.
New:
diagnostics()is the entry point for posterior diagnostics on any fit. It reads how the draws were produced and returns the reliability question that applies: chain mixing (improved Rhat, bulk / tail / mean / sd / quantile ESS, MCSE) for MCMC draws; the PSIS approximation-reliability table (pareto_k, grid quadrature ESS) for i.i.d. draws from a deterministic backend;NULLwith a message for a point summary that carries no sample.The routing is a registry keyed by provenance kind, so a new engine class is one entry plus its table builder rather than another branch. This replaces the hand-rolled
if (!is_chain)dispatch that previously sat insidemcmc_diagnostics().
Deprecated:
-
mcmc_diagnostics()andlaplace_diagnostics()are deprecated in favour ofdiagnostics(). Both still work and return exactly what they always did;mcmc_diagnostics()in particular still routes an i.i.d. fit to the reliability table, which is the behaviour that made its name wrong. Thelaplace_diagnosticsclass and itsprint()method are unchanged, so code that inspects the returned object keeps working.
Fixed:
- The
laplace_diagnostics()example fitted withmode = "laplace", which returns a mode plus covariance and carries no draws, so the example printed a “no posterior draws” message instead of the table it documents. Both it and the newdiagnostics()example now usemode = "smc", a deterministic backend that does emit draws.
tulpa 0.0.94
Empirical Bayes over random-effect covariances, and the lme4 / posterior accessor surface.
New:
-
mode = "eb"andtulpa_eb(): estimate one or more random-effect covariances by maximizing the Laplace marginal likelihood over them, then report the fixed effects conditional on the maximizer. This is the plug-in counterpart oftulpa_re_cov_nested(), and deliberately not a second implementation of it: both call the extracted.re_cov_theta_fit(), so they share the objective, the inner solve and the optimizer, and theirtheta_hatvalues are identical on the same data (asserted withexpect_identical, not a tolerance). EB stops at the mode; the nested integrator carries on and marginalizes around it. Registered as a Tier-2 backend and opt-in by name – conditioning onSigma_hatdrops the hyperparameter uncertainty, soautonever selects it. -
fixef()and theas_draws()/as_draws_array()/as_draws_matrix()/as_draws_df()/as_draws_rvars()family ontulpa_fit..onLoad()also registers these methods (and the existingranef()) onlme4::fixef,nlme::fixef,lme4::ranef,nlme::ranefandposterior::as_draws*when those packages are installed, solme4::fixef(fit)dispatches without any of them entering Imports – and without tulpa masking their generics on attach. - A Gaussian-approximation fit carries no draws, so
as_draws()on one errors by default and names the alternative.as_draws(fit, n_draws = )opts in to samplingN(coef, vcov); that is a modelling decision (every downstreamposteriorsummary would treat the approximation as a posterior sample), so it is never taken silently.
Correctness:
-
$is now exact on atulpa_fit(new$.tulpa_fit). A fit is a list, so$’s default partial matching let an ABSENT field resolve to any longer field it prefixes – and since the accessors decide which posterior shape a fit carries by testing whether$draws/$mode/$modes/$covis NULL, a partial match there reads the wrong object outright. Live collisions:$draws -> draws_kind(the string"iid", on every Laplace/EB fit),$mode -> model_matrix(the design matrix, on every sampler fit),$sigma -> sigma_re(AGQ fits),$theta -> theta_hat(EB fits).Three symptoms were live:
posterior_sample()returned"iid"andtulpa_draws_array()built a 1x1x1 array from it for every Laplace-shaped fit;laplace_diagnostics(), which exists for exactly those fits, could never reach its “no posterior draws” branch; andprint()on an AGQ fit reported the random-effect standard deviation under thesigma:label, where it reads as the dispersion. The remaining collisions were latent – masked by a branch ordered ahead of them or by a companion&& !is.null(...)guard – so no coefficient, standard error or interval changes:coef(),vcov(),confint()andsummary()return exactly what they did before on every backend. The draws accessors additionally route through one.fit_draws()helper.This reaches model packages that set
class = c("<model>_fit", "tulpa_fit"): they inherit exact$too, so any of their code that was relying on a partial match now gets NULL. That reliance was always a bug, but it will surface here. Cost is ~2 us per$read (an S3 dispatch);coef()andsummary()are ~0.5 ms, so it is not measurable at the accessor level. offset()was silently dropped by the RE-covariance backends.tulpa()never threaded the offset intotulpa_re_cov_nested()(which had nooffsetargument at all), so a rate model reached undermode = "re_cov_nested"– or via the automatic random-slope redirect offmode = "laplace"– fitted counts instead. On a simulated rate model with exposure spanning 50x and a true intercept of -1.5, it returned +1.66.tulpa_re_cov_nested()andtulpa_eb()now takeoffsetand thread it through the innertulpa_laplace()solve, which always supported it. Where an offset genuinely cannot be carried it now errors instead of dropping:n_quad > 1(the compiled per-group AGHQ oracle has no offset term) and there_cov_gibbsbackend.The outer optimization over the random-effect covariance(s) now warns when it does not converge, in both
tulpa_eb()andtulpa_re_cov_nested(). It previously returned wherever the optimizer stopped without a word – for EB that is the estimate, and for the nested path it is the centre the integration grid is placed around.control$outer_maxit(default 500) sets the budget, andtulpa_eb()reports the code as$outer_convergence.One-dimensional outer optimization uses Brent rather than Nelder-Mead, which R warns is unreliable there.
k == 1is the common case (a scalar(1 | g)block), so this affectstulpa_re_cov_nested()as well as EB. Brent reports success at a bracket endpoint, so a variance component pinned at the bracket now warns rather than being reported as a fitted value – the low end is the classic empirical-Bayes collapse tosigma = 0.
tulpa 0.0.93
Zero inflation as a composition over the count families, and compiled kernels for the last three families that had none.
New:
ziformulabuilds a second linear predictor and forms the mixture at the likelihood level, so zero inflation is a composition over the base families rather than a set of families of its own. The math lives once, inR/family_zi.Randsrc/builtin_family_zi.h, mirrored term for term.The two backend classes reach it through different hooks because they differ structurally. The sampler paths take the
logit_zicallback argument the engine already plumbed; the Laplace spec shim hardcodes that argument to zero and instead rides the zi predictor as process 1, so the mixture’s cross term reuses the existing row-majorn_processes x n_processescurvature block rather than introducing a second curvature contract. Coverage is derived fromBACKEND_REGISTRY, so a new sampler backend inherits ZI with its fitter, and backends that would silently drop the mixture refuse instead.Hurdle models fall out of this as ZI over a zero-truncated base: with no atom at zero the mixture degenerates exactly, so they need no families of their own. The degenerate case is reached by taking the
p0 = 0limit rather than by evaluating a density at a point the family does not define – the general branch is correct in double arithmetic but yields NaN under AD. Both directions are pinned, including the converse that an untruncated base keeps a non-zero cross term.Compiled kernels for
neg_binomial_1,truncated_poissonandtruncated_neg_binomial_2on both the Laplace and the AD / sampler paths. These three were registered in R but absent from C++;.R_ONLY_FAMILIESis now empty, so every registered family is fittable. Density, score, working weight and observed curvature agree with the R registry to machine precision, and each family recovers its generating parameters through the Laplace path.The two zero-truncated families share their retained-mass term
log P(Y > 0)and its first two eta-derivatives, differing only inaand its derivatives, sotruncation_term()computes the pieces once and the density, the weight and the observed curvature all read from it.Two curvatures are now distinguished rather than conflated.
grad_hess_for_family()returns the Newton working weight, chosen for positive-definiteness;obs_grad_hess_for_family()returns the observed curvature at the realizedy, mirroring.family_obs_weight()in R. The zero-inflation mixture needs the latter, because itsy = 0branch differentiates the density rather than taking an expectation. The distinction is not cosmetic:neg_binomial_1’s observed curvature turns negative onceysits well above the mean, which would break Newton, while its working weightmu / (1 + phi)never does. The mixture only ever evaluates the observed form aty = 0, where it is positive.Compiled ZI coverage follows from that: a family becomes ZI-fittable exactly when its observed curvature is registered, which is now
poisson,binomial,neg_binomial_2,neg_binomial_1,truncated_poissonandtruncated_neg_binomial_2. The remaining count families keep the R-level composition (zi_loglik()and friends) for density work and are refused at the front door for fitting.arena::Varandad::Vargainexpm1(fwd::Dualalready had it), andautodiff_utils.hgainsexpm1_fnplus alog1m_exp_fnthat splits atlog 2. Both branches differentiate, so the truncated AD densities are exact rather than falling back.cpp_family_obs_terms()andcpp_family_ad_terms()give the two new surfaces test probes. The latter evaluates the AD density atfwd::Dual, so its value checks against the independent double implementation and its derivative against the analytic score.
Correctness:
The silent Poisson fall-through is gone.
variance_fn,grad_muandlog_lik_mueach ended in the Poisson branch, so a family known to R but not to C++ fitted, and fitted the wrong likelihood – which is what.R_ONLY_FAMILIESexisted to guard against. They now raise, and that is what makes emptying the list safe..family_base()stripped link suffixes in registration order and so resolvedbeta_binomialtobeta. Every validator built on it was applying beta’s rules to beta-binomials, including.validate_family_counts, which silently skipped integer-count validation for that family. It now matches exactly first, then by longest prefix.src/autodiff_fwd.hwas a byte-equivalent copy ofinst/include/tulpa/autodiff_fwd.hserving a single include, and is deleted rather than left to diverge onceexpm1landed.
tulpa 0.0.92
Audit fixes (0.0.91 review, issues #228-#239).
Correctness:
- The batched joint nested-Laplace driver (
compute_eta_species) now adds the per-observationoffset, which it silently dropped – multi-arm joint fits with an exposure offset on a coupled arm no longer disagree with the single-species path (#228). - Cyclic RW2 is now honored on the joint multi-block kernel: the
cyclicflag reached the block but the rw2 precision / pattern / log-prior calls were hardcoded acyclic, so a cyclic seasonal RW2 in a joint prior fit as acyclic. This mirrors the single-arm fix in #218 (#229). - The spatiotemporal temporal rank is now single-sourced through
rw1_rank/rw2_rank; the centered Type-IV path had drifted to a full-rank cyclic normalizer inconsistent with the non-centered path (#230). - The SPDE Matern
(range, sigma) <-> (kappa, tau)conversion is now nu-general (sigma = 1 / (sqrt(4*pi*nu) * kappa^nu * tau)) and single-sourced via.spde_kappa_tau/.spde_range_sigma; the six copies hardcoded thenu = 1normalizer, mis-calibrating fractional-nu SPDE fits. The defaultnu = 1path is byte-identical (#231). -
select_main_params()now strips comma-separated multi-index latent names (factor[i,j]), so latent-factor fields no longer flood the diagnostic display (#232). - The simulation diagnostics (
test_dispersion,test_zero_inflation,check_model) error via a shared.resolve_obs()when the observed response cannot be found, instead of fabricating a “0 observed zeros” result (#233). -
tulpa_hyper_grid(var_of_means_consistency = TRUE)now recomputes the per-cell log-prior after the consistency pass grows the grid, fixing a length mismatch that returnedNAreweighting (#234).
Validation & docs:
-
spatial_gp()/spatial_svc()/spatial_multiscale()reject unsupported covariances (gaussian,spherical) and Maternnuoutside{1.5, 2.5}at construction rather than deep in the fit (#238). - Corrected the
tulpa_em_laplace(damping=)andagq_fit(sigma_eps=)documentation to match the implementation (#239).
Clean-up:
- Removed process/status/refactor-history comments from committed source and a stale issue reference (#235); deleted an orphaned duplicate SVC roxygen block in
spatial_gp.R(#236); single-sourced the natural-scale hyperparameter transforms shared byspatial_range()/temporal_corr()(#237).
tulpa 0.0.91
Audit fixes (0.0.90 review, issues #218-#227).
Cyclic RW2 dropped on the multi-block nested-Laplace path (#218). A
temporal_rw2(cyclic = TRUE)block was honored on the single-block and exact-NUTS paths but silently ignored once a second latent block routed the fit through the multi-block driver: the wrap-around second-difference penalty and theT-1(vsT-2) rank normalizer were hardcoded to the acyclic form. The C++ multi driver and the joint-multi spec builder now threadcyclicfor RW2 as well as RW1.NNGP marginal-SE precision builder over-allocated (#219). The triplet accumulator pre-size squared the grand total of neighbour counts (
sum(...)^2) instead of the per-rowsum((...)^2), allocatingO((n_spatial * nn)^2)integers – tens of GB at a few thousand locations, sosummary()/vcov()/confint()on an NNGP fit could OOM. The result was always numerically correct; only the allocation was quadratic.tulpa()control surface omitted the joint keyset (#220). Inlinespatial()/temporal()field fits route through the joint nested-Laplace driver, buttulpa()’s control whitelist did not union its keys and the field fitter forwarded the raw control unmasked. Legitimate joint knobs (adaptive_grid,prune, …) were rejected at the front door, and some keys valid elsewhere hard-errored inside the joint driver. The union now includesnested_laplace_joint, and the field fitter subsets control to the joint keys like every other backend route.Outer Pareto-k target carried a spurious Jacobian on the single-block grid path (#221). The default positive-scale grid is geometric (uniform in
u = log theta) and the integrator weights it with plainsoftmax(log_marginal)and no volume element, soexp(log_marginal)is already theu-space posterior density. Both the single-block (.nested_grid_pareto_k) and joint / multi-axis (.joint_pareto_inv) paths added a+ sum(u)change-of-variables term on thelogaxes, tilting the certified target away from the posterior the fit reports (a false reliable / unreliable verdict; draws and moments were unaffected). Both now drop it on thelogaxes, matching the integrator and the SPDE Pareto-k path. A target-agnostic ground truth (PSIS of the grid-nodelog(w_k) - log q(u_k)) confirms the correction: with the Jacobian the outer k-hat overstated the truth by ~0.25-0.55, enough to flip a verdict near 0.7. The correlation axis (logit01) keeps its logit Jacobian, correct for the grid uniform in the naturalrho.automode errored on SVC / TVC (#222). With the defaultmode = "auto", a spatially- or temporally-varying-coefficient model fell to a Laplace / size heuristic and then hit the varying-coefficient guard, which only the exact ModelData NUTS backend clears.auto_select_mode()now routes SVC / TVC to the exact backend so the default mode fits end to end.Gradient-check fallback leaked across fits (#223). A failed warmup gradient check flipped the process-global gradient mode to numerical and never reset it, so every later fit in the session silently ran slower central-difference gradients. The fallback is now scoped to a single fit (restored on return), while a mode set explicitly via
set_gradient_mode()is preserved.find_reasonable_epsilonignored the active integrator (#227). The warmup step-size seed always used a first-order leapfrog step regardless of the selected scheme (yoshida4/6/8, minerror2, adaptive, mts). It now walks the same SIMP op sequence the trajectory integrator uses, so the seed epsilon matches the scheme’s per-step energy error.Diagnostic and marginal-SE test coverage (#224, #225). Added known-answer tests for the generic
moran_i/durbin_watsondiagnostics (hand-computed, plusspdep/lmtestcross-checks where installed), and a correctness test pinning the continuous-spatial (NNGP) marginal fixed-effect SE against an exact dense-GP penalized-IRLS Schur reference.Comment cleanup (#226). Removed residual meta / status comments from the sampler and autodiff sources and repaired truncated comment fragments in the joint Pareto-k module.
tulpa 0.0.90
Audit fixes (0.0.89 review, issues #207-#217).
Nested-Laplace crash on a length-1 multi-block prior (#207). The outer Pareto-k diagnostic was invoked with
type = NULLon the multi-block path; the single-block decline guard did not fire for a length-1 block list, so the.NL_REGISTRY[[NULL]]lookup errored. A single latent block wrapped in a list (thetgmrf()/ custom-latent front door) or a single block with a model-suppliedlikelihood(the tulpaObs consumer path) now resolves the type from the block itself. Default-config crash on both paths.Self-loop adjacency corrupted the CAR/ICAR precision (#208). A user adjacency with a non-zero diagonal was fit on a self-referential
Qafter only a warning..validate_adjacency_arg()now zeroes the diagonal (the graph is an off-diagonal adjacency everywhere downstream), and the CSR builder excludes a node from its own neighbour list as defence in depth.temporal_ar1(rho_prior = )is now wired end to end (#209). Supply aprior_beta(alpha, beta)to place a Beta prior onu = (rho + 1)/2. The compiled sampler kernel addsa*log(u) + b*log(1-u)(new ModelDataar1_rho_prior_a/ar1_rho_prior_b, ABI 35 -> 36) and the nested-Laplace outer grid is reweighted by the Beta density. DefaultBeta(1, 1)reproduces the previousUniform(-1, 1). Previously the argument was accepted and documented but silently ignored.Nested-Laplace fit accessors (#210).
ranef()now grid-marginalizes the random-effect tail of a nested fit (was empty);spatial_range()/temporal_corr()summarize the spatial / temporal axes of a mixed or spatiotemporal nested fit instead of erroring on theall(...)type check; anddiagnostic_summary()unwraps a$joint_fitwrapper like the shared reliability readers, so a model-package subclass surfaces its Pareto-k.Input-robustness fixes (#211). SVC / TVC
terms = ~ f/ character interfaces expand a factor covariate to its contrast columns (shared.resolve_varying_coef_columns()) instead of failing a bare-name match; the inlinetemporal()field enforces the RW2 >= 3 time-point guard;moran_i()drops self explicitly under coincident coordinates; andcheck_diagnostics()returnsNAon a too-short chain rather than a spurious “checks passed”.C++ defensive fixes (#212).
TapeScopemove-assignment re-points the active thread-local tape; a zero-byte file at a checkpoint path is treated as fresh rather than a bad-magic error.GP / NNGP Laplace pinned to serial (#217). Running the GP / NNGP Laplace kernel multi-threaded triggered a flaky heap corruption under the mingw OpenMP toolchain (a hard crash the second time it ran in a session). The observation-scatter is the only OpenMP region there and its speedup is negligible (small
n_spatial, serial Vecchia prior scatter dominates), so the kernel now runs on one thread; the crash is eliminated.Docs and internal single-sourcing (#213). Removed the
tulpa_psis()tail_points“(with a warning)” doc that never fired; corrected the defaultphiPC-prior description to match its behaviour (phiis the NB2 size, so the default keepsphifinite / allows overdispersion, withphi -> Infthe Poisson limit);.tulpa_param_layout()builds RE names through the single.re_names_from_layout()source; grammar fix inpriors_default().Front-door hyperparameter recovery + CI coverage (#214). New
test-hyperparameter-coverage.Rgates the hyperparameter posteriors of the nested-Laplace paths against simulated truth with >= 20-seed CI-coverage checks: temporal AR1 (rho, precision), proper CAR (sigma, rho), GP / NNGP (sigma, range), HSGP (sigma), BYM2 (total spatial SD), and the free-Sigma random-slope correlation. Previously only field shapes and fixed slopes were recovered; the variance / range / correlation hyperparameters had no coverage test.Test quality (#215, #216). The SVC / TVC exact-NUTS front door gained a divergence guard and scale recovery (the configuration the #144 divergence bug survived in). The debias is now shown as a differential: on small binary groups the exact Gibbs draw of Sigma lifts the under-dispersed nested sigma toward truth.
tgmrf_cpp()recovery is checked across seeds (cpp == R to 1e-6 per seed, inheriting the R closure’s verified recovery).test-gpu-nngpwas retitled to the path it exercises;test-hsgp-recoveryrenamed totest-hsgp-density-identity(it is an analytic identity, not a fit); the leapfrog-drift baseline now fails loudly if missing; the inference-tier test checks every backend’s tier; and the native Rhat/ESS/MCSE reference tolerance was tightened from 1e-4 to 1e-12 (the estimators matchposterior::to ~8e-16).
tulpa 0.0.89
Audit fixes (0.0.88 review, issues #193-#206).
-
Field-aware marginal SE for intrinsic areal fields (#196). ICAR / CAR / BYM2 conditional-Laplace fits computed
H_betain the dense-Hessian branch that ignores the spatial field, sosummary()/vcov()/confint()reported fixed-effect SEs at the wrong linear predictor and without marginalizing the field. Added.marginal_H_beta_icar/_bym2(field precisionL + 11'; the BYM2 two-block convolution), validated against an independent penalized-IRLS reference (test-marginal-se-areal.R). -
Front-door
controlknobs no longer dropped (#194). The spde / re_cov_nested / re_cov_gibbs / gibbs branches hand-built the innercontrollist, silently discarding valid knobs (diagnose_k,k_samples,checkpoint,n_threads, …); they now forward the validated subset. -
Random-slope guard single-sourced (#195). A no-intercept single slope
(0 + x | g)was silently fit as a random intercept on the group-index-only paths;.is_scalar_re_intercept()now gates AGQ / Gibbs / SPDE / nested-RE consistently. Plain Laplace still fits it (carries the slope column as Z). -
ESS sampler bounds (#193, #201).
n_savefloored while the store loop firesceil(post / n_thin)times (out-of-bounds write); multi-term RE with distinct sigma now hard-errors instead of freezing the extra terms. -
Spatiotemporal summary labels (#197). The
format = "summary"(s, t) index was t-fastest while draws are s-fastest, mispairing labels when S != T. -
Nested-Laplace diagnostics (#198, #203, #204).
tulpa_hyper_gridrefreshes the per-cell log-prior on the refined grid (was stale-length -> NA into power-scaling); the single-block outer Pareto-k is computed on the default grid (not only when the user named it); a nested-prior EM block attaches a grid-marginalizedH_betaso the MI / Gibbs correction reports a real pooled SE instead of NaN. -
Cross-tier hyperparameter summaries (#199).
spatial_range()/temporal_corr()returned raw grid axes (tau, phi_gp, sigma2) on a nested-Laplace fit but interpretable range / sigma / rho on a sampler fit. The nested path now maps each axis to the same interpretable quantity, computed per grid cell then weighted-summarized (sigma = 1/sqrt(tau) or sqrt(sigma2); range = 3 * lengthscale). -
Generic diagnostics (#200, #205).
plot_pairs()selects fixed effects fromfit$fixed_names(was hard-coded to ratio’sbeta_num/beta_denom);plot_diagnostics()guards an all-NA Rhat; NNGP neighbour builders no longer index a non-existent row atN == 1;ranef()no longer emits the field / hyperparameter tail as random effects;plot_acf()/geweke_test()handle a 3-D[iter, chain, param]fit;(x - 1 | g)drops the intercept;spatial_multiscale()samplernarrowed to the documented modes. -
Cleanup (#206). Issue tokens and
dev_notes/pointers removed from user-facingstop()messages and comments, refactor-history narrative reworded to describe current behavior, a dead no-op and a duplicated MCSE body removed, and the multi-block CAR_proper log-det cache made cell-keyed (NlCellCache) to match the single-block path.
tulpa 0.0.88
-
SoftAbs divergence-retry invariance test (#189). The post-warmup SoftAbs retry re-runs a diverged NUTS trajectory under a frozen Hessian-based metric and takes that proposal instead – a state-dependent kernel mixture whose target-invariance was argued but never tested. A new test-only entry point (
cpp_test_funnel_nuts,src/tulpa_test_funnel.cpp) fits Neal’s funnel through the exact production NUTS path with the retry forced on or off, andtest-softabs-retry-invariance.Rchecks that toggling the retry on removes divergences without shifting thevmarginal (seed-averaged paired mean/sd/tail differences within tolerance over 10 seeds). Verdict: invariance holds empirically – 24-seed paired |t| < 0.4 on every summary, divergences 735 -> 8, no posterior shift.
tulpa 0.0.87
Bug fixes and cleanups from a second whole-repo audit (issues #176-192).
-
Cyclic RW1 / RW2 rank normalizer on the exact-NUTS, multiscale, TVC, and spatiotemporal paths (#176). The production templated log-posterior used
rank = Tin the cyclic branch; a cycle-graph intrinsic GMRF has a single null direction, so the rank isT-1for both RW1 and RW2. The 0.0.86 fix had landed only on the double-precision twin. The rank is now single-sourced throughtulpa_temporal::rw1_rank/rw2_rank, consumed by every site. -
Cyclic RW2 now fits cyclically on the Laplace / nested-Laplace path (#177). The RW2 kernels dropped the
cyclicflag and the front door only propagated it for RW1, sotemporal_rw2(cyclic = TRUE)silently fit a non-cyclic RW2. The dense / sparse / pattern kernels now add the two ring-closing second differences and the flag is threaded through the RW2 front door. -
car_propernested grid (#178). Supplying only one grid axis no longer discards the other, and the correlation axis is accepted under bothrho_gridand the joint-APIrho_car_gridspelling. -
Grid-checkpoint fingerprint folds the RE group assignment
re_idx(#180) so a resume onto a checkpoint written for a different grouping errors instead of loading stale cells. -
get_compute_layout_fn()verifies the ABI version (#181) like the sibling registered-callable getters, so an ABI-mismatched consumer gets a clear error rather than silent memory corruption. -
tgmrf joint / outer NUTS count energy divergences (#182). Divergences were only flagged on a gradient-resync failure; a genuine energy divergence now sets the flag, and the R adapter surfaces
divergent/n_divergent. -
ranef()on sampler-tier fits (#183) returns exactly the random-effect coefficients (there[...]draw columns), no longer re-including the latent field and a spuriouslog_sigma_rerow. -
BYM2 default scaling (#184).
compute_bym2_scale()now returns the Riebler et al. (2016) generalized-variance factor1 / sqrt(geomean(diag(Q^+)))instead of the geometric mean of the ICAR eigenvalues, so the spatial fractionrhostays interpretable across graphs. -
spatial_range()/temporal_corr()(#186) now summarise the outer hyperparameter grid on a nested-Laplace fit rather than erroring on the primary spatial / temporal fitting path. -
sigma_reignored-argument warning (#187) also fires when the covariance backend is selected by name (mode = "re_cov_*"). -
tulpa_simulate(theta = <engine fit>)(#188) consumes a single-process matrix-draws fit instead of erroring. - Gradient-mode documentation reconciled with the dispatcher:
A/A_talias the arena reverse-mode path rather than claiming separate kernels (#185). - Single-sourced the duplicated NUTS trajectory loop (primary + SoftAbs retry) and aligned the SVC gradient Cholesky pivot floor with the value kernel (#190).
- Removed dead code, stale rename artifacts, and leftover issue-tracker / history comments; completed the ABI changelog (#191). Minor:
.default_tau_gridargument cleanup and the spatiotemporal long-format s/t ordering (#192).
The nested-Laplace tensor-grid integrator was investigated (#179) and left unchanged: CAR_proper (tau, rho) parameter recovery confirms the marginal is already in the internal log-scale parameterization, so equal-weight grid integration is correctly calibrated and adding a user-scale Jacobian biases the scale posterior.
tulpa 0.0.86
Bug fixes from a whole-repo audit.
-
Random slopes on the sampler modes now integrate the RE covariance.
tulpa(y ~ (1 + x | g), mode = "mala" / "pathfinder" / "imh_laplace")previously fell through the covariance redirect (gated on the Laplace backend) and fit the term with a single scalarsigma_reper block – dropping the intercept/slope correlation, forcing the SDs equal, and conditioning atsigma_re = 1. These modes now route to the exact Metropolis-within-GibbsSigmadebias, matching the documented coverage. -
Cyclic RW1 / RW2 temporal (and spatiotemporal) rank normalizer. The cyclic branch used
rank = T; a cycle-graph GMRF has a single null direction (the constant), so the correct generalized-determinant rank isT - 1for both RW1 and RW2. The old value biased the integrated / sampled precisiontauupward (oversmoothing), scaled byS - 1in Type-IV interactions. - Plain-ICAR exact-NUTS prior now adds the soft sum-to-zero pin the BYM2 and Laplace paths already carry, so the constant field direction is no longer jointly unidentified with the intercept.
-
compare_models()weights. The Akaike / pseudo-BMA weight on the elpd scale usedexp(0.5 * delta); corrected toexp(delta)(weights were systematically too uniform). -
check_model()asked forresiduals(type = "deviance"), which is not a supported residual type and errored on the base fit; panel 2 now uses Pearson residuals. -
Chain diagnostics on non-chain fits.
geweke_test()andplot_acf()now respect the draws-provenance gate (they returned a vacuous “converged” result on i.i.d. nested-Laplace / VI draws);plot_pairs(),plot_divergences(), andplot_energy()no longer error when$backendisNULL. -
spatial_range()/temporal_corr()named the quantile columnsq025/q975regardless ofprobs; the columns are now derived fromprobs(e.g.q2.5,q97.5). -
Non-finite fitting inputs.
tulpa()now rejects NA/NaN/Inf in the response or model matrix with the offending row, instead of letting aNaNpropagate silently into the kernels (the model is built withna.pass). -
svc(approx = "nngp")now rejects duplicated coordinates (a distance-0 neighbour gave a singular per-node covariance) rather than failing silently downstream.
Warnings and cleanups.
-
Silently-ignored arguments.
tulpa()now warns whensigma_reis passed for a random-slope model (the covariance is integrated, not conditioned on a scalar SD), andspatial_multiscale()surfaces its default range-prior bounds when they are left unset. -
Dead code removed. The unreachable non-centered AR1 gradient helpers (one carried a wrong logit Jacobian), five unused NUTS helpers, the unwired spatiotemporal-interaction helpers (
validate_spatiotemporal,prepare_spatiotemporal_for_hmc,build_st_index, and the two precision builders – superseded byfit_st_nested()), the internalhas_implicit_intercept(), and a no-effect temporal-Gibbs selection gate were deleted; the proper-CARrho-bounds eigenvalue roles were corrected (they were swapped, though the (0, 1) clamp masked it). -
Minor consistency fixes.
predict(type = "response")documents that a binomial fit returns the per-trial probability (vsfitted()’s trial-scaled count); the k-quality band index now shares the reliability bands the band-confidence flag used (at the realised finite-draw count) rather than recomputing them at the draw budget; the fractional-SPDE rational-order default is single-sourced to 2; and two split-message errors and a couple of stale comments were tidied. -
Duplicated computations single-sourced.
spatial_range()andtemporal_corr()now share one hyperparameter-summary scaffold; the three RNG snapshot/restore helpers share one snapshot + restore-closure factory; the outer Pareto-k importance core delegates to the batched core (identical draws); and the temporal-GP PC prior routes through the sharedpc_prior.hform instead of a hand-rolled Jacobian. The GP and SVC NNGP analytic gradients now share one Vecchia conditional-gradient assembler (nngp_cond.h; each keeps its own solver and distance source), and the fast leapfrog drift carries the same sparse-GMRF mass-block range override asinv_mass_times_p()so the integrator and U-turn check cannot use different metrics. -
Meta-comments removed. Issue-tracker references (
gcol33/tulpa#NNNin all forms, parenthetical(#NNN)lists, and bare issue numbers) and stale version tokens (“Phase 1.3”) were stripped from code comments and roxygen, keeping the domain rationale. References inside error messages,test_that()labels, and design-principle / improvement enumerations (principle #5,improvement #1) were preserved.
tulpa 0.0.85
Per-block quadrature order and an optional variance-component prior on the AGHQ path.
tulpa_re_aghq(n_quad = ...).n_quadnow accepts an integer vector of lengthlength(re_terms)giving a per-block node count, alongside the existing single integer broadcast to every covariance block. The tensor grid then usesn_quad[b]nodes along every dimension of blockb(prod_b n_quad[b]^(dim_b)total nodes); a scalar reproduces the uniform grid exactly (byte-identical). Per-block orders let a heterogeneous stack spend fewer nodes on cheap scalar nuisance blocks (a dispersion or zero-inflation random effect) than on the correlated coefficient blocks. R-only, no ABI change.tulpa_re_aghq(sigma_prior = ...). A Penalized-Complexity prior on the marginal standard deviations of one or more random-effect covariance blocks, added to the ML-II objective (and hence the marginal Hessian).NULL(default) is pure ML on the covariances, byte-identical to before. Ac(U, alpha)pair (P(sigma_i > U) = alpha, there_cov_pc_lkj_prior()convention) applies to every block, orlist(blocks = <indices>, prior_sigma = c(U, alpha))to named blocks only. Reuses the exact PC log-prior + Jacobian ofre_cov_pc_lkj_prior()(single source of truth), so the+ log sigmaJacobian repelssigma -> 0and the- lambda sigmaterm caps inflation. A weakly-identified variance component (e.g. a scalar dispersion / zero-inflation random effect at few groups) can drift to the boundary and flatten the marginal Hessian; a weak PC prior adds curvature there, keeping the joint optimum non-singular without materially shifting an identified fit. R-only, no ABI change.
tulpa 0.0.84
Checkpoint fix (#161).
-
Diagnostic re-solve no longer aborts a resumable joint fit. In a joint nested-Laplace fit, the outer Pareto-k diagnostic re-solves the inner marginal with its own cheaper solver knobs (
max_iter,tol,inner_refresh). Those knobs are part of the checkpoint fingerprint, so withcontrol$checkpointset anddiagnose_k = TRUEthe diagnostic pass computed a fingerprint that did not match the file the main outer grid had written, and the fit stopped with a “fingerprint mismatch” error after the main grid had already completed. The diagnostic solve now runs checkpoint-free (via the same quiet-options path the CCD / adaptive probes already use), so only the main outer grid owns the checkpoint. A resumed fit stays byte-identical to an uninterrupted one; fits withdiagnose_k = FALSEare unaffected.
tulpa 0.0.83
Front-door API convention cleanup (#156, fully closed) and the missing-front-door features (#158, fully closed). ABI break (TULPA_ABI_VERSION 34 -> 35) for the proper-CAR exact-NUTS eigenvalue log-determinant; downstream packages must rebuild.
-
Unified fixed-effect prior.
beta_prior = list(mean, sd)is now the one interface acrosstulpa_ep,tulpa_multinomial,tulpa_ordinal,tulpa_gibbs,tulpa_gaussian,tulpa_re_cov_gibbs,tulpa_nuts_beta, andtulpa_nuts_spde, replacing the four names (beta_prior_sd,sigma_beta,prior_beta_sd,beta_prior_mean/_sd) it had before. -
Statistical hyperpriors leave
control. New statisticalre_prior = list()argument ontulpa()carries the random-effect / variance-component hyperpriors (prior_sigma,eta,prior_df,prior_scale,prior_sigma_scale,sigma_re_scale);sigma_betafolds intobeta_prior.tulpa(control = list(prior_sigma = ))now errors and points atre_prior. -
Control doctrine.
tulpa_gibbs,tulpa_gaussian,tulpa_nuts_beta, andtulpa_nuts_spdemove their perf/sampler knobs intocontrol = list();tulpa_gibbsthreadsthinand seeds via the session RNG. -
Inference as an argument.
tulpa_laplace_beta(mode = "nuts")andfit_spde(mode = "nuts")delegate to the NUTS engines, folding thetulpa_nuts_beta/tulpa_nuts_spdeverb variants behind amode=switch. -
Flagship input validation.
tulpa_nested_laplace,tulpa_gibbs, and the twore_covfitters now checknrow(X) == length(y),n_trialslength, andre_idx/grouprange up front. -
Hard-error silently ignored inputs.
tulpa_em_laplace()rejects the never-consumedspatial=/re_list=;tulpa_re_cov_gibbs()errors when aprior_scalematches no RE block;plot.tulpa_st_summary()implementstype = "spatial_map"and errors on an unknown type. -
EP is a registered backend (#158):
tulpa(mode = "ep")fits a fixed-effect GLM by Expectation Propagation (Tier 2 structured). -
SPDE + random intercept (#158): a single
(1 | g)term now rides alongside a Matern SPDE field throughfit_spde()andtulpa()(integer-nu, conditioned onsigma_re). -
predict()kriging for continuous fields (#158):predict()now interpolates the posterior-mean field to new coordinates for HSGP (spatial_gp(approx = "hsgp")) and GP/NNGP (spatial_gp()) fits, not just SPDE. Both are marginalised over the hyperparameter grid via new native kernels (cpp_hsgp_field_predict,cpp_gp_field_predict); held-out recovery cor > 0.9 against a known surface. -
FIX: a
tulpa()GP/NNGP nested-Laplace fit aborted with “‘a’ is computationally singular” on ordinary data (Matrix’s condition guard on the ill-conditioned joint precision); the fixed-effect marginal-Hessian solve now retries with a negligible jitter, so gp/nngp fits complete. -
Tier-1 exact SPDE from the front door (#158):
tulpa(spatial = <spde>, mode = "exact")routes to NUTS over the Matern field + hyperparameters. -
Spatiotemporal nested Laplace (#158): new exported
fit_st_nested()wires thecpp_nested_laplace_st_{icar,bym2,car_proper}kernels (additive areal field + rw1/rw2/ar1 temporal field, integrated jointly over the spatial / temporal precisions and the ar1 autocorrelation), previously reachable only from consumer packages. Recovers both fields (cor > 0.85) on simulated data. -
latent_factor()roxygen corrected: it is a ratio / multi-arm construct for consumer packages, not the single-responsetulpa()front door (#158). -
Continuous / areal fields from the exact-NUTS front door (#158):
tulpa(spatial = <field>, mode = "exact")now samples the latent field, its variance, and (where present) its range / correlation jointly with Tier-1 NUTS for GP / NNGP (spatial_gp()), HSGP (spatial_gp(approx = "hsgp")), and proper CAR (spatial_car_proper()), joining the SPDE exact path. The generic ModelData sampler keys each block onspatial_type, so the field enters the parameter vector (previously only the fixed effects were sampled). The proper-CAR log-determinantlog|D - rho W|is evaluated in the autodiff-friendly closed formsum_i log(1 - rho mu_i)from the precomputed adjacency eigenvalues, not a per-gradient Cholesky. Each field’s NUTS posterior-mean recovers the simulated truth and matches its nested-Laplace counterpart (cor > 0.88). -
Spatially- and temporally-varying coefficients from the front door (#158):
tulpa(spatial = spatial_svc(...), mode = "exact")andtulpa(temporal = temporal_tvc(...), mode = "exact")fit NNGP spatially- varying and RW1/RW2/AR1 temporally-varying coefficients, threaded into the generic sampler via newsvc_spec/tvc_specinputs. Exact-only: a nested / laplace / auto mode errors rather than silently dropping the varying field. Recovers the varying-coefficient surface / trajectory on simulated data (cor > 0.94).
tulpa 0.0.82 (2026-07-15)
The sampled spatial range gets a real prior. ABI break (TULPA_ABI_VERSION 33 -> 34); downstream packages must rebuild, and any package placing a gp() / svc() NNGP block must now supply the range anchors (see the last bullet).
FIX (statistical): the GP and SVC NNGP paths placed a Uniform prior on the spatial range
phibehind a hardreturn -INFINITYoutside(phi_prior_lower, phi_prior_upper). Two defects compounded (gcol33/tulpa#144). The rejection sits inside an autodiff log-posterior, so a step landing outside the box produced no usable gradient and NUTS reported it as a divergence; and the+ log_phiJacobian made the flat density in the sampledlog_phia Uniform onphiitself, whose default(0.01, 10)carries mean ~5. Under a weakly informative binary likelihood the posterior collapsed onto that mean. Measured downstream onoccu() + svc()with a truth ofphi = 0.25on unit-square coordinates: 72-83% of post-warmup draws divergent andphiat ~4 across every seed (gcol33/tulpaObs#118). Both paths now samplelog_phiunconstrained under a PC prior on the range, which is proper on(0, inf)and needs no bounding box – the same shape the SPDE field has always used forlog_kappa.FIX (statistical): the SVC NNGP block’s half-Cauchy prior on the marginal SD was improper on the coordinate it is sampled on, so nothing bounded the SVC marginal SD from above. The density is written on
sigma(-log(1 + sigma^2 / scale^2)) but carried to the sampledlog_sigma2with that coordinate’s variance-row Jacobian (a fulllog_sigma2) rather than its own (-log(2) + 0.5 * log_sigma2). The surplus0.5 * log_sigma2flattens the tail exactly:log ptends to a constant assigma2grows, so the mass below a bound grows linearly in that bound instead of converging, and only the likelihood pulledsigmaback. The GP block was never affected – its base density (log_prior_sigma2_pc_t) is written onsigma2, so the fulllog_sigma2is the right Jacobian there. Both scale priors now come from named helpers inpc_prior.h(log_prior_sigma_half_cauchy,log_prior_log_sigma2_half_cauchy) rather than being spelled out at the sampling site, which is the failure mode that header exists to prevent.test-pc-prior.Rasserts the density integrates to the half-Cauchy normalizerscale * pi/2and that doubling the integration bound leaves the accumulated mass unchanged.FIX: every NNGP fit whose locations carry replicates returned
H_beta = NULL, so all fixed-effect standard errors were lost – degraded to a warning rather than an error, and covered by a test that guarded its own assertions behindif (!is.null(fit$H_beta)), which passes precisely when the builder fails. The obs -> unit design read the map fromspatial_idx, the field an areal spec carries; a GP spec calls itobs_to_loc. Absent, the map defaulted toseq_len(n_obs), whose column index runs pastn_spatialas soon as one location holds more than one observation. The helper now takes the map and the unit count from its caller, and rejects a map that is missing, short, or out of range instead of defaulting.FIX (statistical):
spatialRange()reported the reciprocal of the range. It computed the effective range as3 / phi, commented as a decay parameter, but every kernel this engine ships isexp(-d / phi)(cov_exponential,cov_matern32,cov_gaussian), sophiis the range and the effective range is-phi * log(0.05) ~= 3 * phi. A fit atphi = 0.25was reported at 12 rather than 0.75.FIX (statistical): the unwired
log_prior_phi_pcset its rate to-log(alpha) / Uwhile its own derivation,exp(-rate / U) = alpha, gives-U * log(alpha), and it disagreed with both written PC range priors. It had no call sites, so it never reached a fit; it is removed rather than wired.test-pc-prior.Rpins the contract it violated by integrating the density:P(range < U) = alpha.REFACTOR: the d = 2 PC range density now lives once, in
pc_prior.halongside the PC scale densities, and the SPDE hyper-prior consumes it instead of restating the closed form.test-pc-prior.Rchecks it againstpc_prior_log_density(), the independently written R twin the SPDE nested path integrates against. The stale block comment intulpa_priors_spde.hstill documenting the superseded d = 1 form is corrected.REFACTOR: the logit-bounded parameterization (map + exact Jacobian) is extracted from the temporal GP path into
bounded_from_logit()/log_jacobian_bounded()inautodiff_utils.h. Behaviour is unchanged; the temporal range keeps its Uniform prior, since moving it off Uniform needs the d = 1 PC density thatpc_prior.hdeliberately does not provide.BREAKING:
ModelDatacarriesgp_phi_prior_U/gp_phi_prior_alphaandsvc_phi_prior_U/svc_phi_prior_alpha(encodingP(range < U) = alpha) in place of the_lower/_upperbounds. They ship unset (-1.0) andcompute_param_layout()errors once, before sampling, on any NNGP block that fails to set them – matching the SPDE field’s existingprior_rangecontract. The engine does not default them: a silent default is a prior, and an unanchored range under a weakly informative likelihood is precisely howphicame to sit at the old Uniform’s mean. The SVC HSGP path is unaffected; it puts aLogNormal(0, 1)on an unbounded log-lengthscale and reads neither.The internal
tulpa_version()reads DESCRIPTION viautils::packageVersion()instead of returning a version restated in C++. The literal dated to the first commit and had never been updated, so it disagreed with DESCRIPTION across the whole 0.0.x line; the test asserted the same literal, which is why nothing caught it. The C++ entry point is removed rather than kept in sync, and the test now reads DESCRIPTION directly.TULPA_ABI_VERSIONremains a compiled constant by design: it describes the DLL a model package linked against, not the metadata beside it.
tulpa 0.0.81 (2026-07-15)
Third deep-audit pass: statistical, memory-safety, and backend-consistency fixes surfaced by a fan-out code audit.
- FIX (statistical): the GP NNGP autodiff kernel and its double twin conditioned the neighbour covariance differently – the double copy added a
1e-8nugget to every diagonal, the autodiff copy added it only to an already-degenerate pivot, so on well-conditioned data it added none at all. The analytic GP gradients are finite-differenced from the double copy, so the value and the gradient described different models on ordinary input, not just degenerate input. (hmc_gp_autodiff.hcarried a “known heisenbug with autodiff” note.) Both twins now share one kernel and one pair of constants. - FIX: the SVC NNGP double kernel and its autodiff twin ran different conditioning – 1e-6 vs 1e-4. The 0.0.75 consolidation (#109) routed the double kernel through a
double-only core the autodiff twin could not use, so the twin kept its own literals; and the function it consolidated has no callers, while the live SVC path runs the autodiff one. Both now readtulpa_svc::kSvcJitter/kSvcVarFloor, pinned at the live path’s values, so behaviour on the reachable path is unchanged. - The Vecchia/NNGP conditional (factor, krige, floor, accumulate) is now one templated kernel in
nngp_cond.h, shared by the SVC and GP kernels on both thedoubleand autodiff paths – the previous shared core wasdouble-only, which is precisely why the autodiff copies were hand-written and drifted. The per-kernel conditioning constants stay explicit arguments (the SVC kernel deliberately runs looser than the GP one), but a kernel can no longer differ from its own twin. Newtest-nngp-twin.Rasserts each autodiff kernel, instantiated atT = double, returns its double twin’s value – including on near-duplicate coordinates, where the conditioning actually bites (gcol33/tulpa#142 A3). - FIX (statistical): the GP NNGP gradient returned half the true derivative of the gaussian covariance with respect to the range –
dcov_dphiinhmc_gp_gradients.hdropped the factor of 2 ink * 2*d^2/phi^3. The SVC copy of the same function had it. Aspatial_gp(cov = "gaussian")HMC/NUTS fit therefore explored the range on a mis-scaled gradient. - FIX (statistical): neither copy implemented the spherical range-derivative, so
spatial_gp(cov = "spherical")computed its covariance from the spherical kernel and its gradient from the exponential one – the value and its derivative described different kernels. The spherical derivative is now implemented (it needssigma2, since unlike the others it is not proportional tok(d), which is why it had been left to fall through). -
dcov_dphiis single-sourced: the GP copy delegates to the canonicaltulpa_svc::dcov_dphi_svc. Newtest-cov-kernel.Rchecks every covariance type’s range-derivative against a numerical derivative of the value function it differentiates, so a copy cannot drift from its own kernel again (gcol33/tulpa#142 A5). - FIX: two out-of-bounds accesses in the analytic temporal gradient kernels –
rw1_grad_wread one past the end ofwat a single time point, andrw2_grad_wwrote one past the end of its second-difference buffer for fewer than two. The sigma2-parameterized twins guarded both cases; the precision twins, written separately, did not. The RW2 and AR1 gradients are now thin wrappers over the canonical precision kernels (as RW1 already was), and the AR1rhogradient floors only the dividing1 - rho^2so it stays finite at the stationarity boundary without biasing the stationary precision. Newtest-temporal-grad-equiv.Rpins the wrappers against the canonical kernels and both against numerical derivatives of their priors – neither header was reached from a compiled translation unit, so nothing would previously have caught a wrong wrapper (gcol33/tulpa#142 A7). - FIX:
control$hessianwas parsed andmatch.arg-validated on the multi-block joint nested-Laplace path and then never passed to the kernel, so"psd"and"fisher"silently ran as LM / observed curvature. The single-block path forwarded it correctly, so the setting worked or not depending only on how many blocks the prior had. The seven.cpp_joint_multicall sites now go through one fit-scoped factory (.joint_multi_call_factory) that supplies the invariant arguments once, so a per-fit setting cannot be honoured at one solve and dropped at another; each site drops from ~19 arguments to a handful (gcol33/tulpa#142 A6). - FIX:
temporal_ar1()andspatial()silently acceptedshared = FALSEwhile every sibling constructor warned about unshared confounding structure. The warning was copied into 12 constructors, two of which had also drifted to a shorter one-sentence form; all now call one.warn_nonshared()helper. - FIX:
spatial_car(),spatial_bym2()andspatial()validated a raw adjacency matrix only for squareness and exact symmetry – strictly weaker than the validatoradjacency()/check_adjacency()already use. A graph with self-loops, non-binary weights or isolated nodes was reported bycheck_adjacency()and accepted silently by the constructors, which then built an improper field. They now share.validate_adjacency_arg(), so the same graph reports the same way whichever door it enters. This also stops the constructors densifying a sparse graph to test symmetry (O(n^2) memory) and accepts float-rounded symmetric matrices thatcheck_adjacency()accepts. Newtest-field-constructor-shared.Rpins the cross-constructor consistency (gcol33/tulpa#142 A8). - FIX: the Laplace binomial log-likelihood dropped the
lchoose(n, y)normalizer, so it was not a true log-density: a binomiallogLik/ WAIC / cross-backend comparison was off bysum(lchoose(n_i, y_i))whenevern > 1, while the autodiff and GLMM-oracle paths (anddbinom()) kept it. The term is eta-independent, so no mode, gradient or normalized grid weight moves; only the reported absolute value changes. Bernoulli data (n = 1) is unaffected. - New
test-family-cross-path.Revaluates every per-family (loglik, grad, curvature) kernel maintained in parallel – the Laplace/Newton dispatch, the compiled GLMM oracle, and the explicit triplets – at a shared(y, eta, phi)and pins them against each other and against R’s own densities. It guards the 0.0.73 bug directly:phiis the residual SD in the Laplace kernels and the residual VARIANCE in the GLMM oracle, bridged only by convention in R, so the test assertsglmm_elt(phi = s^2) == log_lik_for_family(phi = s)and checks that passing the wrong scale is actually detectable. Adds the three probes that made this constructible (cpp_family_terms,cpp_glmm_elt_terms,cpp_test_laplace_gaussian– the gaussian triplet carrying the phi convention previously had no callable surface) (gcol33/tulpa#142 A9). - FIX (statistical): the PC prior on the TVC log-precision carried an excess
+2*log_tau, tilting the prior bytau^2toward large precision (improper astau -> Inf) and biasing time-varying-coefficient SDs low – coefficients were over-smoothed toward constant. The identical error had been found and fixed in the spatiotemporal block but was never propagated to the TVC copy. - FIX (statistical): the PC prior on the HSGP log-variance mixed two change-of-variables conventions and lost
+log(sigma), leaving the density flat (improper) at the origin and biasing the HSGP amplitude low. The same applied to the HSGP-ST block. - The PC prior is now single-sourced for real:
pc_prior.his templated over the autodiff types and provides the density on every scale a sampler parameterizes (sigma,log_sigma,sigma2,log_sigma2,tau,log_tau), each derived once from the base exponential plus its Jacobian. Every site delegates. The header was previouslydouble-only, which is why each autodiff path re-derived the algebra by hand – and why two of them got it wrong. Newtest-pc-prior-scales.Rpins each scale against the base density plus a numerical Jacobian and checks each integrates to one (gcol33/tulpa#142 A4). - FIX (statistical): RW1/RW2 single-block Laplace deleted the field’s level – the post-Newton centering discarded the field mean instead of folding it into the intercept, so an intrinsic temporal fit reported an intercept of ~0.
spec_inner_solvenow compensates process 0’s intercept like the joint driver; RW1/RW2 intercept recovery tests added. - FIX (statistical): the LKJ prior on correlated random slopes double-counted the Cholesky -> correlation Jacobian (effective LKJ(2.5) not LKJ(2)); removed the spurious term in
tulpa_priors_re.h/lkj_chol_helpers.h. The test now cross-checks the Stan Cholesky lpdf plus an independentdet(R)^(eta-1) * |dR/draw|pushforward. - FIX (statistical): the temporal-GP non-centered branch added a spurious
z -> fJacobian (the NC target is-0.5 z'z); the spatiotemporal PC-prior Jacobian had the wrong sign for the log-precision parameterization; the SPDE range PC prior flipped its tail in the nested path and used the d=1 shape for a 2-D field in the NUTS path. All corrected to the d=2 Fuglstad et al. (2019) form; the stale d=1 test reference is updated. - FIX (statistical): temporal AR(1) under NUTS was restricted to
rhoin (0,1); it now maps to (-1,1) to match the documented Uniform(-1,1) prior andbuild_ar1_precision. - FIX (statistical): the sampler ICAR/BYM2/ST rank normalizer counted a single graph component; it now counts connected components (
S - k) like the Laplace path, via a sharedcount_graph_componentshelper (ABI 32 -> 33). - FIX (statistical): the single-point NNGP Laplace attached observation
ito field nodei, mis-mapping and dropping observations when coordinates repeat (n_spatial < N); it now uses the per-observationobs_to_locmap. - FIX (draws): BYM2 posterior draws constrained the unstructured
thetacomponent to sum-to-zero (it has a proper N(0,1) prior and no centerer);tulpa_re_aghq()reportedlog_marginalincluding the LKJ penalty; and a nested-Laplace mixture node with a failed Cholesky became a zero-variance point mass that deflated fixed-effect CIs. All corrected. - FIX (memory / concurrency): the nested-Laplace per-cell block cache could collide worker threads onto one slot when
n_threads_outerexceeded the environment thread count (e.g.OMP_NUM_THREADS=1withn_threads_outer > 1), corrupting the shared CHOLMOD factor; the outer width is now clamped. Also fixed: an out-of-bounds write in the NNGP non-centered forward, a missing exception barrier around atweedieRcpp::stopin a parallel region, per-thread reduction buffers allocated inside a parallel region, and an untrusted checkpoint record length driving a multi-GiB allocation. - FIX (joint driver): local-CCD refinement combined with
store_Qleft the stored per-cellQmisaligned with the refined grid and crashedtulpa_posterior_draws; local-CCD is now skipped whenstore_Qis set. Thek_qualitygrid-refinement verdict no longer over-claims on multi-block fits. - CHANGE (tiers):
sgld,sghmc, and (unadjusted)mclmcare reclassified from tier “exact” to “optimized” – they carry discretization / minibatch bias, somcmc_diagnostics()no longer certifies them as exact and auto-mode never selects them silently.smcstays exact. - FIX (single-source):
print.tulpa_spatialwas defined twice (the SPDE copy shadowed the areal ICAR/CAR/BYM2 formatter); merged into one method. A dead duplicate.with_preserved_seedwas removed. - FIX (CRAN):
tulpaRatioexample blocks moved from\donttestto\dontrun;toolsdeclared inImports; theggplot2vignette chunks gated onrequireNamespace(). - FIX (previously unwired PG kernels): the six binomial Polya-Gamma spatial / temporal Gibbs kernels (ICAR, GP, multiscale GP, temporal AR1, BYM2, RSR) – exported but not wired to a front door, and previously untested – carried real statistical defects. Fixed and validated with a new parameter-recovery suite (
test-pg-spatial-recovery.R): the ICAR tau shape now counts connected components and the field mean is absorbed into the intercept; the single- and multi-scale GP kernels use the NNGP-correct conjugate sigma2 and MH phi (sharedupdate_nngp_scale, DRY) with the field level anchored (they diverged before – sigma2 railed, phi did a data-free random walk); the temporal AR1 conditional now uses both neighbours and rho is sampled (it was frozen at its init yet reported as a posterior); and the BYM2 component updates remove only the other component’s contribution. The two-process negbin kernel now draws the exact real Polya-Gamma shape (rpg_real).
tulpa 0.0.80 (2026-07-14)
CRAN-preparation release.
- FIX (statistical, Polya-Gamma Gibbs): the shared fixed-effect update drew from
N(m, (L'L)^{-1})instead ofN(m, (LL')^{-1})– a forward substitution where the transpose back-substitution was required – so every PG Gibbs backend (binomial ICAR/BYM2/RSR/GP/multiscale/temporal, negbin, the two-process negbin) sampled beta with the wrong covariance wheneverp >= 2and the design was correlated. Means were unaffected. Draw covariance is now pinned against the asymptoticglmvcov in a regression test. - FIX (statistical, Laplace SEs): the fixed-effect curvature
H_beta(and the RE / spatial-field Schur complements) evaluated the GLM weights atetaWITHOUT the observation offset, sovcov()/ SEs /confint()described a different model on any non-gaussian offset fit (poisson probe: link-scale SEs off ~2.3x). The offset now enters the Hessian eta on every path. - FIX (statistical, negbin Gibbs): the PG augmentation
omega ~ PG(y + r, eta)rounded its real shape to an integer, biasing the chain worst at zero counts with smallr. Newrpg_real()draws the exact real-shape Polya-Gamma (truncated sum-of-gammas with tail-mean correction). Same pass: the spatial negbin kernel’s ICARtaushape counts adjacency components, its sum-to-zero centering absorbs the field mean into the intercept (posterior-invariant), and all three negbin kernels share the exact half-Cauchy auxiliary scheme forsigma_re. - FIX (concurrency): the hsgp / hsgp_mo / spde / nngp latent-block factories kept one shared
prep-rebuilt cache; under the joint driver’s parallel outer grid (control$n_threads_outer > 1) concurrent cells silently solved at each other’s hyperparameters. Per-cell state is now published under the cell id (NlCellCache), safe under the coupled-cell scatter’s work-stealing tasks; joint and single-arm car_proper use the same cache. Regression test: joint fit identical undern_threads_outer = 2and1. - Robustness (C++): exception barriers on the nested-Laplace grid’s OpenMP cell loops and the parallel NUTS chain loop (a worker exception was
std::terminate); the remaining six registered C callables carry the shim exception guard; every OpenMP region is sized by an explicitnum_threads(...)clause through theOMP_THREAD_LIMIT-aware clamp and the process-globalomp_set_num_threads()mutations are gone; allocation sizes compute insize_t. - API: every front door validates its
controlnames against a canonical whitelist (a misspelled knob was a silent no-op) andtulpa()errors on stray...arguments (familly = "poisson"used to fit a gaussian). The joint fitter’sdiagnose_drawsknob is renamed tok_samples, the one name every nested-Laplace fitter uses (hard rename, no alias). - API: front-door SPDE fits answer
coef/summary/confint/vcov/predict(the nested return now carries an anchored mode plus the field-marginalH_beta); directtulpa_nested_laplace()/tulpa_nested_laplace_joint()fits slicecoef()to the actual fixed effects instead of relabelling the full latent vector;tulpa_gibbs()returns a classedtulpa_fit;fitted()includes the offset and trial scaling soy - fitted(fit)equalsresiduals(fit, "response");tidy/glancere-export thegenericsgenerics (no masking next to broom). - The backend registry is the single source for per-backend family support (spde gained its gaussian entry; the agq entry declares its three families);
compare_models()/model_average()on Laplace-tier fits are deterministic (the Gaussian-synthesis draws behind the reconstructed log-likelihood are seed-pinned and RNG-neutral). - FIX (heap corruption): lazily-initialized
thread_localC++ objects inside OpenMP parallel regions corrupt the process heap under the mingw toolchain. One call to the NNGP CG/PCG solver (spatial_gp(solver = "cg")) was enough to crash the R session minutes later (Windows fail-fast 0xC0000374/09), and the joint coupled-cell scatter / parallel-chain NC-GP storage carried the same pattern – the long-standing “OpenMP joint-parallel test heap-corrupts under parallel testthat” instability. All four sites now use either function-local buffers or constant-initialized POD-pointer TLS (the safe pattern the autodiff arena already used). The full test suite completes in a single process again. - FIX: inline
temporal(~ ... || time)varying-coefficient fields reported empty per-time field means (fit$temporal_fields[[...]]$meanhad length 0): the shared bar-field core read the joint layout’sfield_starts, which registers only areal block types. It now reads the per-blockblock_start, which is aligned for every block type. - Documentation: every exported Rd documents its return value; stale ratio-era examples (two-arm formulas with
tulpa_poisson_gamma()/tulpa_binomial()and pre-controlarguments) are rewritten as runnable single-response engine examples (plot_rhat, spatial_bym2, spatial_hsgp, spatial_rsr, temporal_rw2, temporal_ar1) or marked\dontrunwith their tulpaRatio provenance; all 318 Rd example sets now run clean including\donttest. Slow-but-runnable examples moved from\dontrunto\donttest(\dontrunkept only on experimental-path examples); new runnable examples for the core verbs (tulpa_criteria(),compare_models(),model_average(),mcmc_diagnostics(),tidy(),glance(),ranef(),moran_i(),posterior_sample(),tulpa_em_laplace()); R sources and Rd are ASCII-clean; plumbing exports (ccd_grid(),hyper_axis_spec(),sn_*(),findbars(), …) are@keywords internal. - New generics:
residuals.tulpa_fit("pearson"/"response", computed from the family registry) andnobs.tulpa_fit;print.tulpa_fitrewritten to be shape-robust across the sampler / Laplace / nested tiers (it printed Gaussian-only fields before). - WAIC / LOO on engine fits: when a fit does not carry
$draws$log_lik, the pointwise log-likelihood is computed from the linear-predictor posterior draws and the family registry, socompare_models(),model_average()and the criteria layer work ontulpa()fits from any tier. - Simulation diagnostics (
pit_residuals(),test_dispersion(),test_outliers(),test_zero_inflation(), …) read the engine fit’s stored response ($y); the default residual type formoran_i()/durbin_watson()is"pearson", matching the newresiduals()method. - RNG hygiene: user-supplied seeds are applied through a scoped helper that restores
.Random.seedwhen the fitter exits (9 fitters plusbayes_R2()), so a seeded fit no longer clobbers the session RNG stream. -
control$re_cov = "aghq": random-slope fits can route the nestedSigmaintegrator through the AGHQ inner marginal (control$n_quad, default 9 there). -
tulpa_re_aghq():maxitrenamedmax_iter(hard rename, no shim). The EM drivers report progress viamessage()(suppressible);tulpa_gibbs()is quiet by default. - Every
extern "C"C-callable shim (the model-package ABI) now catches C++ exceptions at the boundary and converts them to R errors instead of unwinding across the C ABI. - OpenMP team sizing honors
OMP_THREAD_LIMITvia the sharedtulpa_omp_team_size()helper (CRAN 2-core test compliance). - DESCRIPTION:
Remotesdropped (tulpaMesh is on CRAN), Description expanded with method references (DOIs),cphrole added; newinst/WORDLIST,inst/COPYRIGHTS, and acleanupscript;inst/CITATIONreadsmeta$Version. Vendoredsrc/simp/headers carry license lines. - Test suite: heavy fit / sampler blocks are
skip_on_cran()-gated; vignette sampler chunks shrunk to CRAN-friendly iteration counts.
tulpa 0.0.79 (2026-07-12)
-
integration = "grid_adaptive"now declines to the dense tensor BEFORE any inner solve when the outer grid is small (fewer thancontrol$adaptive_grid_min_cellscells, default 48), instead of paying a coarse-seed pass first. On a small tensor the coarse seed is already most of the grid, so there is no tail worth skipping; the guard means the adaptive integrator can never be slower than the dense tensor on a small grid. Measured on the real MOTIVATE 25 km shared-trend fit (a 22-cell outer grid),grid_adaptiveandgridare now the same fit at the same wall-clock. The adaptive integrator earns its speedup only on large outer grids whose hyperparameter posterior concentrates.
tulpa 0.0.78 (2026-07-12)
- New outer-grid integrator
integration = "grid_adaptive"for the multi-block joint nested-Laplace driver, the low-dimensional companion to the CCD. It seeds a coarse subsample of the hyperparameter tensor lattice (latent block axes and phi axes together), floods outward from the posterior mode on the fine lattice, and evaluates only the cells within a log-density cutoff of the peak. The kept cells are a strict, uniform-weight subset of the dense tensor, so the posterior matches the dense grid to that cutoff (each omitted cell carries dense-grid weight< exp(-cutoff)) while evaluating far fewer inner solves when the hyperparameter posterior concentrates – the fine-grid regime where the mass sits in a handful of cells. On a diffuse posterior it declines back to the dense tensor after only the coarse seed, so it never costs accuracy. Tuned bycontrol$adaptive_grid_cutoff/adaptive_grid_stride/adaptive_grid_max_frac; the kept-cell / dense / solve counts are returned as$adaptive_grid_info. The main kernel call re-evaluates the selected cells with the full contract (store_Q, phi tensor, tile warm start), so the fixed-effect and per-cell posteriors are unchanged from the dense path. Fills the gap the CCD leaves at1 <= d <= 3latent axes, where the CCD mode-find is not worth it but a dense tensor still pays a full inner solve for every near-zero-weight cell.
tulpa 0.0.77 (2026-07-10)
- The outer-grid thread memory clamp in the sparse joint nested-Laplace driver now budgets against the memory that is actually free, not the installed total. The previous clamp (tulpa#64) reserved half of total RAM for the replicated per-thread working set, which over-provisions on a loaded machine: half of a 64 GB install is 32 GB even when only 30 GB is free and the rest is already committed, so a wide fit (a fine-resolution SPDE field at many outer threads) still spilled into swap or ran out of memory. The clamp now sizes the per-thread pool at a safety fraction (0.6) of currently-available RAM, leaving the remainder for the grid results, CHOLMOD fill-in, and OS headroom, and falls back to half of total (then a fixed 2 GB) only when the platform memory query is unavailable. The budget arithmetic is factored into
mem_budget.h(outer_thread_mem_budget(),outer_thread_cap()), single-sourced and unit-tested. - The per-thread footprint the clamp budgets is now measured, not guessed. The dominant term is the CHOLMOD factor each concurrent solve carries, and its fill-in on a 2D SPDE mesh is superlinear, so the previous flat “2x nnz(Q)” allowance under-counted a fine field (3.9x low at a 16x16 lattice, widening to 7x low by 96x96 – and worse still at production resolution). The clamp now runs a one-time supernodal symbolic analysis of the joint Hessian pattern (the same
as_cholmodview and supernodalcholmod_commonthe inner solve uses) and reads the factor’s trueL->xsize, via the newSparseCholeskySolver::analyzed_factor_bytes(); the analysis is symbolic (no numeric values, runs once in serial setup) and falls back to the old estimate only if it produces no factor. The O(n_x) Newton scratch is now counted too. - When the clamp has to reduce the requested outer thread count it now emits an R warning naming the old and new thread counts and the memory budget, so a memory-driven slowdown is visible rather than silent. In the floor case – a single cell’s working set larger than the whole budget – it warns that the fit runs best-effort at one thread and may page or run out of memory, and points at the real remedies (a coarser grid/resolution, freeing RAM, or
control$checkpointto make the run resumable) rather than crashing. - New platform memory queries
available_ram_bytes()(WindowsullAvailPhys, Linux/proc/meminfoMemAvailable, macOS Mach free + inactive pages) alongside the existingtotal_ram_bytes(), exposed to R for diagnostics ascpp_available_ram_bytes()/cpp_total_ram_bytes().
tulpa 0.0.76 (2026-07-10)
The coupled-cell scatter in the sparse joint nested-Laplace driver (the
occu_cover()hot loop, about 94% of runtime on a large fit) runs in parallel when the outer hyperparameter grid is under-saturated: the tail of any grid, small grids, or a many-core server where more outer threads are free than there are active grid cells. Those idle team threads steal per-cell scatter chunks, each accumulating into a private partial Hessian that is reduced in a fixed chunk order, so no grid cell’s scatter runs single-threaded while freed cores sit idle. When the grid saturates the thread pool (the bulk of a large fit) the scatter stays serial and byte-identical to before; only the under-saturated cells chunk, and they stay within the thread-invariance tolerance (means agree to about 1e-14 and are reproducible run to run via the fixed-order reduce). SetTULPA_GRID_WORKSTEAL=0to force the serial scatter for exact reproducibility.The documented starting point for the PC prior on the copy coefficient
alphais nowc(U = 8.0, alpha = 0.01). A sweep over prior strengths on the alpha-recovery fixture showed the earlierU = 2.0recommendation over-shrinks the copy coefficient below its truth and, through thealpha * sigmacopy axis, lifts the coupled donor amplitudesigmaabove its truth;U = 8.0regularizes the tail without that bias. The sigma-pos-prior recovery test asserts the retuned, measured behaviour.
tulpa 0.0.75 (2026-07-08)
SPDE field prediction standard errors (
predict(se.fit = TRUE)with the field included) are computed by a streaming C++ kernel (cpp_spde_field_se) that factorizes the joint (beta, field) precision once and solves one query cell at a time. The dense working set isO(p + n_mesh), independent of the number of query cells, so the per-cell SE over a large prediction grid holds a bounded amount of memory. Numerical results are identical.The sparse joint nested-Laplace driver shares one per-worker resource pool (Hessian builder, scatter cache, Newton scratch, arm specs, dense-basis buffers) across the cheap-screen and full-solve passes, which run in disjoint phases. This halves the pre-grid setup working set at a given
control$n_threads_outer; fits are numerically identical.
tulpa 0.0.74 (2026-07-07)
-
adjacency()gains a settable neighbourhood.order = kextends the grid / raster stencil to the k-th ring: queen keeps every cell within Chebyshev distancek((2k+1)^2 - 1neighbours: 8, 24, 48, …), rook every cell within Manhattan distancek(2k(k+1): 4, 12, 24, …). The advancedoffsetsargument takes a custom stencil (a two-column integer matrix or a list ofc(dx, dy)lattice offsets) for any anisotropic / off-axis neighbourhood; because an ICAR / CAR field is undirected, an asymmetric stencil is symmetrized to an undirected graph with a message.order = 1(default) andoffsets = NULLreproduce the previous queen / rook graphs byte-for-byte.
tulpa 0.0.73 (2026-07-07)
- Posterior prediction:
posterior_predict()draws replicated responses from the posterior predictive (per-draw linear predictor – fixed and random effects jointly from the draws, or the Gaussian approximation on the Laplace tier – through new per-family sampling functions);simulate.tulpa_fit()is the base-R alias, andpp_check()falls back to generated replicates when the fit stores noy_rep. - Observation weights:
tulpa(weights =)scales each row’s log-likelihood on the non-spatial Laplace path and the log-posterior samplers; other backends refuse loudly. A weight of 2 reproduces the duplicated-row fit exactly. - Covariate smoothers:
y ~ s(x, k =, structure = "rw2"/"rw1")puts an RW GMRF over the binned covariate and integrates its smoothness hyperparameter through the nested-Laplace temporal kernels (single block alone; the joint multi-block stack alongside an areal spatial field, temporal field, or a second smoother).smooth_effects()extracts the fitted smooth at the nodes. - Second dispersion channel:
phi2threads through the family registry, the compiled kernels, and every fitter surface. The Student-t degrees of freedom are now configurable (family = "t",phi2 = df; default 4). - Gaussian dispersion unified:
tulpa()’sphiis the residual VARIANCE for gaussian/lognormal on every backend, as documented. Previously the compiled kernels (Laplace, ModelData samplers) read it as the residual SD, somode = "laplace"andmode = "mala"fit different models at phi != 1; the conversion now happens once at each R-to-kernel boundary. The direct doors (fit_spde(),tulpa_nested_laplace_joint(),tulpa_sample_glmm()) keep their documented SD parameterization. - New families:
lognormal(variance convention, front-door fittable on the Laplace and sampler tiers) andtweedie(compound Poisson-gamma,phi2= power in (1, 2); Dunn-Smyth series density in R and the Laplace kernel, pinned againsttweedie::dtweedie). - Categorical responses through the front door:
tulpa(family = "multinomial" / "ordinal" / "ordinal_probit")routes to the Laplace drivers;tulpa_ordinal()gainslink = "probit"(pinned againstMASS::polr). - Model criticism:
bayes_R2()(per-draw R^2 with model-based residual variance from new per-family variance functions);tulpa_reloo()(PSIS-LOO with exact refits at observations above the Pareto k-hat threshold, sharing the kfold refit machinery);tulpa_kfold()now threads storedn_trials/weightsper training partition (previously a storedn_trialsexpression evaluated full-length against the subset data). - Expectation Propagation returns its approximate marginal likelihood (
log_marginal, exact for gaussian), so EP fits enter model comparison vialogLik(). - Power-scaling sensitivity gains a
hyperparametercolumn: the nested-Laplace mixture paths record per-draw hyperparameter log-priors at draw-synthesis time andtulpa_powerscale_sensitivity()reweights them. - SPDE kriging uncertainty:
predict(se.fit = TRUE)with the field included propagates the joint (fixed-effect, field) posterior precision at the fitted(range, sigma), including the cross term (integer-nu, no-RE fits). - Temporal AR(p):
temporal_ar(time_idx, p =)generalizestemporal_ar2()via the Levinson-Durbin PACF parameterization (always stationary) and the exact Yule-Walker banded precision. - Boundary decision recorded: general censored/survival responses are observation processes owned by model packages via
LikelihoodSpec; the engine keeps only the generic interval/truncated gaussian kernels.
tulpa 0.0.72 (2026-07-07)
- New approximation-layer fitters:
-
tulpa_ep(): Expectation Propagation for GLMs with a Gaussian coefficient prior – one Gaussian site per observation on the linear predictor, tilted moments by Gauss-Hermite quadrature. Exact for a Gaussian likelihood; matches marginal moments rather than mode curvature, so it is typically more accurate than Laplace on skewed GLM likelihoods. -
tulpa_multinomial(): baseline-category (unordered K-class) multinomial logistic regression as a Laplace fit driven by the validated native kernel (cpp_multinomial_logit_terms) – a joint Newton solve over the K-1 coefficient blocks, no engine change. -
tulpa_ordinal(): cumulative-logit / proportional-odds ordinal regression (L-BFGS mode + Laplace); cutpoint ordering is guaranteed by the log-increment reparameterization.
-
-
tulpa_kfold(): refit-based K-fold cross-validation, the exact counterpart to PSIS-LOO intulpa_criteria()for fits whose outer Pareto k-hat flags importance sampling as unreliable. Fixed-effect / GLMM fits only – subsetting observations would break a spatial or temporal field’s structure, so those are rejected loudly. -
tulpa_powerscale_sensitivity(): power-scaling prior / likelihood sensitivity (Kallioinen et al. 2024) by importance-reweighting the existing draws through the native PSIS smoother – no refits. The cumulative Jensen-Shannon distance and its gradient reproduce the priorsense reference implementation. -
temporal_ar2(): exact stationary AR(2) temporal GMRF as a user-defined latent block (latent(temporal_ar2(time_idx))) – pentadiagonal precision from the Yule-Walker autocovariances, with the PACF parameterization mapping the open square bijectively onto the stationarity triangle. - New builtin families
gamma,inverse_gaussian,beta_binomial, and Student-t(fixed df = 4, matching the C++kStudentTDf) in both the R-closure family ops and the C++ AD path, so they run under Laplace and NUTS alike (test-family-ad-nuts.R). - SPDE:
fit_spde()gainsfamily = "gaussian"(continuous-field geostatistics;phiis the observation-noise SD), andpredict()kriges a fitted Matern field to arbitrarynewdatacoordinates by re-projecting the posterior-mean mesh-node field through the spec’s mesh (include_field = FALSEgives the fixed-effect / population prediction). - API cleanup:
-
fit_spde(),tulpa_re_cov_nested(), andtulpa_re_cov_gibbs()move their perf / numerical knobs (method,n_grid,integration,n_per_axis,span,n_draws,seed,max_iter,tol,n_threads,diagnose_k,k_samples) into the singlecontrol = list(), matching the front-door convention. Statistical arguments stay in the signature. - snake_case renames (hard, no shims):
modelAverage->model_average,postHocLM->post_hoc_lm,spatialRange->spatial_range,temporalCorr->temporal_corr.getSVCSamples()is removed – usesvc(fit, summary = TRUE). The internal formula helpersfind_latent_terms,no_latent_terms, andparse_bar_termare no longer exported.
-
- Internal reorganization: the nested-Laplace posterior-moment machinery moved to
R/nested_laplace_moments.R, and the Polya-Gamma Gibbs dispatch plus thetulpa_gibbs()front door toR/fit_gibbs.R.
tulpa 0.0.71 (2026-07-06)
-
cpp_laplace_fit_spatial()andcpp_laplace_fit_bym2()gain aforce_sparseargument (0 = size threshold, 1 = force sparse, -1 = force dense) threaded to the shared Newton solver. It lets the same problem run through both factorization paths for a byte-level dense-vs-sparse equivalence gate on the single-response path, the analogue of the joint path’sforce_sparsecontrol. Default behaviour (0) is unchanged. - Tests: a byte-level dense == sparse equivalence gate on the single-response ICAR / BYM2 Laplace path (
test-sparse-cholesky.R), atulpa_pit()calibration check (Uniform-under-correct-model, with a powered misspecification counter-case,test-pit-calibration.R), and a stored-draws reference for the default leapfrog integrator so a change to the stepper fails loudly rather than silently reproducing its own new draws (test-integrator.R,tools/gen_leapfrog_ref.R).
tulpa 0.0.70 (2026-07-06)
-
tulpa_integrator("adaptive2")/"adaptive3"cap the warmup-end curvature estimate atDENSE_MAX_PARAMS(200) parameters. Above that bound the step-adapted coefficient resolves at the well-adapted operating point (nu_max = epsilon,omega_max = 1) rather than building the densep x pfinite-difference Hessian, matching the cap the dense mass matrix already applies. A high-dimensional latent field (thousands of BYM2 / ICAR cells) selecting an adaptive integrator no longer allocates an O(p^2) Hessian or runsp + 1gradient sweeps at warmup end. Default leapfrog, the fixed schemes, and any model at or below the bound are unchanged.
tulpa 0.0.69 (2026-07-06)
tulpa_integrator("adaptive2")and"adaptive3"wire the SIMP step-adapted minimum-error integrators into NUTS. Each chain resolves its multistage coefficient at the end of warmup for its own operating point: the coefficient that minimizes the worst-case energy error over the band of dimensionless steps(0, nu_max]the chain actually takes.nu_max = omega_max * epsfollows from the adapted mass matrix and the local posterior curvature (omega_maxis the square root of the largest eigenvalue ofM^{-1}times the curvature, found by power iteration on a warmup-end finite-difference Hessian), so the integrator is tuned to the target rather than to a fixed compromise – the nested-approximation-informs-the-sampler synthesis. The coefficient is per-chain (carried on the NUTS workspace, not a shared global); warmup runs a fixed placeholder of the same stage family so the dual-averaged step size transfers. Default leapfrog and the fixed schemes are byte-for-byte unchanged.tulpa_integrator("mts", mts_substeps = )adds a multiple-time-stepping (RESPA / Verlet-I) integrator to NUTS. The trajectory leaf splits the force into a stiff but cheap prior part (the Gaussian latent structure, taken withmts_substepsinner leapfrog substeps) and a smooth but expensive likelihood part (one full gradient per leaf, as leapfrog pays), so a larger outer step handles the stiff prior without evaluating the likelihood at the inner rate. The split reuses the existingskip_obs_loopdecomposition: a new prior-only gradient path (arena-AD or central differences, folding in any model-package prior) supplies the fast force, andgrad_full - grad_priorgives the slow (likelihood) force – exact when the full and prior gradients are additive, which holds unless a model-package gradient hook alters the full gradient non-additively. The leaf stays symplectic and time reversible, so the U-turn / tree machinery is unchanged. Helps most when the latent field is stiff relative to a comparatively flat likelihood.Refreshed the vendored SIMP snapshot, which adds the step-adapted minimum-error integrators (an exact harmonic energy-error analysis picks the multistage coefficient for a target’s step band, from
omega_max * eps) and the parametrictwo_stage/three_stageconstructors. These ship insrc/simp/; the fixedtulpa_integrator()schemes are unchanged. The integrator decls now include onlysimp/scheme.h, so the Eigen-heavy headers do not enter every translation unit.
tulpa 0.0.67 (2026-07-03)
- The SIMP integrator headers are now vendored into
src/simp/(snapshot viavendor_simp.sh) instead of pulled in throughLinkingTo: SIMP. tulpa builds self-contained – no dependency on a non-CRAN package and noAdditional_repositories– while SIMP (gcol33/SIMP) stays the upstream development home for the integrator core. Re-runvendor_simp.shto refresh the snapshot after updating SIMP. No behaviour change:tulpa_integrator()and the schemes are identical.
tulpa 0.0.66 (2026-07-03)
-
tulpa_integrator("minerror2")selects the two-stage minimum-error integrator from SIMP 0.2.0. Its coefficient cancels the leading energy error on a Gaussian target, so near the mass-adapted optimum it conserves energy well and adapts to larger step sizes without the stability cliff of the high-order Yoshida schemes. It recovers with zero divergences on the linear-Gaussian recovery test and is the recommended advanced integrator for near-Gaussian posteriors. RequiresSIMP (>= 0.2.0).
tulpa 0.0.65 (2026-07-03)
- The HMC / NUTS trajectory integrator is now backed by the SIMP symplectic integrator library (
LinkingTo: SIMP). Both leapfrog steppers (the in-place NUTS step and the fixed-trajectory HMC step) walk a SIMP scheme’s op sequence with tulpa’s own fused mass-matrix kernels, so the integrator identity has a single source of truth while the hot path keeps its specialised drift kernels. The default, leapfrog, is byte-identical to the previous step (verified against the bit-for-bit chain checkpoint and the existing NUTS recovery / reproducibility tests). -
tulpa_integrator()selects the integrator process-wide:"leapfrog"(default) or the higher-order"yoshida4"/"yoshida6"/"yoshida8", generated from leapfrog by SIMP’s triple-jump composition."yoshida4"samples reliably; the higher orders are experimental for NUTS (a sharp step-size stability threshold interacts poorly with dual-averaging adaptation). See?tulpa_integratorandtest-integrator.R.
tulpa 0.0.64 (2026-07-01)
-
tulpa_pit()runs in C++ (cpp_tulpa_pit), and the leave-one-out PIT weighting is exposed ascpp_psis_loo_pit– per-observation PSIS leave-one-out weights (reusing the deterministic PSIS core) applied to predictive-CDF limits, with the PSIS columns parallelised and the single uniform jitter drawn in index order. Both draw from R’s RNG stream in the same order as their former R bodies, so results are byte-identical under a fixed seed (test-pit-cpp.R).
tulpa 0.0.63 (2026-07-01)
-
tulpa_psis()runs its deterministic core – the Zhang-Stephens generalized- Pareto tail fit and the Pareto smoothing of the upper-tail log weights – in a C++ kernel (cpp_tulpa_psis). The R helpers.tulpa_gpd_fit()/.tulpa_qgpd()are kept as the reference oracle (test-psis-cpp.R). The tail size (with its expert-control cap and warning) stays in R, and the bootstrap k-uncertainty still resamples with R’s RNG, so results are unchanged and reproducible; each per-observation LOO fit and each bootstrap refit is now the C++ path. Byte-close to the former R body (~1e-12).
tulpa 0.0.62 (2026-06-30)
-
tulpa_nested_laplace_joint()gainsprior_phi, a regularizing hyperprior on the per-arm dispersion axes declared throughphi_grid(a Beta precision, a negbin size, a Gaussian residual SD). Mirrorsprior_sigma/prior_alpha:NULL(flat over the phi grid, default),list("pc.prec", c(U, alpha)), orlist("half_normal", scale). A single spec re-weights everyphi_<arm>axis by its density at the kernel-call boundary, so refinement and Pareto-k passes see the regularized posterior; with nophi_gridit is a no-op. Threads through the single- and multi-block paths (gcol33/tulpa#139).
tulpa 0.0.61 (2026-06-23)
New
control$local_ccdrefines a multi-block tensor outer grid with local central-composite-design node clouds (R/nested_laplace_joint_ccd_local.R): a few high-weight, mutually non-adjacent interior cells are each replaced by a small curvature-aware CCD design, so a coarse base grid resolves the sharply-peaked hyperparameter directions without thek^dtensor blow-up. The local curvature is a diagonal finite difference of the outer log-marginal over each cell’s own grid neighbours – no mode-find, only the off-centre nodes are new inner solves, warm-started from the cell’s mode. Refined cells carry partition-of-unity design weights, so the total integration weight is conserved exactly (no double-count); the design scale is shrunk per cell so the cloud fits the cell’s Voronoi box (the local-Gaussian mass beyond it belongs to the neighbouring cells, which carry their own mass). Engages only on the tensor path at>= 4transformable latent axes with no activephi_grid– the regime where a uniformly fine tensor isk^d-expensive and grid densification is the wrong tool; below it, the tensor grid is already dense and boundary / interior grid refinement covers a too-narrow grid. The applied refinement is summarised on the result as$local_ccd_info.control$k_refinegains a"ccd"rung alongside"grid". Underk_quality = "ok"/"good", a bad outer Pareto-k-hat now escalates by refining high-weight cells with local CCD node clouds (forcing a tensor base so the curvature stencil has axis neighbours), the right response when the grid is too coarse to resolve a sharp direction rather than too narrow at the boundary. Each round refines more cells; the verdict never silently downgrades and reports when local CCD finds no peaked interior cell to act on. Recovery, weight conservation, and the escalation path are pinned bytest-nested-laplace-joint-ccd-local.R.
tulpa 0.0.60 (2026-06-23)
Joint nested-Laplace CCD outer integration is now robust to a sharply-peaked, ill-conditioned hyperparameter posterior – the shape a joint occu_cover fit with an observation-arm random effect produces, where a narrow field-SD axis sits at a grid edge alongside a wide, weakly-identified axis. The CCD mode-find (
.joint_ccd_grid) keeps the grid-median seed with a fixed finite-difference step as the default and, when that declines, falls forward to a rescue that warm-starts at the best latent grid cell (.joint_ccd_grid_seedevaluates the latent Cartesian grid in one batched call and takes the joint argmax) and uses a per-axis step calibrated to the local curvature (.joint_ccd_calibrate_stepspans ~1 posterior sd per axis: small on a sharp axis, wide on a weakly-curved one, since one fixed step cannot resolve both). A ridge-safe coordinate-ascent seed (.joint_ccd_pilot_seed) is the fallback when the latent grid is too large to evaluate. Well-conditioned and sigma-alpha-ridge fits are unchanged (they engage on the default path); previously a sharply-peaked posterior declined to the full tensor grid. A recovery test pins the rescue.The CCD mode-find’s backtracking line search now evaluates all
max_halvestep lengths in one batched call, so the candidates run across the outer-grid threads rather than one full-field inner solve at a time. On a large field (an expensive inner Laplace) the line search no longer serialises the mode-find; the accepted step is the same the sequential backtrack would take.
tulpa 0.0.59 (2026-06-22)
- New
adjacency()front door builds the symmetric graph thatspatial()andspatial_car()consume, so areal models no longer need a hand-coded adjacency matrix. It is one generic that dispatches on the layout: adata.frameof cell centroids (queen / rook contiguity over the inferred lattice, no extra dependency), ansfpolygon layer (shared-boundary contiguity via DE-9IM; queen = point or edge, rook = edge), and a raster (a terraSpatRasteror astarsobject) whose non-NAcells become the nodes. It returns a printabletulpa_adjacencyobject carrying the sparse 0/1 matrix ($adjacency), the per-node cell identifier ($ids), the inferred cell size, and the node count – the model still receives an explicitgraph = g$adjacency, so the graph stays inspectable before fitting; nothing is guessed from coordinates silently. -
node_index(graph, ids)maps original cell identifiers to 1-based node indices by key, for remapping observation data (many rows per cell, different row order) onto the graph without assuming row alignment. -
check_adjacency()validates a hand-built matrix in one pass – square, symmetric, zero diagonal, 0/1 valued, isolated nodes, and matching unique ids – andadjacency()runs the same checks on the graphs it constructs. -
spatial(),spatial_car(), andspatial_bym2()now accept atulpa_adjacencyobject directly (unwrapping its$adjacency), in addition to a bare matrix.
tulpa 0.0.57 (2026-06-22)
- Joint nested-Laplace cell loop: a
CellCouplingSpeccan now declare, via the newdense_cross_pairs(n_coupled, rank1_self_supported)virtual, which(kk, ll)arm-pair cross-Hessian slabs it actually writes densely. The single-response cell loop allocates a denserc_kk * rc_llslab only for those pairs; every other pair keeps anullptrbuffer (the scatter already guards null). This bounds a cell withJobservations on a self-coupled arm toO(J)rather thanO(J^2): a self block emitted through the rank-1 self-cross descriptor, or a cross a factorising likelihood never writes, no longer reserves aJ x Jslab. The default returns every pair, so a spec that does not override it is unchanged; the change is numerically inert for theoccu_coverspec (the dropped slabs were allocated, zeroed, and never written even before). Removes astd::bad_allocon grids with a very high-visit cell (e.g. an all-undetected cell with tens of thousands of plots).
tulpa 0.0.56 (2026-06-21)
-
k_qualityescalation is now driven by adaptive integration-grid refinement rather than diagnostic-draw doubling (gcol33/tulpa#131). When the outer Pareto-k is bad the integration grid does not faithfully represent the hyperparameter posterior, so each escalation round now REFINES THE GRID (adaptive_gridboundary extension / interior densification, one more pass per round) and re-diagnoses, driven by the bad k, until the band is reached or the round budget is spent. Doublingdiagnose_drawsonly re-scores the same grid, so it is no longer the escalation lever;diagnose_drawsstays the separate knob that sharpens the k ESTIMATE (still auto-raised for"ok"/"good"so the bootstrap CI resolves). -
control$k_refinevalue"mixture"is renamed to"grid"(it refines the integration grid), and the default fork_quality = "ok"/"good"changes from"none"to"grid": asking for a reliable band now chases it by refining.k_refine = "none"opts out (the band is reported but not chased). - The #130 dispatcher invariant – a grid-width deficiency stays unreliable and the reported k is never the moment-matched single Gaussian’s optimistic value – is now pinned by a direct unit test, not only indirectly via the band threshold.
tulpa 0.0.55 (2026-06-21)
- The outer Pareto-k proposal refinement (moment matching, gcol33/tulpa#119) is allowed more passes. Proposal refinement is a separate step from the bare diagnostic and is not under the diagnostic’s cost target, so the moment-matching cap
.K_DIAG_MM_MAXis raised from3(a cost throttle added in #127) to8, a runaway-loop backstop rather than a budget. The loop self-limits well below the cap: it still stops as soon as the k-hat reaches the usable band, and now also stops once a refined pass no longer improves on the proposal it was estimated from, so the extra passes are spent only on a stubborn k that is still above the usable band and still falling. A fit that already fits, or one whose moment matching has plateaued, pays the same as before.
tulpa 0.0.54 (2026-06-20)
-
control$k_qualitynow climbs the reliability ladder (gcol33/tulpa#131). When an"ok"/"good"target is not confidently reached on the first fit, the engine escalates: it doublesdiagnose_drawseach round and, with the newcontrol$k_refine = "mixture", refines the integration grid (adaptive_grid) on the final round when more draws alone do not resolve the band, re-fitting and re-diagnosing up tocontrol$k_max_rounds(default2) times.k_quality_roundsreports how many re-fits were used, and the verdict stays honest – the request is a target, not a promise. The k_quality verdict (k_quality_requested/reached/best/reason/rounds) is now attached for BOTH the single- and multi-block paths (previously single-block only). The single fit is factored into an internal engine driven by the escalation front door, with no change to the fit itself for the defaultk_quality = "report".
tulpa 0.0.53 (2026-06-20)
- Fixed: the outer Pareto-k no longer under-reports when the hyperparameter posterior is heavier or wider than the integration grid (gcol33/tulpa#130). The moment-matching refinement could widen the single-Gaussian proposal past the grid and report a deceptively low k – a target the grid cannot cover read ~0.39 (“good”) instead of ~0.76 (“unreliable”), exactly the grid-width deficiency the diagnostic exists to flag. The dispatcher now compares the grid mixture (the faithful within-grid proposal) against the GRID-MOMENT k rather than the moment-matching-refined one, so a refinement that lowers k only by escaping the grid can no longer win. A near-collapsed grid, where the mixture is degenerate and does not improve on the grid-moment proposal, still keeps the moment-matched Gaussian, so the collapsed-grid path (gcol33/tulpa#117) is unaffected. The skipped bad-case recovery test is restored.
tulpa 0.0.52 (2026-06-20)
- New
control$k_qualityreliability front door for the joint nested-Laplace outer Pareto-k (gcol33/tulpa#129). A single statement of the reliability the fit should report:"report"(default) computes the diagnostic and reports the achieved band;"ok"/"good"name a target band (the k-hat confidently usable, resp. good) and raise the defaultdiagnose_draws(to800L/2000L, unlessdiagnose_draws/k_samplesis set) so the bootstrap CI can resolve it;"none"disables the diagnostic. The fit carries an honest verdict –k_quality_requested,k_quality_reached,k_quality_best,k_quality_reason– and never silently downgrades: if the requested band is not confidently met it reports the band actually reached and why. The adaptive draw-escalation loop and the integration-refinement rung (k_refine) are tracked in gcol33/tulpa#131. The reliability vignette covers the new front door.
tulpa 0.0.51 (2026-06-20)
- Outer Pareto-k reliability bands are now sample-size dependent (gcol33/tulpa#128). The usable upper boundary is
min(1 - 1/log10(S), 0.7)forSimportance draws (Vehtari et al. 2024; matches loo’sps_khat_threshold): about 0.565 at the small-S end (S = 200), reaching the fixed 0.7 cap only pastS~ 2154. The good cut stays at 0.5.pareto_k_band_confidentnow tests the bootstrap CI againstc(0.5, min(1 - 1/log10(S), 0.7))at the realised draw count, so a k-hat near the upper band at a modestdiagnose_drawsis correctly read as not-yet-usable rather than acceptable.control$k_conf_bandsdefaults toNULL(the size-dependent bands); pass a strictly-increasing vector (e.g.c(0.5, 0.7)) to fix the boundaries. New vignette section and the helpers.ps_khat_threshold/.ps_conf_bands. - Known issue (gcol33/tulpa#130): for a hyperparameter posterior heavier or wider than the integration grid, moment matching can widen the diagnostic proposal past the grid and under-report the outer Pareto-k (reading it usable when the grid is in fact too narrow). A fix that bounds the refinement to the grid is tracked; the bad-case recovery test is skipped meanwhile.
tulpa 0.0.50 (2026-06-19)
- Joint nested-Laplace outer Pareto-k: replaced the adaptive batched reporting (#123/#124) with a single-batch + bootstrap uncertainty (gcol33/tulpa#127). The batched/adaptive design could not honour a sane cost budget: one importance batch costs about one fit (each draw is an off-grid Laplace re-solve), so growing the batch count to resolve a borderline k drove the diagnostic to 10-30x the fit. The chosen proposal is now scored ONCE over
control$diagnose_drawsimportance draws, and the k-hat’s sampling uncertainty is estimated by bootstrapping its raw importance log-ratios (control$k_bootstrapreplicates, re-fitting the GPD tail; no new inner solves). New control / output:-
diagnose_draws(default500L; legacyk_samplesaccepted as an alias) is the precision knob – a tighter k needs MORE actual tail ratios, so raise it, NOTk_bootstrap. The bootstrap only quantifies how unstable the current estimate is; it cannot create tail information. -
pareto_k_se_boot,pareto_k_ci_low/pareto_k_ci_high(2.5% / 97.5% bootstrap quantiles),pareto_k_se_formula(the closed-form GPD-shape MLE asymptotic SE(1 + k) / sqrt(M), a cross-check), andpareto_k_band_confident(TRUE iff the bootstrap CI lies within one reliability band). -
k_tail_points(defaultNULL= the automatic PSIS ruleceil(min(0.2 N, 3 sqrt(N)))) is an EXPERT tail-threshold control, capped at the 20%-of-draws ceiling with a warning; the used / requested counts are reported inpareto_k_tail_points/pareto_k_tail_points_requested. -
k_conf_bands(defaultc(0.5, 0.7)) sets the reliability-band boundaries, with intervals(-Inf, 0.5] (0.5, 0.7] (0.7, Inf). -
diagnose_cost_ratio(anddiagnose_draws) attached at the top level: the diagnostic’s wall-clock cost relative to the fit it certifies. The per-arm k (diagnose_k = "by_arm") carries the matching per-arm fields. A borderline k is reported with its honest (wide) CI rather than chased to false precision; for a tighter estimate, raisediagnose_draws.tulpa_psis()gains atail_pointsargument and returnstail_len.
-
- New vignette
reliability-pareto-k: what the outer Pareto-k certifies (the integration, not the posterior), the reliability bands and their draw-count-dependent usable boundarymin(1 - 1/log10(S), 0.7), the bootstrap band-confidence flag, and the reliability ladder from reporting to debiasing.
tulpa 0.0.49 (2026-06-19)
- Joint nested-Laplace outer Pareto-k: adaptive batched reporting with a proper Monte Carlo standard error and a band-resolution flag (gcol33/tulpa#124). The true outer k-hat is a single fixed number for a given fit and proposal, so all batch-to-batch variation is estimator sampling error – min/max conflates a good/bad seed with too few iterations and WIDENS with the batch count
B, so it cannot signal convergence. The batched diagnostic (control$k_batches > 1) now reports, alongside the medianpareto_k:-
pareto_k_mcse=sd(batch k-hats) / sqrt(B), the Monte Carlo standard error of the estimate (shrinks as1/sqrt(B)); -
pareto_k_band_confident=TRUEiffpareto_k +/- 2 * pareto_k_mcselies within one reliability band (does not cross 0.5 or 0.7); -
pareto_k_n_batchesand the secondary observedpareto_k_lo/pareto_k_hirange (kept as QA fields). The per-arm k (diagnose_k = "by_arm") carries the same fields (pareto_k_by_arm_mcse,pareto_k_by_arm_band_confident), over the same number of batches the joint loop settled on.
-
- New opt-in adaptive mode
control$k_adapt = TRUE: starting atk_batchesbatches (defaulting to4Lrather than the off sentinel), the diagnostic adds batches untilpareto_k_band_confidentbecomesTRUEor thecontrol$k_batches_maxcap (default20L) is reached. A fit on the wrong side of a band boundary keeps sampling until the good/ok/unreliable verdict resolves; a fit whose true k sits ON a boundary (whose interval always straddles) stops at the cap with the honestband_confident = FALSE, so the cap is both a cost bound and the correct classification for an on-the-line fit. The seed pool is drawn for the cap, so an adaptive run is a reproducible prefix of the full-cap run.k_batchesmust be>= 2whenk_adapt = TRUE(an MCSE needs at least two batches). The1/sqrt(B)rule reduces the seed-to-seed variance, not the GPD k-hat’s small-sample bias (controlled byk_samples, kept>= 200).
tulpa 0.0.48 (2026-06-19)
- Joint nested-Laplace outer Pareto-k: opt-in batched reporting (gcol33/tulpa#123). The outer k-hat is a noisy estimator – a generalized Pareto shape fit to the upper tail of one batch of importance weights, with real Monte Carlo error that shrinks only slowly with the draw count – so a single reported value is seed-dependent and can mislead near a reliability-band boundary (
< 0.5good,0.5-0.7ok,>= 0.7unreliable). The newcontrol$k_batches(default1L= OFF, byte-identical to the prior single-value behaviour and cost) evaluates the CHOSEN proposal’s k over that many independent importance batches and reportspareto_kas the MEDIAN plus the observedpareto_k_lo/pareto_k_hirange andpareto_k_n_batches; the reliability band is classified off the median. The opt-in per-arm k (gcol33/tulpa#120) is batched the same way (pareto_k_by_arm_lo/pareto_k_by_arm_hi). The proposal SELECTION (single Gaussian vs grid-mixture, gcol33/tulpa#121) is made once on the canonical pass so every batch scores the SAME proposal (no per-batch source flip); per-batch seeds are drawn up front from the restored RNG state, so the diagnostic stays reproducible and the fit’s draws are bit-for-bit unchanged. The spread is the Monte Carlo uncertainty of the PSIS k-hat across independent importance samples – NOT a posterior credible interval and NOT a coverage-calibrated CI; the per-cell estimates and coefficients do not move. Cost isk_batchestimes the scoring, gated behind the already opt-in / slow diagnostic; a handful of batches (5-10) gives an honest min/max range.
tulpa 0.0.47 (2026-06-19)
New built-in
truncated_gaussianfamily (gcol33/tulpa#122): an upper-truncated Gaussian latent, the bounded-support sibling of thelognormalarm. On the natural scale it is an upper-truncated lognormal – a positive response known to lie below a ceiling, modelled as a Gaussian on the log-response conditioned onlog y <= ufor a per-row boundu(+Inf=> no truncation). The likelihood is the Gaussian density divided by the retained massPhi((u - eta)/sigma), the continuous-density counterpart of theinterval_gaussian(ordinal) family. It is log-concave inetaso the inner Newton needs no Fisher fallback, and reduces exactly to thegaussianarm asu -> +Inf. Wired through the joint nested-Laplace path via a per-armtrunc_upperceiling (src/laplace_family_link.h,src/laplace_builtin_family_spec.h,R/nested_laplace_joint_helpers.R). FD gradient/Hessian, deep-truncation stability, the lognormal reduction, and truncated-normal moment identities are checked intest-truncated-gaussian.R.New baseline-category multinomial-logit kernel (
src/multinomial_logit.h, gcol33/tulpaObs#106): a nominal (unordered) K-class likelihood with K-1 coupled linear predictors sharing the softmax denominator. The per-observation negative Hessian is the full (K-1)x(K-1) multinomial information (diag(p) - p p'), positive semidefinite for anyeta, so the inner Newton needs no Fisher fallback; the softmax is formed overflow-safe. This is the engine primitive backing tulpaObs’soccu_categorical()positive arm. FD gradient/Hessian, the PSD data-free information identity, and overflow safety are checked intest-multinomial-logit.R.
tulpa 0.0.46 (2026-06-18)
- Joint nested-Laplace outer Pareto-k: grid-mixture (basin) importance proposal (gcol33/tulpa#121). The engine represents the hyperparameter posterior as the weighted integration grid and draws hyperparameters from it (a grid cell proportional to its weight, then that cell’s latent Laplace), never from a single continuous Gaussian. The diagnostic, however, scored a single grid-moment Gaussian fit to the grid’s mean and covariance. On a skewed or multi-node hyperparameter posterior, which the grid covers through its nodes, that symmetric Gaussian underweights the off-mode mass: importance draws landing there carry runaway weights and the k-hat reads unreliable even though the grid representation, and the per-cell estimates drawn from it, are fine. The spread-tensor
grid_momentpath now also scores the proposal the engine actually samples, a defensive mixture of local Gaussian bumps at the grid cells mixed by the grid weights (proposal_source = "grid_mixture"); each bump’s per-axis SD isgetOption("tulpa.kdiag.mix_bw", 0.5)times the largest grid gap on that axis. Because that mixture is confined to the grid, it is adopted only when the grid actually covers the posterior (the grid-moment Gaussian’s importance weight stays inside the mixture’s coverage hull) and it lowers the k-hat: a target tail beyond the grid keeps the single Gaussian’s higher k so the grid-width deficiency is still flagged, a near-collapsed grid keeps the moment-matched Gaussian, and no fit that already read usable can regress. A true delta collapse still uses the finite-difference mode-Hessian and a supplied CCD mode-Hessian proposal still uses the single Gaussian, both unchanged. The fit, its per-cell estimates and coefficients are untouched; only the reliability diagnostic changes. On a real EVA-scale occu_cover fit whose occupancy field-SD posterior is right-skewed the reported k-hat goes from a seed lottery (0.10 to 0.81, peaks flagged unreliable) to a stable usable band (max ~0.47 across seeds, median ~0.16) with a roughly two-fold higher importance-sampling effective size.
tulpa 0.0.45 (2026-06-18)
tulpa_criteria()gains agroupargument that sets the LOO unit explicitly (gcol33/tulpa#118). A column oflog_likis the leave-one-out fold; withgroup = NULL(the default) the result is byte-identical to before (leave-one-row-out, e.g. per plot / per visit). When supplied, the per-draw pointwise log-likelihoods are summed within group to a[n_draws x n_groups]matrix before PSIS, so each fold is a whole group (leave-one-group-out cross-validation, LOGO-CV) – e.g. leave out a whole cell rather than one of its rows. The grouping streams over the (possibly EVA-scale) input once and never materialises it;lppd,p_waic,elpd_loo,cpoandpareto_kare all computed on the grouped matrix and the standard-error multipliers follow the fold count. DIC is a plug-in deviance over all observations and is unaffected. The result reportsn_groupsand prints the fold count, and the pointwise data frame is keyed bygroup.Joint nested-Laplace outer Pareto-k: opt-in per-arm reporting (gcol33/tulpa#120).
control$diagnose_k = "by_arm"computes, in addition to the single joint k over the whole hyperparameter posterior, a k-hat restricted to each arm’s hyperparameter axes (the other arms held at their posterior mean), so a tail-heavy joint k can be localised to one arm rather than reported as one pooled number. Each axis is attributed to the arm(s) whose linear predictor it enters – a latent block’s axes to the arms the block loads on, a copy coefficientalphato the recipient arm, aphi_<arm>dispersion axis to that arm – reusing the same proposal-build + moment-matching + PSIS path as the joint k over the arm’s axis subspace. Reported inpareto_k_by_arm(named by arm) withpareto_k_by_arm_is_ess/pareto_k_by_arm_scope, surfaced indiagnostic_summary(). OFF by default: the joint k stays the default behaviour and cost, and is bit-for-bit unchanged by the opt-in (it is scored first, and the diagnostic is RNG-restored). Defined for the multi-block layout with two or more arms; the single-block shared-field layout declines rather than mis-attribute its axes.
tulpa 0.0.44 (2026-06-18)
New built-in
interval_gaussianfamily: an interval-censored Gaussian latent (an ordered probit with KNOWN thresholds). The latent value isNormal(eta, sigma^2)and the observation records only that it fell in the half-open interval(lower, upper]on the linear-predictor scale, with-Inf/+Infthe open outer classes. The log-density is the class probability MASS,log(Phi((upper - eta)/sigma) - Phi((lower - eta)/sigma))– a genuine PMF over classes with no change-of-variable Jacobian, so the score is comparable across arms. The mass is differenced in the accurate tail to avoid catastrophic cancellation, andP(eta)is log-concave so the analytic-d2 logP/d eta2 >= 0needs no Fisher fallback. The family is read throughtulpa_nested_laplace_joint()(family = "interval_gaussian", with per-armlower/upperin place of the point responsey); it backs tulpaObs’scover(positive = "ordinal")Braun-Blanquet cover arm. The kernel is FD-gradient tested via the internalcpp_interval_gaussian_terms()(test-interval-gaussian.R).Joint nested-Laplace outer Pareto-k: the importance-sampling proposal now carries an optional moment-matching refinement (gcol33/tulpa#119, after Paananen, Piironen, Burkner & Vehtari 2021). When the integration grid is sharply concentrated the node-covariance proposal can mis-scale – too wide scatters draws to extreme hyperparameters where the inner Laplace log-marginal inflates, too narrow leaves the target tail uncovered – so the k-hat reads unreliable even on a fine fit. The proposal is now re-estimated from the PSIS-smoothed importance-weighted moments of its own draws and re-scored, up to a few passes, keeping the lowest-k-hat proposal and stopping early once the k-hat reaches the usable band (
<= 0.7); the smoothed weights bound any single draw’s influence so a sharp posterior is matched in a couple of passes (proposal_source = "moment_matched"). The fit’s RNG is restored so the posterior draws are bit-for-bit unchanged. The collapsed-grid FD mode-Hessian rescue (gcol33/tulpa#116, #117) is now reserved for a TRUE delta collapse (no grid-weighted spread on ANY axis); a partial collapse where some axis still carries weighted spread keeps the grid-moment proposal and lets moment matching refine it, rather than over-widening the proposal with the local mode curvature of a non-Gaussian outer marginal.
tulpa 0.0.43 (2026-06-18)
Joint nested-Laplace outer Pareto-k: the collapsed-grid mode-Hessian rescue (gcol33/tulpa#116) now engages when a hyperparameter axis is pinned (gcol33/tulpa#117). When the integration grid concentrates (
ess_grid <= d) the diagnostic reconstructs a Laplace-at-mode Gaussian proposal from a finite-difference Hessian of the outer target. Previously it differenced over alldaxes, so a pinned axis (zero weighted variance: acopy()alphafixed at 0, or a one-point dispersion grid) made the FD Hessian singular, the conditioning guard rejected it, and the k fell back to thegrid_momentproposal #116 was meant to supersede – which can be spuriously high and label an otherwise-fine fit “unreliable”..joint_pareto_mode_cov()now restricts the stencil to the varying axes (the samevar_tolset the proposal build uses, factored into the shared.joint_pareto_vary_axes()) and embeds the inverse curvature block-diagonally, pinning the zero-variance axes atu_hat. Excluding a pinned axis from the curvature is exact, not an approximation. Affected any jointoccu_coverfit with an uncoupled (no-copy()) cover arm plus a concentrated hyperparameter posterior.Per-cell warm start for the outer Pareto-k re-solves (gcol33/tulpa#118 follow-up). Each importance draw’s inner solve now starts from the converged latent mode of its NEAREST integration cell (
.joint_nearest_grid_mode, threaded throughcpp_nested_laplace_joint_multi(x_init_per_cell=)and the grid driver) instead of the single broadcast modal mode. Unlike the 0.0.42 near-neighbour re-order – which only helps the serial chain – this also warms the PARALLEL pilot-mode path, so a threaded diagnostic (n.threads.outer > 1) gets it too: on a real EVA occu_cover fit (402 cells, beta) the per-cell warm cuts the parallel diagnostic a further ~1.5x on top of threading. The k-hat is byte-stable (each draw converges to the same mode regardless of start; validated == the broadcast-mode path andloo::psis). Knobtulpa.kdiag.percell(default TRUE; falls back to the re-order, then the broadcast mode, when grid modes are unavailable).
tulpa 0.0.42 (2026-06-18)
- Faster joint nested-Laplace outer Pareto-k diagnostic (gcol33/tulpa#118). Profiling the joint occu_cover diagnostic showed the dominant cost is the per-Newton-iteration Hessian/gradient scatter (the beta cover arm’s per-observation digamma/trigamma curvature fill, 73-83%), not the sparse Cholesky factorize (8-12%). Two changes attack that without moving the k-hat:
-
Shamanskii (chord) factor reuse on the re-solves (
.K_DIAG_REFRESH): the diagnostic’s inner re-solves run withinner_refresh = 4, so off-factor steps re-apply the cached factor to a refreshed gradient and scattergrad_only(skipping the curvature fill). The final mode-pass always re-factorizes with the true Hessian, so the converged log-marginal – and thus the k-hat – is unchanged; only the path to the mode uses a stale curvature, which the diagnostic (no per-draw SEs) does not need. -
Near-neighbour chain ordering of the importance batch (
.joint_is_chain_order/.joint_is_solve_reordered): the serial outer-grid driver warm-starts each cell from the previous cell’s converged mode, so the random-order proposal draws were each starting from a random-neighbour mode (8-16 inner-Newton steps/draw). The batch is re-ordered into a standardised near-neighbour chain seeded at the modal cell (.joint_modal_theta), so each draw corrects only the small drift from its neighbour; the result is un-permuted before the PSIS layer, which is unaffected. The per-cell parallel path warm-starts from the pilot mode, so the order is then immaterial. -
Loosened inner-Newton tol on the re-solves (
.K_DIAG_TOL = 1e-4): a large share of the per-draw steps was intrinsic convergence to the fit’s own tol (~1e-6), which the diagnostic does not need – the Laplace log-marginal error from stopping at gradient normtis O(t^2), immaterial to the tail-shape k-hat. Never tighter than the fit’s tol. Combined, the three cut the diagnostic 3-4x on the beta arm with the k-hat byte-stable (validated vsloo::psis/posterior::pareto_khaton real EVA occu_cover importance ratios: identical to 4 decimals). Each is overridable viatulpa.kdiag.refresh/tulpa.kdiag.tol/tulpa.kdiag.reorderfor the byte-for-byte exact diagnostic, andtulpa.kdiag.captureexposes the importance log-ratios for an external cross-check.
-
Shamanskii (chord) factor reuse on the re-solves (
tulpa 0.0.41 (2026-06-18)
- Mode-Hessian outer Pareto-k proposal for the joint nested-Laplace backend (gcol33/tulpa#116). The outer
pareto_kdiagnostic built its importance proposal from the grid-weighted covariance of the integration nodes; when the hyperparameter posterior is sharp the grid concentrates on ~1 cell, the grid-weighted covariance is then driven by negligible-weight far cells, and the too-narrow proposal yields a spurious high k-hat even though the fit is fine. The proposal is now built from a mode Hessian, on both integration paths.-
CCD path. The integrator already places its design from the analytic curvature at the outer mode; that Gaussian (
u_hat,L_scale) is captured and spliced into the Pareto-k driver over the axes it spans, block-diagonal with the grid-weighted spread on the independently tensor-crossed phi axes. -
Tensor path. When the grid collapses (effective grid ESS
<= d) and no CCD proposal exists, the Laplace-at-mode covariance is reconstructed from a finite-difference Hessian of the outer target at the modal cell (reusing the CCD stencil / conditioning helpers). Degenerate or ridged curvature falls back gracefully to the grid estimate. - New return field
pareto_k_proposal_sourcein{"mode_hessian", "grid_moment", NA}(documented ontulpa_nested_laplace_joint) flags which regime a fit is in. - Known boundary: on a genuinely ridged outer posterior (a weakly-identified copy
alpha) there is no PD Hessian along the ridge, so the FD Hessian declines and the path reports agrid_momentk. A ridge gives a wide proposal and hence a low k, so the spurious-high-k failure does not arise there; a principled diagnostic for non-Gaussian hyperparameter posteriors is left as future work. - A matched proposal is a byte-exact no-op on well-resolved grids, so the diagnostic is unchanged where it already worked. Decisive before/after on a well-conditioned tensor collapse (quad-ESS = 1.00):
grid_momentk = 0.895 (unreliable) ->mode_hessiank = 0.543 (reliable).
-
CCD path. The integrator already places its design from the analytic curvature at the outer mode; that Gaussian (
tulpa 0.0.40 (2026-06-17)
- Outer-grid progress reporter (
tulpa_progress::GridProgress, the nested-Laplace grid and parallel NUTS sampler): two fixes for long detached / redirected runs (gcol33/tulpa#115).-
ETA from realised throughput, not the serial pilot. The ETA used to project the central, warm, serial pilot cell’s per-cell rate across the whole grid. The parallel cells are extreme-hyperparameter, take more inner Newton steps, and run under memory-bandwidth contention, so each costs well more than the pilot – the projection ran badly optimistic (observed ~10x low). It now rests on the mean wall time of completed POST-pilot cells (the throughput the remaining cells actually run at, the outer width already folded in). While only the pilot has been timed the projection is shown as a lower bound (
ETA >=) rather than a point estimate (ETA ~), and the printeds/cellstracks the running average. At width 1 (serial) this reduces to the plain extrapolation. -
Live console in a detached parallel run. The console line used to freeze at the serial pilot (
1/N) because every parallel tick suppressed the console (worker threads must not touch the R print API), leaving only the heartbeat file advancing. The master thread (thread 0, the R main thread) now emits the newline-terminated line from inside the parallel region on its own throttle clock, so a redirected log shows the grid advancing cell by cell. Worker threads still only update the counter and heartbeat file; the heartbeat-file wire format (<done> <total> <elapsed_s> <eta_s>) is unchanged, so existing readers keep parsing.
-
ETA from realised throughput, not the serial pilot. The ETA used to project the central, warm, serial pilot cell’s per-cell rate across the whole grid. The parallel cells are extreme-hyperparameter, take more inner Newton steps, and run under memory-bandwidth contention, so each costs well more than the pilot – the projection ran badly optimistic (observed ~10x low). It now rests on the mean wall time of completed POST-pilot cells (the throughput the remaining cells actually run at, the outer width already folded in). While only the pilot has been timed the projection is shown as a lower bound (
- Joint nested-Laplace multi-block driver: two random-effect capabilities for random slopes on an observation arm (gcol33/tulpa#114), so a downstream consumer can fit correlated and uncorrelated slopes, not just intercepts.
- An optional per-row design weight (
svc_weight) on theiidblock, mirroring the areal / temporal SVC path: the field’s contribution to armkrowiis row-scaled bysvc_weight[[k]][i](eta_i += svc_weight[[k]][i] * sigma * u[obs_idx_i]). An uncorrelated slope(0 + x | g)/(x || g)is then one weightediidblock per coefficient, each with its ownsigmaaxis. Unsetsvc_weightis byte-identical to the plain random-interceptiidblock. - A new multivariate-IID block,
type = "miid": the non-spatial sibling ofmcarwithQ = I. Per groupga coefficient vectorb_g ~ N(0, Sigma)(block dimn_fields = 1 + n_slopes), with the free cross-coefficientSigmaintegrated over the samep(p+1)/2log-Cholesky outer-grid axes asmcar. The precisionSigma^-1 (x) Iis full rank, so (unlikemcar) there is no sum-to-zero pinning and the normalizer carries the fulln(n log|Sigma^-1|). This expresses a correlated random slope(1 + x | g). Copy (alpha) semantics compose as formcar. Amiidblock withn_fields = 1is the centered counterpart of the scalariidblock (Laplace-invariant). Direct-algebra prior assembly (Sigma^-1 (x) I, gradient, log-prior) and parameter recovery are tested (test-miid-prior.R,test-miid-recovery.R). The shared MCAR/MIID obs scatter and copyarm_scaleare single-sourced inmcar_block_factory.h.
- An optional per-row design weight (
-
control$k_threads: outer-thread width for the joint nested-Laplace fit’s Pareto-k diagnostic importance batch. Thek_samplesre-solves run after the grid (every core free), each solved single-threaded once the batch saturates the pool, so widening the pool is a bit-identical wall-clock speedup with an unchanged k-hat.NULL(default) follows the fit’s own thread grant,"auto"uses the physical performance-core count (capped at 2 under R CMD check), and an integer pins the width.
tulpa 0.0.38 (2026-06-17)
laplace_diagnostics(): a front-door diagnostic for deterministic (i.i.d.-draw) nested-Laplace fits, the classmcmc_diagnostics()declines to treat as MCMC. It returns a per-parameter table (posterior mean / sd, plus the i.i.d.-draw bulk / tail effective sample size and split-Rhat of the draws, labelled as Monte-Carlo diagnostics rather than chain mixing) and attaches the reliability headline as attributes and asummaryrow: the PSIS Pareto-k-hat of the outer hyperparameter integration scored against the exact inner-Laplace marginal (Vehtari et al. 2024; Yao et al. 2018), and the grid quadrature effective sample sizeess_grid = 1 / sum(w_k^2).mcmc_diagnostics()now dispatches an i.i.d.-draw fit tolaplace_diagnostics().Pareto-k diagnostic on the joint engine: the importance-sampling proposal is now built on the grid axes that actually vary, so an outer grid that pins an axis (for example
alpha.grid = 0) no longer yields a singularchol(Su)that silently skips the diagnostic; the k-hat is computed on the remaining axes.Speed: the per-sample marginal re-solves of the Pareto-k diagnostic refit now honour
n_threads_outer(previously hardcoded to one thread), giving a 5.8-7.7x speedup ondiagnose.k = TRUEfits with k-hat unchanged.
tulpa 0.0.37 (2026-06-16)
- build: the
tulpaMeshdependency floor is raised totulpaMesh (>= 0.1.3), locking it to the current tulpaMesh release. TheRemotesinstall reference was alreadygcol33/tulpaMesh@v0.1.3.
tulpa 0.0.36 (2026-06-16)
- Maintenance release. No user-facing changes; the version is bumped to keep the tulpa and tulpaObs release tags in step.
tulpa 0.0.35 (2026-06-16)
- test(tiers): the test suite is now organized into three explicit cost tiers, single-sourced in
tests/testthat/helper-tiers.R: tier 1 structural (ungated, runs on CRAN), tier 2 recovery (skip_on_cran()), tier 3 full samplers and coverage (skip_if_not_slow()). About 100 test files were re-gated and a tier table added intests/testthat/README.md. - test(tiers):
TULPA_FAST=1is a fast smoke profile folded into both tier gates, collapsing the suite to the tier-1 structural tests only (heavy fits and samplers reported as skips, never dropped) for sub-minute plumbing iteration. The default and CRAN runs are unchanged. - build: bump
LinkingTo: gcol33/tulpaMeshtov0.1.3.
tulpa 0.0.34 (2026-06-12)
- feat(nested-laplace-joint):
cpp_joint_inner_vcov_blocksnow defaultsfield_marginal = TRUE(andn_threads = 1). The cheap selected-inversion recipe – the betas block and betas x field cross solved exactly, the field marginal variances from one Takahashi pass – is the default per-cell extraction; the fullp x pblock is opt-in viafield_marginal = FALSE. - perf(nested-laplace-joint): the cell-coupling per-cell scatter (
scatter_cell_coupling_branch_impl,src/nested_laplace_joint_multi.h) gained an optional per-arm rank-1 self-cross descriptor onCellDerivs(arm_cross_rank1_coef/arm_cross_rank1_vec,inst/include/tulpa/cell_coupling.h). When a coupled arm’s (k, k) off-diagonal cross-Hessian is the symmetric rank-1a v v^T– every cross-row second derivative factoring through one scalar, as in tulpaObs’s all-undetected occupancy mixture – the kernel collapses it to a singlea u u^Tin joint-dof space (u = sum_r v[r] chain(row_r), accumulated byaccumulate_self_rank1_uand scattered byscatter_self_rank1_{dense,sparse}) instead of the O(rc^2) densearm_cross_hess[k][k]loop, dropping the scatter from O(sum rc^2) to O(sum rc) (gcol33/tulpaObs#94). Single-response path (n_batch_ == 1) only; cross-arm blocks and the densearm_cross_hesspath are unchanged, and the spec folds the rank-1’s own diagonal intoarm_neg_hess_diagso the assembled Hessian matches the dense path to machine precision.
tulpa 0.0.33 (2026-06-12)
- perf(nested-laplace-joint): the joint post-grid inner-covariance extraction is now a single parallel C++ primitive,
cpp_joint_inner_vcov_blocks(src/joint_inner_vcov.{h,cpp}), replacing the serial-R per-cellsolve(Qk, E)over ~n_betas + n_fieldright-hand sides (gcol33/tulpa#112, #113; gcol33/tulpaObs#93). For the field-marginal summary it solves only then_densefixed-effect columns ofQk^-1(the betas block and the betas x field cross, exact) and recovers the field marginal variances from one Takahashi selected-inversion pass (selected_inversion_diagonal,sparse_cholesky.h); the field x field off-diagonal – read by neither the SD summary nor theQ_k-direct predict path – is not formed. Cells run concurrently over the supplied thread budget. Afield_marginal = FALSEmode forms the full block (the betas-only callers). Numerically identical to the former dense path on the read sub-blocks (test-joint-inner-vcov.R).
tulpa 0.0.32 (2026-06-12)
- refactor(linalg): the small-dense lower-Cholesky factorization, triangular solves, log-determinant, and NNGP conditional (kriging) moments are now a single
linalg_fast.hcore (chol_factor_lower/chol_forward_solve/chol_back_solve/chol_log_det/nngp_conditional_moments), replacing 8 hand-rolled copies across the Laplace, PG-Gibbs, SVC, spatiotemporal, temporal-GP, GPU-fallback, and proper-CAR paths (#109). The default1e-10pivot jitter is namedkCholJitter; the SVC kernel’s1e-6is now an explicit argument rather than a drifted literal. Behavior-preserving. - refactor(temporal): the RW1/RW2 quadratic and cross forms and the AR1 log-density are single templated kernels shared by the double, autodiff, and generic-sampler paths (TVC and multiscale-temporal included), replacing three drifted implementations (#110). Where the copies disagreed the merged kernel keeps the live generic-sampler behavior (cyclic honored, AR1 stationary guard
1e-10); the dead*_autodiff.htwins are removed. Numerics shift at most at the1e-10/ ULP level (AR1 stationary term, summation order). - refactor(joint):
.normalise_joint_arm()and.normalise_joint_arm_multi()delegate to a shared.normalise_joint_arm_core(), collapsing ~50 duplicated lines of arm-spec validation (#111). The single-block path still requires an arm-levelspatial_idx; the multi-block path still fills a zero placeholder – the only behavioral split, now an explicit policy argument.
tulpa 0.0.31 (2026-06-12)
- fix(laplace):
tulpa_laplace(..., weights=)now scales the log-likelihood by the per-observation weight, matching the already-weighted score and Fisher Hessian (#108). The Newton globalization backtracks on the log-likelihood, so a weighted-optimal step was judged against an unweighted objective, halved toward zero, and stalled – any non-uniform weighting returned a non-converged mode shrunk toward the prior. The fix is a no-op when weights are absent or uniform (every unweighted path is byte-identical); weighted gaussian / binomial fits now matchlm()/glm(weights=)to ~1e-5.test-laplace-weights.Rlocks it in. - perf(nested-laplace-joint): when the outer grid has fewer cells than the outer thread pool, the surplus threads are now handed to the inner per-observation solve via nested OpenMP instead of idling (#107). A shared
joint_inner_thread_budget()splits the pool soouter_used * inner <= n_outer(never oversubscribed) and is a no-op when the grid saturates the pool. Verified result-invariant (theta means byte-identical) with a ~1.12x single-fit speedup on a 4-cell surplus grid;test-nested-laplace-joint-threading.Rlocks in the invariance.
tulpa 0.0.30 (2026-06-10)
refactor(spde): the fractional rSPDE Laplace marginal moves from R to C++ (
cpp_spde_fractional_logmarginal, Eigen). The well-conditionedB/ matrix-determinant-lemma method – built through the operator factorPl(cond = sqrt cond(Q)), never an explicit ill-conditionedQinverse – is preserved: a direct precision-space marginal drifts by O(10) nats at large range where cond(Q) ~ 1e10+, mis-identifying range..spde_nested_logmarginal_atand the single-point.spde_laplace_fractional_atdelegate to it; the R det-lemma / closed-form / family-weight code is removed. Reproduces the former R marginal to ~1e-10. The gaussian fractionalphiis now the residual SD (variancephi^2), consistent with the integer path (was the variance).fix(spde): the nested-Laplace
(range, sigma)marginal now carries the GMRF prior normalizer0.5 log|Q(theta)|(#98). It was dropped on the integer-alpha SPDE path – the Occam term that bends the marginal down at largesigma– sofit_spde()(and thetulpa(..., spatial = spatial_spde())front door) railedsigmato the prior boundary and collapsedrange, while the Tier-1 NUTS-joint path recovered. The two integrators now agree: on a true 0.35/0.80 field the CCD weighted means recoverrange ~ 0.38,sigma ~ 0.76(matching NUTS), validated by a new multi-seed recovery + CI-coverage gate intest-spde-ccd.R. The normalizer is single-sourced in C++ (src/spde_logdet.h, a CHOLMOD log|Q|); the fractional path’s R-side fold is removed accordingly. Exposed and fixed a latent gap where the SPDE CCD refit returned aNULLbeta(the mode was never split into fixed effects / field), now done once inlaplace_spde_at().fix(diagnostics): the outer Pareto-k radius cap no longer biases k-hat downward in the heavy-tail regime it exists to flag (#100). When the importance log-ratio is still rising at the cap boundary (the target is heavier-tailed than the Gaussian proposal) the dropped far-radius draws are folded back in, so the GPD fits the genuine uncapped tail; a flat/light tail leaves the cost cap in force. The re-cov path’s no-cap choice is documented as consistent in correctness.
fix(s3): every exported front-door fitter (
tulpa_re_cov_nested/_gibbs,tulpa_laplace,fit_spde,tulpa_nested_laplace[_joint],tulpa_tgmrf) routes its return through a shared.finalize_fit()so a directly-called fit carries the sametulpa_fitclass, fixed-effect layout, and explicitdraws_kindprovenance tag as atulpa()-dispatched one. The chain-vs-iid diagnostic gate no longer computes a vacuous Rhat on a directly-called iid fit (#102).fix(validate):
tulpa()now validatesphi > 0for every dispersion-carrying family (not just beta) and rejects non-integer / negativeyfor count families at the front door; theinverse/1mu2links clampetaoff their singularities (#104).refactor: removed the single-block
copy=back-compat shim fromtulpa_nested_laplace_joint()(declare the copy coefficient on the arm viafield_coef, #105); consolidated the comparison / averaging verbs to native PSIS-backedcompare_models()/modelAverage()(stacking + pseudo-BMA, noloodependency –loomoved to Suggests), and the fourtulpa_tgmrf_*fitters into onetulpa_tgmrf(mode=); standardized RE-Gibbs / PG-Gibbs iteration arguments onn_iter+warmup(#103).test: parameter-recovery / CI-coverage gates on the deterministic Tier-2 hot paths – nested-Laplace spatial hyperparameters (ICAR/BYM2/CAR_proper/NNGP/HSGP, #97), the Pareto-k diagnostic’s discriminating power on real engine output (#99), and the assembled generic-NUTS and VI estimators (#101). The SPDE cross-integrator test (#98) makes explicit that the deterministic
fit_spdeCCD path does NOT recover(range, sigma)(the Laplace-marginalized SPDE likelihood is prior-dominated in both); only the Tier-1 NUTS-joint path does.docs: runnable
\examplesand method@referenceson the front doors,inst/CITATION, internal issue tokens stripped from rendered help, and stale version / example strings refreshed (#106).feat(frontdoor):
mode = "agq"now reaches the adaptive Gauss-Hermite quadrature fitter throughtulpa(). A single random-intercept(1 | g)model with abinomial/poisson/gaussianfamily routes toagq_fit(), withcontrol$n_quad(default 7;1recovers Laplace) selecting the quadrature order andphimapped to the gaussian residual sd. The front-door fit equals a directagq_fit()call. Previously the backend was registered but the design dispatch fell through to a “reachable but not yet wired” error. Random slopes, multiple RE terms, other families, and abeta_prior(AGQ is a marginal-likelihood fit) are rejected with guidance.fix(methods): the generic fixed-effect accessors (
coef,summary,confint,vcov) now read a Gaussian fit’s full-parameter$covwhen it carries no draws, grid moments, or$H_beta(the AGQ shape), and.fixed_draws_mat()treats a zero-row$drawsmatrix as “no draws” rather than empty draws. Fits with real draws / grid moments /H_betaare unaffected.feat(spatial): the single-arm multi-block nested-Laplace driver now honours a per-observation design weight on an areal (icar) block, exposed as an optional
svc_weightfield in the block spec. When present, observation i’s eta contribution issvc_weight[i] * z[spatial_idx[i]]rather thanz[spatial_idx[i]]– a spatially-varying coefficient (the arealf(cell, weight, ...)), the single-arm analogue of therow_weightthe joint multi-arm driver already carries. The weight enters at one layer (the block-local weight resolved alongside the node index); the gradient inherits it and the block Hessian its square through the chain rule, incompute_eta_spec/scatter_specand thefitted_eta/ predictive-variance reconstruction. A block withoutsvc_weightis byte-identical to before. This lets a standalone occupancy fit carry a cell-indexed varying-coefficient field (consumed by tulpaObsoccu()’s spatial bar).fix(joint): the coupled cell-coupling per-cell scatter now handles
INDEXED_MULTIprior blocks (a separable-MCAR block’s several latent dofs per row), not onlyINDEXED_SINGLE. Previouslyscatter_one_arm_row_{dense,sparse}andbuild_arm_row_chainresolved a row’s active latent dofs throughblock.idxalone, so a multi-field block (e.g. a free-Sigma MCAR field) coupled onto acoupled = TRUEarm received no gradient/Hessian from the cell-coupling likelihood and stayed pinned at its prior mean. The active-latent resolution is now a singlecollect_coupled_row_latents()helper shared by all three, so the free-Sigma MCAR field couples through the joint occupancy mixture (consumed by tulpaObsoccu_cover()’s correlated|spatial bar).INDEXED_SINGLEcoupled fits (the areal / SVC trend path) are byte-identical.feat(spatial):
spatial()gains aby =argument for replicated CAR – one independent copy of the whole varying-coefficient field per level of a factor, with the hyperparameters shared across levels (INLA’sreplicate =/mgcv’ss(cell, by = ...), generalised to the bar). It is orthogonal to the bar character:|/||sets the covariance among the coefficient columns within a field, whilebysets how many replicates exist. Abyfactor withLlevels builds the field over the block-diagonal Kronecker graphI_L (x) Q(Ldisjoint copies, the node index offset into each level’s copy), so the replicates are independent and share one precision – the outer integration grid stays one axis. Supported for||and|(intrinsic) and||+proper = TRUE;|+properstays out of scope, with or withoutby. The newtulpa_bar_field_replicate()exposes the Kronecker remap for consumer packages (the graph-side sibling oftulpa_bar_field_specs()).fix(spatial): the intrinsic-CAR kernels (ICAR and the separable MCAR) are now connected-component aware. The rank-deficiency treatment – the sum-to-zero null-space pin and the
(n - 1)log-determinant normaliser – assumed a single connected graph; over a disconnected graph (theL-component block-diagonal field a replicated CAR builds) the field constant of each component is its own null direction. The kernels now apply one sum-to-zero pin per component and normalise with(n - n_components), so a block-diagonalL-component log-prior equals the sum of theLindependent single-component log-priors. A connected graph is then_components = 1case, byte-identical to before.
tulpa 0.0.28 (2026-06-09)
- feat(spatial): the separable-MCAR areal block can now be COPIED across arms in the joint multi-block driver. A correlated
(intercept, slope)field sharing a free cross-covarianceSigma (x) Q^-1(the within-arm covariance among the fields) is copied onto a second linear predictor with one estimated amplitudealpha(the cross-arm transfer): the donor arm sees the natural-parameter field at amplitude 1, the copy arm atalpha, withalphaintegrated over the outer grid as a trailing axis alongside theSigmalog-Cholesky coordinates.copy = list(arm =, block =, alpha_grid =)now accepts atype = "mcar"block; the copy amplitude rides on the block’sarm_scale(the natural- parameter field stays the latent, so a single per-arm scalar carries the whole correlated field). Previously MCAR rejected copy semantics. This is the engine half of the cover-hurdle correlated-field consumer (gcol33/tulpaObs#64). The single-arm MCAR path and every existing copy block are byte-identical (no copy = emptyarm_scale). RecoversSigma(both SDs + the cross- correlation) and the copyalphaagainst simulated truth (test-nested-laplace-joint-multi-copy.R).
tulpa 0.0.27 (2026-06-09)
- feat(formula): the varying-coefficient bar column-expansion is now public.
tulpa_bar_field_specs(~ 1 + w || node, data)expands an lme4-style bar into one spec per design-matrix column –(column_name, weight, is_intercept), where the intercept column is the unweighted (all-ones) field and each covariate column carries its per-observation design value as the field weight.tulpa_is_spatial_bar()recognizes such a bar. Both surface the single expansionspatial()and the inlinetemporal()field constructor already use internally (the one bar column-expansion helper the two paths share), so a downstream package can offer a one-term spatial / temporal bar without re-parsing the~ 1 + w || nodegrammar.spatial()and the temporal field constructor are refactored onto the shared bar recognizer so the engine and any consumer cannot drift (gcol33/tulpa#93).
tulpa 0.0.26 (2026-06-08)
- fix(spatial):
fit_spde(method = "ccd")now actually runs the central- composite design.fit_spde_nested_ccd()tookoptimHess()of the negative log-posterior – already the positive-definite precision of the Laplace approximation – and then negated it, so the degeneracy guard rejected every usable mode and the integrator fell back to the rectangular grid on all inputs. The negation is removed; the precision is used directly to orient the design.method = "ccd"is thefit_spde()default, so fits over an identified SPDE hyperparameter posterior now use the 9-node mode-centred design instead of the 25-node grid. A weakly-identified axis still falls back to the grid via the existing mode and Hessian guards (gcol33/tulpa#92).
tulpa 0.0.25 (2026-06-08)
- feat(spatial): the correlated areal field (separable MCAR,
spatial(graph, ~ ... | cell)) now integrates its cross-covarianceSigmaon a mode-centred central-composite design over the log-Cholesky coordinates, the same outer-integration recipetulpa_re_cov_nested()uses for random-effect covariances. The log-Cholesky coordinates (log L_iion the diagonal, raw strict-lowerL_ij) are already unconstrained on all of R, so they enter the joint CCD as identity axes: the integrator mode-finds the marginal-likelihood mode inSigma-space, orients the design by the Cholesky of the posterior covariance, and weights with the corrected R-INLA design weights – no new mode-find or CCD code, the existing joint CCD machinery drives it. This replaces the fixed log-Cholesky tensor that could land on the nearest node and miss a sharp likelihood mode, and scales to generalpat a polynomial node count (1 + 2k + 2^kfork = p(p+1)/2axes) where the fixed tensor was exponential (p = 3 is 77 nodes vs ~1700 cells; p >= 4 no longer overruns the grid cap). When the cross-correlation is weakly identified the outer curvature is ill-conditioned and the CCD declines back to the fixed log-Cholesky tensor, the correct net there. The outer Pareto-k accuracy diagnostic (fit$pareto_k) is now reported for MCAR fits (it wasNAwhile the fit declined the CCD); a high k-hat on a small-group, weakly-identified cross-correlation is a correct signal, not a defect. Recovers the fields and every cross-correlationrho_ijwith covering CIs at p = 2 and p = 3 (test-spatial-mcar.R).
tulpa 0.0.24 (2026-06-08)
- feat(spatial):
spatial(graph, ~ 1 + x | cell)(a single bar|) builds correlated areal varying-coefficient fields – a separable multivariate CAR (MCAR) where the per-cell coefficient vector shares a cross-covarianceSigma, with joint latent covarianceSigma (x) Q^-1(#89). One coupled block over thepfields assembles the Kronecker precisionSigma^-1 (x) Qin the inner Laplace solve (thepper-field sum-to-zero constants are pinned and folded by the block-Schur path), with thepdesign columns entering the linear predictor aseta_i += sum_c X_{ic} u^{(c)}_{cell_i}via an INDEXED_MULTI block. The outer grid integrates overSigmain log-Cholesky coordinates; the cross-field correlationrhoand the per-fieldsigmas are derived quantities, reconstructed per grid cell and weighted-quantiled (the marginalize-derived-quantities rule).print()andfit$mcar_summaryreport the marginalizedSigma(sigma_1,sigma_2,rho_12, … with 95% CIs). Recovers the fields andrhovs simulated truth with CI coverage (test-spatial-mcar.R); theSigma^-1 (x) Qassembly, gradient, and log-prior (incl. the(n-1) log|Sigma^-1|normalizer) are locked by a direct algebra check againstkronecker(Sigma^-1, Q)(test-mcar-prior.R). The single bar|no longer errors. A single|withproper = TRUE(correlated proper CAR) is out of scope and errors. Tested forp = 2(the headline intercept-plus-slope case); generalp > 2fits through the same path with a coarser raw log-Cholesky grid.
tulpa 0.0.23 (2026-06-08)
- feat(temporal):
temporal(formula = ~ 1 + x || time, structure = "rw1")declares inline temporally varying-coefficient fields in atulpa()model formula, the temporal mirror ofspatial()(#91). The bar’s right-hand side names the time index; the left-hand side expands (viamodel.matrix) into one temporal field per design column – the intercept column is a smooth temporal level, a covariate column is a temporally varying slope on it (eta_i += x_i * f(time_i)).structureselects the temporal GMRF:"rw1"(default),"rw2", or"ar1"(which estimates its own correlationrho).||(independent fields) only; a single|(correlated) is reserved (the temporal counterpart of the spatial MCAR).print()andfit$temporal_field_hypersreport each field’s structure,sigma, and (ar1)rho. Thetemporal()accessor on a fitted model and the baretemporal(col)naming term are unchanged – the constructor is reached only whentemporal()is given a formula. - The temporal nested-Laplace blocks (
rw1/rw2/ar1) now carry the per- row design weight (svc_weight) that the areal blocks already had, so a covariate column scales the field per observation. The spatial and temporal inline-field fitters share one engine (.bar_field_fit_core) and one bar column-expansion helper, so the two paths cannot drift.
tulpa 0.0.22 (2026-06-08)
- feat(spatial):
spatial(graph, ~ ... || cell, proper = TRUE)builds proper CAR varying-coefficient fields, where each field’s precision isQ = D - rho_car Wwith the spatial autocorrelationrho_carestimated from the data instead of the intrinsicrho = 1(#90). Each field stays independent (||) but gains its own(sigma, rho_car)pair, so the per-field outer grid is 2D (thecar_properregistry derives the(tau, rho_car)axes from the eigenvalue interval ofD^-1 W); two proper fields give 4 axes and CCD engages automatically.print()reports each field’s structure (ICARvsproper CAR) plus the marginalizedsigmaandrho_car(median + 95% CI, each a derived quantity weighted-quantiled over the outer grid, never a plug-in of the modal hyperparameter);fit$spatial_field_hypersexposes them.proper = FALSE(default) is unchanged (intrinsic ICAR,rhofixed at 1). A single|withproper = TRUE(correlated proper CAR) remains a separate model.
tulpa 0.0.21 (2026-06-08)
- perf/fix(nested-laplace): the sparse sum-to-zero large-field inner solve now takes the exact block-Schur Newton step instead of escalating a Levenberg-Marquardt ridge until the pinned Hessian factors. For
B = A + sum_k coef_k 1_k 1_k'(the intrinsic field plus its sum-to-zero rank-1 pins) the field sub-blockA_FFis factored once, the pins fold via the matrix-determinant lemma, and the field<->scalar coupling closes with a small dense Schur complement – the true Newton step with no perturbing ridge, so the inner iteration converges quadratically and no longer drops ill-conditioned high-(sigma, alpha)grid cells to-Inf(gcol33/tulpa#69). The Laplace log-determinant uses the same partition (log|B| = log|B_FF| + log|Schur|), which never factors the unpinned near-singular full matrix and so avoids the matrix-determinant-lemma cancellation along the constant direction. The single- species sparse oracle and the fused batched multi-response driver (run_multi_block_nested_laplace_joint_batch) route their inner step through one shareds2z_newton_step, so they stay bit-identical per species; the LM ridge + Woodbury path remains the fallback whenA_FFor the Schur complement is indefinite far from the mode. Validated bit-identical (batched vs single,max|dmode| = max|dlogmarg| = max|dQ| = 0at a 412-cell field) and locked by a direct block-Schur-vs-dense-LLT log-determinant + step unit test.
tulpa 0.0.20 (2026-06-08)
- feat(spatial):
spatial(graph, formula = ~ 1 + time || cell)declares areal varying-coefficient fields inline in atulpa()model formula, the way a random-effect bar is written. The bar’s left-hand side expands (viamodel.matrix) into one independent CAR / Besag field per design column: the intercept column is the spatial intercept fieldu_cell, a covariate column is a spatially varying slope on it (a per-region trendtime * s_cell). The intercept is just the all-ones column, so the unweighted and weighted cases share one path; factors,I(time^2), and splines expand for free. Each field carries the sum-to-zero constraint and its own precision (independent fields). A single response is fit through the single-arm joint nested-Laplace path, which threads the per-row design weight (svc_weight), andsummary()/coef()report the marginalized fixed effects; the per-field posterior means are onfit$spatial_fields. - The bar grammar is strict:
||(independent fields) only – a single|(correlated fields, a multivariate CAR) errors as not-yet-implemented, as do nested (a / b) or interaction grouping (the grouping must be a single graph-node index; add ordinary nested random effects such as(1 | site)separately), a missing bar,by =(reserved for replicated CAR), andproper = TRUE(reserved). The barespatial(col)areal-naming term and thespatial =constructor path are unchanged.
tulpa 0.0.19 (2026-06-08)
- feat(progress): the nested-Laplace outer-grid progress line now shows the active outer-thread count, e.g.
... | 0.06s/cells | 28 threads, whenever the grid runs more than one cell at once, so “ran on N cores” is a property of the fit log itself rather than something to read out separately (#88). The count is the realised outer width stamped on the reporter byrun_nested_laplace_grid(after the sparse path’s memory clamp), so it covers every model routing through the joint engine – the cover hurdle andoccu_cover()included – plus the parallel NUTS sampler, which reports its concurrent-chain count the same way. The mirrored R-side reporter (.tulpa_iter_progress()) gains an optionalthreadsargument carrying the same field. Serial loops leave the count at 1 and omit the field; their lines are byte-for-byte unchanged.
tulpa 0.0.18 (2026-06-08)
- feat(spde): fractional-nu SPDE fields gain a fixed-hyperparameter NUTS path (#85, #87). The rational (BRASIL) approximation is sampled with the hyperparameters held fixed and the latent field non-centered, marginal fixed-effect standard errors are reported, and the precompute path passes the full both-triangle precision
Qon the fractional NUTS route (#87). - fix(numerics): the s2z rank-1 log-determinant no longer cancels catastrophically for fields above 256 nodes. The matrix-determinant-lemma update lost ~2.7 nats to cancellation at
n_x > 256; the log-determinant structure is now cached across grid cells and species, restoring the densify-vs-rank-1 log-marginal equivalence (guarded downstream by tulpaObs). - perf(laplace): the fused batched occu_cover scatter is sparse-native and bit-identical to the dense path.
- test(joint): the coupled-cell path composes a shared field with a per-group random effect (#86).
tulpa 0.0.17 (2026-06-07)
- docs(vignettes): correctness pass against the current API. Corrected the nested-fit
logLik()/compare_models()description (a nested fit returns the integrated-evidence scalar, not a per-grid vector) in the spatial and temporal vignettes, refreshed the stale temporal front-door scope note (rw1/rw2/ar1 plus panel and areal space-time are all wired), replaced an unexportedis_connected()reference with a base-R graph-Laplacian connectivity check, and movedtgmrf_cpp()from a “forthcoming” framing to its shipped present-tense description. - build: drop the precompiled-header mechanism; each translation unit parses RcppEigen directly.
tulpa 0.0.16 (2026-06-07)
- Tagged release of the grouped beta sufficient-statistic joint interface (
slog_y/slog_1my, added in 0.0.15) so consumer packages can pin a released engine; consumed by tulpaObsaggregate.pos. No engine code change.
tulpa 0.0.15 (2026-06-07)
- feat(laplace): the joint nested-Laplace engine accepts optional grouped beta sufficient statistics on a built-in
betaarm. When an arm carriesslog_y/slog_1my(the within-group sums oflog(y)andlog(1 - y), withn_trialsthe per-row group count), a row collapsesnexchangeable beta observations sharing one linear predictor into a single row. The beta log-density is linear inlog(y)andlog(1 - y), so the log-likelihood, gradient and Fisher Hessian are pointwise unchanged and the aggregated fit is byte-identical to the per-observation path (n = 1reduces exactly to the ungrouped branch). Read by the single-block and multi-arm joint drivers; the sharedlog_lik_beta_grouped/grad_hess_beta_groupedhelpers inlaplace_family_link.hare the single source. This is the engine backing for tulpaObs’saggregate.poscover-arm reduction (gcol33/tulpaObs#49).
tulpa 0.0.14 (2026-06-06)
- refactor: remove structural duplication flagged by a code-rot scan, with no change in behavior.
.is_multi_block_prioris now a single predicate (the byte-identical_jointcopy is dropped); the spatially- and temporally-varying-coefficientprint/summarymethods delegate to shared.print_varying_coef/.summary_varying_coefhelpers; the column-major matrix builder is onetemplateover the element type (NumericMatrix vs IntegerMatrix); and the internal CAR-proper and PC-variance log-prior helpers follow the dominantlog_prior_*naming (log_prior_car_proper,log_prior_sigma2_pc), retiring a dead wrapper.
tulpa 0.0.13 (2026-06-06)
- fix(check): clears every
R CMD check --as-cranERROR and WARNING. The joint-NUTS fractional-nu test now asserts the rejection at the fit call – the spec constructor accepts fractional nu since the rational SPDE landed (gcol33/tulpa#71); the performance-core test drops an assertion comparing a hardware core count against the OpenMP-capped thread limit. Rd: a dangling\linkand lost-brace math in the rational-SPDE docs are fixed; NAMESPACE gainsimportFrom(utils, flush.console)/importFrom(stats, vcov); thetulpa_sample_glmm()offset is passed by its full name (offset_nullable). - fix(build): the Windows precompiled header now rebuilds when
Makevars.winchanges, so a.gchleft from an earlier flag set is no longer silently rejected (“created and used with differing settings”) and re-parsed in every translation unit – restoring the PCH speedup (cold compile ~70s -> ~61s).
tulpa 0.0.12 (2026-06-06)
- feat(progress): unified iteration progress + ETA across every fitting loop – the C++ outer grids (nested-Laplace, joint, sparse SPDE), the NUTS sampler, and the R-side EM loop (gcol33/tulpaObs#43). Two independently gated channels: a console bar (the noisy TTY channel) and a heartbeat file written whenever
progress.fileis set, the robust liveness signal for detached runs where an Rcout flush does not survive the stdout buffer.GridProgressgainsemit_console+unit; an R-level.tulpa_iter_progress()mirrors the same<done> <total> <elapsed_s> <eta_s>wire format so a detached reader sees one file regardless of which loop produced it. NUTS ticks a shared reporter across chains (the console line self-suppresses inside the OpenMP region, the heartbeat file is the parallel channel). The nested-Laplace console default flips ON; inner refinement / EM / CCD-probe call sites passprogress = FALSEso only the top-level fit ticks. - docs(api): document
tulpa_profile(), the inner sparse-Laplace phase timer.
tulpa 0.0.11 (2026-06-06)
- feat(samplers): thread random-effect, areal-spatial, and temporal latent structure through the model-agnostic ModelData sampler kernels – hmc / ess / sghmc / sgld / mclmc / smc / vi (gcol33/tulpa#75). These kernels previously fit a fixed-effect GLM only; a structured formula was routed away to the conditional logpost / nested-Laplace paths. They now sample the full latent vector
compute_param_layout()lays out: random effects (intercept, slopes, correlated, multi-term), an areal spatial field (ICAR / BYM2), and a temporal field (RW1 / RW2 / AR1). The variance-component hyperparameters (sigma_reper term/coef, spatialtau/ BYM2sigma+rho, temporaltau/ AR1rho) are sampled JOINTLY with the latent and fixed effects – full Bayes over the variance components, the exact-MCMC counterpart of the Laplace / logpost backends that condition on them. The four default-link families (gaussian / poisson / binomial / neg_binomial_2) get an analytic reverse-mode AD gradient; other families fall back to the numerical gradient, which still scores the full latent log-posterior.tulpa(mode = "hmc" / "smc" / "vi" / ...)reaches the new path for a structured formula;esscarries random effects but declines a structured spatial / temporal field (its isotropic Gaussian-prior block cannot hold the graph precision). The multi-term RE ModelData marshalling is now shared (re_structure.h) between the Laplace multi-RE fit and the sampler builder. Continuous spatial (gp / nngp / hsgp), CAR_proper, and SPDE fields keep their dedicated paths.
tulpa 0.0.10 (2026-06-05)
- Fix a data race in the threaded sparse joint outer-grid nested-Laplace driver (
run_multi_block_nested_laplace_joint_sparse_impl). The cell-coupling (coupled) arm’s per-cell dispersion was read lock-free from the sharedarmsduring the inner Newton solve while a concurrent grid cell’sprep_at_gridrewrote it under thenl_sparse_phicritical – every other arm already read a thread-local snapshot, but the coupled arm did not. On a gridded coupled-arm dispersion (e.g. the beta-cover precision on the phi axis) this corrupted per-cell values acrossn_threads_outer > 1, causing wrong log-marginals / non-convergence and the intermittent native crashes reported downstream (gcol33/tulpaObs#42). Now the coupled arms’ dispersion is snapshotted under the same critical and read via aphi_overridein the coupled scatter / log-lik; serial and dense callers passnullptrand are byte-unchanged. Verified: a 220-region BYM2 beta-cover fit is identical serial vsn_threads_outer = 6to ~1e-10.
tulpa 0.0.9 (2026-06-03)
feat(offset): thread
offset()terms through the SPDE, spatial-Laplace, and ModelData-sampler paths oftulpa()(gcol33/tulpa#72). A fixed log-exposure / log-effort offset (the standard way to model rates) now enters the linear predictoreta = offset + X beta + fieldon every fitter, where it was previously honoured only on the non-spatial GLMM / EM paths and hard-errored on the rest. The offset is carried as the per-processProcessData::offset(areal ICAR/CAR/BYM2 + NNGP Laplace and all seven sampler kernels – hmc/ess/sghmc/sgld/mclmc/smc/vi – which already consumed it viacompute_eta_spec/precompute_generic_fixed_eta), as a per-armParsedArm::offsetin the nested-Laplace joint engine (the nested SPDE path), and as a raw additive vector in the single-point SPDE kernel. The threetulpa()guards are removed;fit_spde()andtulpa_sample_glmm()gain anoffsetargument.fix(spde): gate fractional Matern smoothness (
nu) instead of silently fitting a mis-specified field (gcol33/tulpa#71). The wired rational assembly buildsQ = tau^2 sum_k w_k (L + r_k C)' C^{-1} (L + r_k C), whose spectral symboltau^2 sum_k w_k (l + r_k)^2is a single quadratic inlfor any number of poles – so no choice of poles/weights recovers a fractionalalpha; the construction collapses to analpha = 2field regardless of the coefficients (verified to machine precision). The coefficient generator (rational_spde_coefficients()) previously returned a self-derived log-uniform approximation while documenting the published BRASIL / Bolin et al. (2023) method.spatial_spde()/spatial_spde_custom()now require an integernu(0, 1, 2, …) – the exact FEM construction – and reject fractionalnuwith a clear error across every fit path (Laplace, nested, NUTS, joint). A faithful rational SPDE precision assembly remains tracked under gcol33/tulpa#71.perf(nested-laplace-joint): budget the replicated per-outer-thread state (the sparse-Hessian builders + numeric factor) against detected physical RAM instead of a fixed 2 GB cap (gcol33/tulpa#64). The old cap clamped the outer grid to ~15 threads at EVA scale on a 64 GB box even when 28 were requested; a new standalone
sysmemtranslation unit (total_ram_bytes(), Windows / macOS / POSIX) lets a wide field use every requested outer thread when the memory is there, falling back to the 2 GB cap only when the RAM query fails.fix(nested-laplace-joint): the outer-grid progress ETA is now computed from the realised parallel width, not a serial extrapolation of the pilot rate (gcol33/tulpa#64). A ~21 min serial pilot on a 48-cell grid previously projected
ETA ~16 h; the ETA now estimates per-cell wall time from completed waves (one serial pilot wave +(done-1)/widthparallel waves) and projects the remaining cells overceil(remaining/width)waves. At outer width 1 this is exactly the previous serial formula.fix(nested-laplace): retire the unguarded
.nl_normalise_weightssoftmax entirely – every outer-grid weight normaliser now routes through the finite-guarded.nl_normalise_weights_safe(gcol33/tulpa#65). The remaining unguarded call sites (the single-block grid, the multi-block grid, the adaptive-refinement reweight, and the cheap-screen ESS gate) could still collapsefit$weightsto all-NaN when one outer cell returned a non-finitelog_marginal, breakingtulpa_posterior_draws()/predict()/ WAIC on the finite, precision-carrying cells.logLik()’s grid log-sum-exp is guarded the same way so a non-finite cell no longer poisons the integrated evidence (andAIC/compare_modelsdownstream). Behaviour is unchanged on all-finite grids.fix(nested-laplace-joint): the single-block joint path normalises the outer-grid weights with the NaN-safe
.nl_normalise_weights_safe(drop non-finite cells, renormalise) instead of the baremax(lm)softmax. A single non-finitelog_marginal(an inner solve that diverges at an extreme hyperparameter cell) previously poisoned the wholeweightsvector to NaN, sotheta_*summaries had to work around it andtulpa_posterior_draws()failed with “no outer-grid cell has positive weight”. Degenerate cells now get zero weight and the remaining cells carry the mass, matching the multi-block path and the hyper-grid path. Behaviour is unchanged on all-finite grids.feat(gauss-hermite): export
gauss_hermite.h(probabilist Golub-Welsch nodes) underinst/include/tulpa/soLinkingToconsumer packages reuse the engine’s one implementation instead of re-deriving the quadrature.feat(nested-laplace): the multi-block joint path announces the engaged outer integrator under
control$verbose = TRUE, in one line at selection time before the inner solves (gcol33/tulpa#63): e.g.outer integration: CCD (4 latent axes, 25 nodes),tensor grid (72 cells), orCCD declined -> tensor grid (72 cells). Previously the"auto"switch to the CCD at>= 4latent axes was silent – a consumer who omittedintegrationfrom the control could end up on the CCD path (and, on a ridged posterior, in the #62 thrash) with no signal, the only post-hoc tell beingfit$...$integration. The resolved integrator is still returned on the joint result as$integration.fix(nested-laplace): the joint multi-block CCD outer mode-find now declines fast on a flat / ridged hyperparameter posterior (gcol33/tulpa#62). Previously a sigma-alpha ridge produced a near-singular outer Hessian, a huge Newton step, and a deeply backtracking line search of full-field inner solves (hours, before the post-hoc guard could decline). The mode-find now (a) pre-checks the centre Hessian conditioning and declines to the tensor grid immediately on a ridge – the same verdict, minus the line search; (b) trust-clamps the Newton step so a candidate never leaps to an extreme hyperparameter; (c) caps the line-search backtracking; and (d) advances the inner warm start to each accepted point so probes solve in a few Newton steps instead of cold from the box centre.
feat(nested-laplace):
control$integrationfor a multi-block joint prior gains"auto"(the new default) and now takes"auto"/"ccd"/"grid"(gcol33/tulpa#59)."auto"uses the CCD only at>= 4transformable axes, where the tensor product’sk^dblow-up bites hardest, and keeps the cheaper, more ridge-robust tensor grid at<= 3axes;"ccd"lowers the CCD threshold to>= 3axes;"grid"always forces the tensor product. (Previously the default engaged the CCD at>= 3axes.)
tulpa 0.0.8 (2026-06-02)
- feat(nested-laplace): CCD outer integration for the joint multi-block path (gcol33/tulpa#59).
control$integration = "ccd"(default for >= 3 transformable axes) integrates the joint hyperparameter posterior on a central composite design around its mode – far fewer inner solves than the k^d tensor product. Auto-declines to the tensor grid for <= 2 axes, an unguessable axis (CAR_properrho_car/ non-BYM2rho), or a degenerate mode-find. - feat(nested-laplace): CCD now rides an active
phi_grid(gcol33/tulpa#61). An active per-arm dispersion axis no longer disables CCD: the design is built over the>= 3latent axes and thephitensor is Cartesian-crossed on top, with the CCD node weights replicated across thephicells. A two-field betaoccu_cover()/cover()joint fit (4 latent axes + aphigrid) integrates on25 x phicells instead of the81 x phidense tensor. - feat(samplers): generic R fitter for NUTS + log-posterior kernels; negbin spatial (areal ICAR) Gibbs.
- refactor(spatial/joint/pg): share the ICAR prior (one structured quadratic + Besag sum-to-zero penalty across the dense / sparse paths), thread an optional informative per-coefficient Gaussian beta prior into the joint arms (breaks the occupancy psi-p identifiability ridge), and fix the AR1 gradient.
- fix(nested-laplace): exact sparse ICAR / BYM2 sum-to-zero (gcol33/tulpa#60). The Besag sum-to-zero penalty has a dense rank-1 (
1 1') Hessian; the sparse path previously stored only its diagonal, soforce_sparse/ large-field ICAR and BYM2 joint fits had a log-marginal off from the dense path by the missing rank-1 log-det term. Now exact, size-gated: small fields densify the field block and store1 1'directly; large fields fold the rank-1 in at solve time (Sherman-Morrison step + matrix-determinant-lemma log-det) via one reuse-solve per field.TULPA_S2Z_DENSIFY_MAXtunes the cutoff. - chore: finish Phase D of the tulpaRatio migration – remove the dead legacy ratio C++ (
gibbs_spatial*,hmc_zi.h) and the trailingModelType/LegacyRatioreferences in the exported headers; rename stalenumdenomreferences totulpaRatioin the docs. - tests:
TULPA_FASTdev tier (skip_if_fast()) runs the structural / closed-form / gradient unit tests only; the default still runs the full recovery suite.
tulpa 0.0.7 (2026-06-01)
- feat: grid-cell / per-unit checkpoint/resume across every fitter with an expensive outer loop (gcol33/tulpa#50). A killed or rebooted fit reloads the completed units and runs only the rest. One content-addressed binary append log (
src/checkpoint_io.h,CheckpointLog<Payload>) owns the file format, load/append/torn-tail logic and fingerprinting once; a torn final record is truncated and re-run, and a header fingerprint mismatch (different data / settings / grid) errors rather than resuming onto a stale result. Wired through:- the single-block nested-Laplace kernels (icar / bym2 / car_proper / temporal / the ST variants / nngp / hsgp),
tulpa_nested_laplace()multi-block, andfit_spde()–control$checkpoint = list(path =, resume =)(acheckpoint =arg onfit_spde()); -
tulpa_re_cov_nested()’s CCD node integration (checkpoint =arg, an atomic-RDS node cache); - the per-chain NUTS producer (
cpp_tulpa_fit_generic_chains(checkpoint_path=)): a chain is deterministic in(seed, chain_id, data, settings), so a resumed chain is bit-for-bit identical to the uninterrupted one. Extends the joint-fit checkpoint shipped earlier to the rest of the engine; the joint path was refactored onto the shared core with no file-format change. Tests:test-checkpoint-universal.R.
- the single-block nested-Laplace kernels (icar / bym2 / car_proper / temporal / the ST variants / nngp / hsgp),
- fix(nested-laplace): the outer Pareto-k accuracy diagnostic no longer dominates runtime or runs solves it then discards (gcol33/tulpa#51). Three changes, all on the diagnostic path – the integration result is unchanged:
- The importance-sampling cores decline up front when
k_samplesis below the GPD-fit floor (.PSIS_MIN_EVAL, 25): a sub-floor budget can never reach enough finite evaluations, so it now returnsNAwithout paying a single inner solve instead of evaluating the whole budget and discarding it. - Each diagnostic re-evaluation solve is warm-started from the modal cell’s converged latent mode, so the draws carrying the bulk of the importance weight converge in a few Newton steps rather than from a cold start.
- The diagnostic solves cap their inner iterations at
min(max_iter, 25). A draw at an implausible hyperparameter (where the inner Newton would otherwise stall to the full budget) carries negligible importance weight, so the cap bounds its cost without moving the k-hat – converged draws keep their exact log-marginal; only the negligible-weight tail is truncated. Coverstulpa_nested_laplace_joint()(single- and multi-block); the sub-floor decline also coverstulpa_nested_laplace(),tulpa_re_cov_nested()andfit_spde()through the shared PSIS cores.
- The importance-sampling cores decline up front when
tulpa 0.0.6
-
tulpa_re_cov_nested()documents its outer accuracy-diagnostic controlsdiagnose_k(defaultTRUE) andk_samples(default 200) in the help page. - Requires tulpaMesh (>= 0.1.2), which fixes SPDE mesh construction returning a zero-triangle mesh on some constrained inputs (gcol33/tulpaMesh#3).
- The two-arm community N-mixture oracle equivalence/recovery test moves to its model-adapter home in tulpaObs (
ms_abun); the generic engine retains the structure-agnosticmake_site/make_groupequivalence checks.
tulpa 0.0.5
- feat(nested-laplace): fits now record wall-clock runtime on the returned object as
fit$timing(gcol33/tulpa#48). A named numeric of seconds carryingtotalplus a phase breakdown –setup(validation / encoding / grid construction),grid(the inner Laplace solves that scale with grid size and core count, including adaptive-refinement and consistency passes),postproc(weight / moment / marginal assembly), anddiagnostics(the outer Pareto-k-hat). Coverstulpa_nested_laplace()(single- and multi-block) andtulpa_nested_laplace_joint()(single- and multi-block dispatch); consumer fits riding on the joint object inherit it. A newprintmethod for the nested-Laplace classes surfaces a one-line summary ("fit in 5h 25m (grid 2h 09m)") alongside the hyperparameters, grid size, and outer Pareto-k-hat. - perf(nested-laplace): the sparse joint outer grid now runs in parallel under
control$n_threads_outer(gcol33/tulpa#46, lever 2). It previously forced a serial outer loop, so on a large field only the inner per-observation OpenMP parallelised and most cores idled. The sparse driver now allocates a per-outer-thread pool (Hessian builder, Newton scratch, arm specs, scatter index cache, DENSE_BASIS scratch) and dispatches grid cells acrossn_threads_outer, matching the dense driver. The phi-grid dispersion axis is parallel-safe: it rewrites the sharedarmsdispersion per cell, so each cell snapshots it into its own thread’s specs under a short critical before the lock-free Newton solve. A memory guard clamps the thread count when the replicated builders would be too large (very wide fields fall back to fewer outer threads). Parallel-vs-serial parity (with and without a phi axis) is covered intests/testthat/test-nested-laplace-joint-sparse-parallel.R. - perf(nested-laplace): grad-only inner-Newton steps under
inner_refresh(gcol33/tulpa#46, lever 1b). On a factor-reuse step the Hessian is discarded, so the cell-coupling scatter now passes agrad_onlyrequest to theCellCouplingSpec(a new ABI-appendedCellDerivs::grad_only): a spec may skip its negative-Hessian work (e.g. a beta arm’s digamma/trigamma) and emit only the exact gradient, and the kernel skips the cross-arm Hessian scatter. Specs that do not implement it write the full Hessian as before (correct, no saving). The gradient stays exact on every step, so the converged mode is unchanged – validated by a grad-only-honoring cell-coupling reuse test intests/testthat/test-nested-laplace-joint-inner-refresh.R. - perf(nested-laplace):
control$inner_refresh(default1L) adds Shamanskii / chord-method Cholesky factor reuse to the sparse joint inner Newton (gcol33/tulpa#46). For a non-quadratic positive arm (e.g. a beta cover arm) the latent Hessian changes every inner iteration, so the default re-factorizes the sparse Cholesky on each step – the dominant per-grid-cell cost.inner_refresh = m > 1re-factorizes only everym-th inner step and reuses the cached factor in between (refreshing early if a reused solve fails). The gradient is exact on every step and each step is line-search safeguarded, so the converged mode is unchanged and the final mode-pass Hessian (log_det, SEs) is always fresh – only the path to the mode uses a stale curvature. Applies to the sparse LM path; the dense small-n_xpath re-factorizes cheaply and ignores it. Bit-equivalence to the every-step default is covered intests/testthat/test-nested-laplace-joint-inner-refresh.R. - feat(nested-laplace):
tulpa_posterior_draws(fit, idx, n)– a generic posterior sampler for the grid-integrated joint nested-Laplace backend (theinla.posterior.sample()analogue, gcol33/tulpa#44). Draws from the outer-grid mixturesum_k w_k N(m_k, V_k): each draw picks a grid cell from the integration weights, then samples the inner latent vector from that cell’s constrained Gaussian via the stored sparse precisionQ_csc_*_per_grid(requirescontrol$store_Q = TRUE). The ICAR / BYM2 field sum-to-zero constraint is imposed by conditioning on kriging (Rue & Held 2005), so the per-cell marginal matches the constrained inner-Laplace covariance exactly; single-block and multi-block (multi-field trend) layouts are both handled. Sampling the mixture – rather than a single moment-matched Gaussian – is the faithful primitive for marginalizing nonlinear derived quantities (change in occupancy, expected-cover products). Draws are taggediid. Tests intests/testthat/test-posterior-draws-joint.R. - fix(nested-laplace): the joint outer-grid cheap-pass prune (
control$prune = TRUE) no longer mis-ranks grid cells or drops the true posterior mode (gcol33/tulpa#43). The screen previously ran a single Newton step from one global pilot mode for every cell; when the inner latent mode moves substantially across the outer grid (large spatial fields, wide sigma/rho/alpha ranges) the one-step approximation mis-estimated far cells by O(1e5) log-units and inverted the ranking, so the prune could skip the full solve on the actual mode. The screen is now a rank-faithful chained sweep over the lattice: each cell runs a short Newton run warm-started from the previous screened cell’s quasi-mode, so every cheap mode stays near its cell’s true mode and the cheap ranking agrees with the full-solve ranking. - fix(nested-laplace): added a safety gate to the joint cheap-pass prune. If the cheap-screen argmax disagrees with the full-solve argmax, or the kept posterior collapses onto a cell whose cheap-vs-full log-marginal gap is large, the fitter warns and falls back to the full grid (
$prune_fallback_triggered,$prune_fallback_reason) rather than silently returning a pruned answer. A silently-wrong pruned posterior is now impossible. - feat(spde):
fit_spde()reports an outer Pareto-k-hat accuracy diagnostic ($pareto_k) over the integrated(range, sigma)hyperparameters – the iid-fit counterpart of Rhat. k-hat < 0.7 means the Gaussian proposal the integrator orients its CCD/grid with fits the hyperparameter posterior; >= 0.7 flags a skewed / heavy-tailed posterior the grid misfits. Controlled bydiagnose_k(default TRUE) /k_samples(default 200), RNG-restored so the fit’s draws are unchanged. - feat(nested-laplace): the joint backend (
tulpa_nested_laplace_joint, single- and multi-block) reports the same outer Pareto-k-hat over its heterogeneous hyperparameter space (gcol33/tulpa#42). A block-type-aware per-axis transform unconstrains each axis – positive scales bylog, the BYM2 mixing weight by logit, the copy coefficient by identity – and the summed log-Jacobians enter the importance target. A CAR_properrho_caraxis (support is the adjacency eigenvalue interval, not guessable) declines to quadrature ESS rather than apply a wrong transform. - feat(nested-laplace):
control$hessianselects the inner-Newton curvature for the joint mixture Hessian –"lm"(default, diagonal-ridge escalation until CHOLMOD factorizes),"psd"(eigen-clamp the dense observed Hessian), or"fisher"(complete-data expected information, PSD by construction). - feat(joint-laplace): cross-arm coupling via a
CellCouplingSpecregistry (gcol33/tulpa#32). Consumer packages register a per-cell coupling spec (e.g. tulpaObs’s cover-hurdle) so two arms share a latent field; the cross-arm block is assembled into the joint Hessian (tulpa_register_cell_couplingC callable, default"separable"always available).
tulpa 0.0.2
- feat:
tulpa_re_aghq()– a callback-driven adaptive Gauss-Hermite refinement of a grouped random-effect covariance. Generalizesagq_fit()(intercept-only RE, built-inbinomial/poisson/gaussian) to random slopes and correlated multi-coefficient blocks sharing one grouping factor, with the per-observation marginal likelihood supplied by the caller through amake_sitecallback. This lets a downstream package refine a custom marginal (e.g. a latent-state-integrated occupancy / detection likelihood) through the same quadrature. Reuses the existing log-Cholesky covariance parametrization (.re_cov_*),gauss_hermite_prob(), and an optional LKJ correlation penalty; the fixed parameters andchol(Sigma)are optimized jointly on the exact-marginal log-likelihood, with SEs from the marginal Hessian. Recovery tests intests/testthat/test-re-aghq.R.
tulpa 0.0.3 (2026-05-28)
feat(nested-laplace):
tulpa_nested_laplace_joint()now reports the outer Pareto-k-hat accuracy diagnostic ($pareto_k,$pareto_k_is_ess) over its heterogeneous hyperparameter space, completing the nested-Laplace k-hat family alongside the re-cov, generic single-axis, and SPDE paths (gcol33/tulpa#42). A block-type-aware per-axis transform registry unconstrains each axis – positive scales (sigma,tau,phi_*, …) bylog, the BYM2 mixing weight (rho) by logit, the copy coefficient (alpha) by identity – with the summed log-Jacobians in the importance target; the inner marginal is re-evaluated through the same kernel the integrator used. A fit carrying an axis whose support is the adjacency eigenvalue interval (CAR_proper’srho_car) declines to the quadrature-ESS fallback rather than apply a guessed transform. Gated bycontrol$diagnose_k(defaultTRUE) /control$k_samples(200), RNG-restored so draws are unchanged. Recovery + plumbing tests intests/testthat/test-nested-laplace-joint-pareto-k.R.refactor(aghq): one compiled adaptive-Gauss-Hermite engine behind the whole ML-II optimize family. The per-group marginal – mode-find, quadrature grid, log-Cholesky
Sigmapacking, LKJ penalty and marginal Hessian – now lives in C++ (src/aghq_re*.{h,cpp},inst/include/tulpa/aghq_oracle.h), driven through one structure-agnostic per-group oracle.tulpa_re_aghq()andagq_fit()are thin wrappers over it (their R integration loops are gone; the optimizer takes finite differences of the compiled objective, consistent at everyn_quad). The mode-find is a globally-convergent modified Newton (prefers the true observed-info Hessian where PD; falls back to a caller-supplied PSD Fisher or an eigenvalue-reflected curvature otherwise), so a latent-variable marginal whose observed information is indefinite away from the mode no longer breaks it.tulpa_re_aghq()gainstheta_prior_sd(a Gaussian ridge on the fixed parameters) and returnslog_marginal.refactor(aghq):
agq_fit()builds its per-group marginal from the native GLMM oracle (cpp_glmm_oracle_make,src/glmm_oracle.h) instead of an R-closure oracle over an R family density. The built-inbinomial/poisson/gaussiandensities now have a single C++ source of truth shared withtulpa_re_aghq(),tulpa_re_cov_nested(n_quad > 1)and the Gibbs sweep; this removes.agq_loglik_elt()/.agq_score_info(). Estimates, covariances andn_quad-convergence are unchanged; the reportedlog_marginalnow carries the full likelihood normalizing constants (the binomial coefficient and Poissonlgammathe R density previously dropped), matching the other AGHQ fitters.refactor(gibbs): the exact-target random-effect-covariance sampler (
tulpa_re_cov_gibbs()) runs its Metropolis-within-Gibbs sweep in compiled code (src/re_cov_gibbs.cpp,src/re_cov_gibbs_sweep.h), driven by one native per-row GLMM likelihood (src/glmm_oracle.h) rather than a duplicated R density. This removes.re_obs_loglik: the family densities (binomial / poisson / gaussian / negative-binomial-2) now have a single source, and the engine owns the shared linear predictor with the cross-block eta coupling for several terms. The estimator is unchanged – the C++ sweep keeps the R sampler’s RNG-draw order, so a seeded run reproduces the previous sampler’s draws bit-for-bit (verified across the correlated, diagonal and multi-termtest-re-cov-gibbs.Rcases). The R wrapper keeps the pilot Laplace solve (starting values + proposal shapes) and the weighted-quantile summary.feat(re-cov):
tulpa_re_cov_nested()gainsn_quad– an adaptive Gauss-Hermite refinement of the inner marginal.n_quad = 1(default) keeps the joint-field Laplace inner solve unchanged;n_quad > 1routes the inner solve through the shared compiled AGHQ engine (cpp_glmm_oracle_make+cpp_aghq_objective), so each per-group integral inside theSigmaintegration is debiased by quadrature (thetulpa_re_aghq()correction applied under the grid), reducing the small-cluster variance attenuation for binary / low-count data. The fixed effects are integrated (profiled out + a fixed-effect Laplace term), so the reported fixed-effect posterior is the marginal (ML-II) one rather than the joint-mode (PQL) estimate. The per-group integral only factorizes over one shared grouping factor, so AGHQ requires that; crossed RE terms keep the joint-field Laplace (n_quad > 1errors). Recovery + the crossed-factor guard intest-re-cov-nested.R.feat(nmix): community / multispecies N-mixture (
tulpa_nmix_laplace_re(), the spAbundancemsNMixmodel) now fits through that shared engine – it wrapstulpa_nmix_site_marginal()as the per-species oracle (the marginal, the abundance/detection score, and both the observed-info block with theVar[N|y]coupling and the PSD complete-data Fisher for the mode-find) and integrates the per-species coefficient random effects atn_quad = 1(joint Laplace). This replaces and removes the bespoke C++ Laplace-EM (src/nmix_re.cpp); the community fit verified against an independent Laplace marginal and recovered over seeds (tests/testthat/test-nmix-re.R). Fixed-effect SEs are now the joint marginal Hessian (marginalizing the community-covariance uncertainty, closer to the spAbundance posterior) rather than aSigma-plug-in Schur complement. The numerical knobstol/inner_max/inner_tolare dropped (the engine owns the mode-find);sigma_betais kept as the fixed-effect ridge. Poisson only for now.feat(nmix):
tulpa_nmix_site_marginal()exposes the per-site N-mixture marginal as a composable random-effect callback (eval/eval_beta/obs_info_block), andtulpa_re_aghq()gained amake_grouppath for the general / multi-arm case (a per-groupb-space oracle), so a custom marginal with coupled arms at different granularities – the abundance / detection arms of an N-mixture site sharing a species grouping – integrates through the same quadrature. (The C++tulpa_nmix_laplace_re()above is the production fitter; this is the composable / AGHQ-refinement path.)perf(nmix): the per-site kernel (
nmix_kernel.h) caches its eta-independentlgammacombinatorial terms (NMixSiteCache/nmix_precompute_site/compute_nmix_site_cached), so an iterative fitter that evaluates a site many times at changing linear predictors skips thelgammarecompute (the dominant cost). The single-shotcompute_nmix_site()Poisson path delegates to the cached helper – single source of truth – so existing single-species / spatial fits are byte-identical (nmix regression suite unchanged).refactor(nested-laplace)!: collapsed the 3 single-block temporal entries (
*_{rw1,rw2,ar1}) to one*_temporalentry that selects the kernel at runtime via atemporal_typeargument through the sharedmake_temporal_opsregistry – the same collapse the spatio-temporal entries already use, so adding a temporal kernel is O(1) at every layer (Rcpp entry, extern-C shim, exported ABI). ABI break (TULPA_ABI_VERSION26 -> 27):tulpa_nested_laplace_{rw1,rw2,ar1}+ theirNestedLaplace{Rw1,Rw2,Ar1}Fntypedefs becometulpa_nested_laplace_temporal/NestedLaplaceTemporalFn; downstream packages rebuild. The R-levelrw1/rw2/ar1block types are unchanged.refactor(laplace)!: removed the 8 dead family-enum single-point Laplace C-callables (
tulpa_laplace_mode_{dense,spatial,dense_multi_re,bym2,gp, multiscale_gp,multiscale_temporal,rsr}) and theirLaplaceMode*Fntypedefs. No package consumes them – every model package routes single-point Laplace through theLikelihoodSpecpath (tulpa_laplace_spec_*). ABI break (TULPA_ABI_VERSION25 -> 26); downstream packages must rebuild. The sharedLaplaceShimResultPOD is retained (reused by the spec shims).refactor(nested-laplace)!: collapsed the 15 spatio-temporal nested-Laplace entries (
*_st_<spatial>_<temporal>) to 5 per-spatial-family entries (*_st_{icar,car_proper,bym2,hsgp,nngp}) that select the temporal kernel (rw1 / rw2 / ar1) at runtime via atemporal_typeargument, dispatched through a singlemake_temporal_opsregistry. Adding a temporal kernel is now O(1) – one registry branch, no new cross-product function at any layer (Rcpp entry, extern-C shim, or exported ABI). ABI break (TULPA_ABI_VERSION24 -> 25): the 15tulpa_nested_laplace_st_*registered callables + theirNestedLaplaceSt*Fntypedefs became 5; downstream packages must rebuild. Dense/sparse per-kernel equivalence preserved (test-nested-laplace-st-sparse-equivalence.R).feat(nmix):
tulpa_nmix_laplace()gainsmixture = c("P", "NB")– a negative-binomial abundance mixing distribution (N_i ~ NegBin(mean = lambda_i, size = r),neg_binomial_2convention) in addition to the Royle (2004) Poisson kernel. The per-site marginal, its scores (including the analytic dispersion scored log L / d log r), and the full joint observed-information Hessian are closed form; the dispersionlog_ris profiled by block coordinate ascent outside the inner beta-Newton and reported with its standard error invcov. Matchesunmarked::pcount(mixture = "NB")on coefficients, log-likelihood, and standard errors to machine precision, with the usual analytic-derivative speed advantage. Poisson remains the default and is unchanged.feat(nmix): the spatial nested-Laplace N-mixture fits (
tulpa_nmix_laplace_icar(),tulpa_nmix_laplace_car_proper(),tulpa_nmix_laplace_bym2()) gainmixture = "NB". The NB sizeris integrated as an additional outer grid dimension alongside the spatial hyperparameters (tau/rho/sigma); the posteriorr_mean/r_sdare reported from the grid weights. The inner(beta, z)/(beta, v, w)Newton is unchanged in dimension – only the likelihood pieces and the NB-awareVar[N|y]rank-1 correction depend onr. Poisson remains the default with identical behaviour and grid shape.refactor(api):
tulpa_nested_laplace()andtulpa_nested_laplace_joint()collapse their perf/numerical knobs into a singlecontrol = list()argument, matchingtulpa(). The top-level signatures now carry only statistical arguments (y/n_trials/X/prior/spec/family/phi/likelihood/…;responses/prior/copy/phi_grid/prior_sigma/prior_alpha). Tuning knobs move intocontrol: single-armmax_iter,tol,n_threads,x_init,keep_grid_hessians; joint additionallyn_threads_outer,tile_warm,prune,prune_tol,store_Q,adaptive_grid,adaptive_grid_edge_thresh,adaptive_grid_max_passes,var_of_means_consistency,force_sparse,verbose. Pre-release breaking change – pass these insidecontrol = list(...)(no deprecation shim). The dead single-armverboseknob was dropped. Internal callers (em_laplace, the tgmrf pilots) and the shipped examples were migrated.feat(laplace):
tulpa_laplace_beta()gains abeta_priorargument, forwarded to the innertulpa_laplace()fits (both the outerphisearch and the final refit) so the beta arm can carry a Gaussian fixed-effect penalty. Pure R passthrough –tulpa_laplace()already appliedbeta_priorforfamily = "beta". Enables penalised beta-regression arms downstream (tulpaObscover_priors()positive arm). Rejected withspatial, matchingtulpa_laplace().feat(laplace): correlated random slopes
(1 + x | g)on the Laplace engine (gcol33/tulpa#28).tulpa_laplace()RE terms accept a per-term covariance viaL(lower-triangular Cholesky,Sigma = L L') orcov; the off-diagonal now enters both the joint Hessian (mode finding) and the marginal fixed-effect SE. Previously a multi-coefficient RE block could only carry a per-coefficient marginal-sigma vector (a diagonal covariance), so(1 + x | g)was inexpressible under Laplace and downstream packages routed it to NUTS. The C++ multi-RE kernel already consumed a packed Cholesky; this wires the R API to it through a single.re_cov_spec()helper and rebuilds the marginal Schur complement with a block-diagonal precision. It also fixes a pre-existing bug in the marginal-SE linear predictor – the eta reconstruction treated every RE term as intercept-only, so the returnedH_betasilently ignored random slopes (this affected(x || g)as well). Validated against an independent full-precision Schur intests/testthat/test-laplace-corr-re.R. Estimating the covariance itself (the EM M-step for a fullSigma) is the follow-up; the engine now fits correlated slopes at a supplied covariance.feat(laplace):
tulpa_laplace(return_re_cov = TRUE)returns per-group posterior covariance blockscov_blocks– onen_coefs x n_coefsmatrix per (RE term, group) in term-major then group order, each a diagonal block of the full inverse Hessian (fixed effects and other groups marginalized out), i.e.Cov(u_g | y, Sigma), not the inverse of a diagonal block. Built by reusing the Cholesky factor from the log-determinant (one back-solve per block column, no refactorization). This is the primitive a full-covariance EM M-step consumes to updateSigma_k <- mean_g [u_g u_g' + Cov(u_g)]; tulpaObs#11 uses it to fit(1 + x | g)deterministically. Non-spatial multi-RE path only (rejected withspatial).feat(nuts): expose tulpa’s across-chain OpenMP runner through the model-facing C ABI (gcol33/tulpa#30). New registered callable
tulpa_run_nuts_chains(header accessortulpa::get_nuts_chains_fn()) runsn_chainschains in one call and fills a caller-allocated array ofNUTSResult, so downstream packages stop re-implementing chain orchestration (offset-seed loops / PSOCK clusters) in R and get the engine’s thread-parallel path for free.initand the optionalinv_metric_diagare chain-major[n_chains * n_params], so a fresh fit broadcasts one init while a resume passes each chain’sfinal_position+inv_metric_out(withn_warmup = 0) — composing with #29 to continue a whole multi-chain fit. The OpenMP loop now lives in one pure-C++ core (run_hmc_parallel_chains_cpp) shared by the C ABI and the existing Rcpp-returningrun_hmc_parallel_chains. New generic R entry pointcpp_tulpa_fit_generic_chains()returns draws stacked chain-major with achain_idvector — the layoutmcmc_diagnostics()(#26) consumes directly — plus per-chainepsilon/inv_metric/final_position. Validated intests/testthat/test-generic-sampler.R, including a cross-chain Rhat/ESS check throughmcmc_diagnostics(). ABI bump 23 -> 24 (new callable only; no struct layout change).feat(nuts): the NUTS C-ABI now returns the state needed to resume or warm-start a chain (gcol33/tulpa#29).
NUTSResultgainsinv_metric_out(the adapted inverse-mass diagonal at end of warmup) andfinal_position(the last raw sampler state);epsilonwas already returned. Feeding them back asinit+inv_metric_diagwithn_warmup = 0continues the chain from the previous fit’s geometry instead of rediscovering it. The inputs already existed onNUTSFn; only the result fields were missing. The generic R entry pointcpp_tulpa_fit_generic()gains optionalinit/inv_metric_initarguments and returnsinv_metric/final_position, exercised intests/testthat/test-generic-sampler.R. ABI bump 22 -> 23 (two trailing pointers appended toNUTSResult;NUTSFnunchanged).feat(diagnostics): extend the native MCMC convergence surface (
R/convergence.R) toward posterior parity (gcol33/tulpa#26).mcmc_diagnostics()gainsmeasuresandprobsarguments selecting from improvedrhat(now the maximum of rank-normalized split-Rhat and folded split-Rhat, matchingposterior::rhat),rhat_bulk,rhat_fold,ess_bulk,ess_tail,ess_mean,ess_sd,mcse_mean,mcse_sd, and per-probabilityess_quantile/mcse_quantile. The default columns (rhat,ess_bulk,ess_tail) are unchanged. New measures are registered in one table (.tulpa_diag_measures), so adding a statistic is a one-line change. Two estimator bugs are fixed so the native code reproducesposteriorto machine precision (~1e-12): the rank-normalization divisor is now the BlomS + 1/4(wasS - 1/4) and the Geyertau_hattail term no longer double-counts. New exported helpers:tulpa_draws_array()(anas_draws_array()-style[iter, chain, param]accessor),n_divergent(), andcheck_diagnostics(). The plotting / summary layer (plot_rhat,plot_ess,diagnostic_summary,plot_diagnostics,plot_acf,plot_pairs) now resolves the previously undefinedget_draws_array(),grep_params(), andn_divergent()helpers and runs end-to-end on a multi-chain fit. Validated intests/testthat/test-convergence.R.feat(re): random-effect blocks support an optional per-term intercept.
ModelData::re_has_intercept(default: all terms carry the implicit group intercept) lets a term be slope-only (lme4(0 + x | g)): everyre_n_coefs[t]coefficient is a slope read from the slope design matrix and there is noz = 1column. The change is threaded through the design lookup (slope_at()/obs_re_contrib()inlaplace_spec.cpp) and the autodiff RE contribution (log_post_generic_impl.h), so the value and its gradient stay consistent.re_term_has_intercept()(inmodel_data.h) centralises the per-term test. ABI bump 21 -> 22 (newModelDatafield). Enables gcol33/tulpaObs#10 slope-only bar syntax.-
fix(spatial):
spatial_car()/spatial_bym2()/spatial_car_proper()withlevel = "group"now accept datasets that cover only a subset of adjacency cells. Closes gcol33/tulpa#25. The Besag / ICAR / BYM2 / proper-CAR field is well-defined on every node of the graph regardless of whether each node has an observation; unobserved cells simply contribute no likelihood term (matching INLA’sf(cell, model = "besag", graph = g)).validate_spatial()andprior_from_spec()now resolvegroup_varto 1-based adjacency row indices via a new.resolve_spatial_idx()helper:- integer / numeric
group_var-> 1-based row indices, validated against[1, n_spatial_units]. - character / factor
group_varwithrownames(adjacency)set -> matched by name (preserves cell identity for sparse subsets). - character / factor
group_varwithout rownames -> legacyas.integer(as.factor(.)), retained for back-compat; errors with an actionable message when level count differs from adjacency size.
- integer / numeric
refactor(joint-laplace): unify single-block and multi-block joint dispatch (Phase J-E).
tulpa_nested_laplace_joint()’s single-block path (prior = list(type = "bym2"/"icar"/"car_proper", ...)) now packs the prior into a length-1blocks_specand dispatches through the samecpp_nested_laplace_joint_multientry that drives the list-of-blocks API. The three legacycpp_nested_laplace_joint_bym2/_icar/_car_properR-facing wrappers (524 lines insrc/nested_laplace_joint.cpp) are deleted; the inner Newton driver (run_multi_block_nested_laplace_joint) was already shared, so the refactor is a routing change with bit-identical log_marginal on every joint test (147/147 pass). User-facing R API and result shape are unchanged. C ABI: thetulpa_nested_laplace_joint_bym2shim (R_RegisterCCallableentry intulpa_shims.cpp) is removed; external embedders should calltulpa_nested_laplace_joint()from R or build a shim on top ofcpp_nested_laplace_joint_multi.TULPA_ABI_VERSIONbumped 19 → 20.fix: joint nested-Laplace reparam
(sigma, alpha)→(sigma_occ, sigma_pos). Closes gcol33/tulpa#18. The BYM2 / ICAR / CAR_proper backends oftulpa_nested_laplace_joint()previously parameterized the joint outer grid as(sigma, rho/rho_car, alpha), wheresigmawas the shared field amplitude andalphascaled the copy arm’s contribution. At smalln_posand low cover-arm sample fraction (e.g. d7 Cell B,n_s = 25,n_pos ≈ 46), the cover-arm likelihood identified only the productalpha * sigma; sigma was pulled toward its prior and alpha inflated to compensate (~ −15% sigma bias, ~ +27% alpha bias on 30 seeds). The reparam scales each arm’s contribution to a unit-precision latent by its own sigma —eta_arm = X beta + sigma_arm * z_s, withsigma_arm = sigma_occon donor arms andsigma_arm = sigma_poson the copy arm. Each axis is now anchored by its own arm’s likelihood, so the posterior ridge along constantalpha * sigmadisappears.alpha = sigma_pos / sigma_occis recovered post-hoc and attached totheta_grid/theta_mean/theta_sd. API:copy$alpha_gridis superseded bycopy$sigma_pos_grid;alpha_gridstill works with a deprecation warning that translates it toalpha_grid * median(prior$sigma_grid). ICAR / CAR_proper joint kernels now takesigma_grid(donor sigma in sigma-space) instead oftau_grid; tulpaObs callers translatetau_gridtosigma_grid = 1/sqrt(tau_grid)internally. C ABI:tulpa_nested_laplace_joint_bym2_implswitches its grid args fromsigma_spatial_grid/alpha_gridtosigma_occ_grid/sigma_pos_grid.TULPA_ABI_VERSIONbumped 18 → 19.feat: EM+Laplace MI and Gibbs corrections.
tulpa_em_laplace()gains two post-EM correction modes (correction = "mi"/"gibbs") that replace the previous “not yet implemented” stub. MI drawsn_imputationshardz’s from the converged posterior weightsP(z|y, theta_hat), refits each block on the hard draws, and pools per-submodel coefficients viarubins_pool(). Gibbs runs a warm-startedz|theta -> theta|zMarkov chain of lengthn_gibbsstarting from the EM fits — every step refreshes weights via the user’se_step, draws hard z, refits — and pools the chain via Rubin’s rules. The fixed-effect(beta, se)extraction is now a shared helper (.attach_beta_se) consumed by both the new corrections and the existingtulpa_em_mc()MCEM driver, so there is one source of truth for “Laplace fit -> Rubin pool input”. Bernoulli is the default per-observation hard-z draw; multi-class latent structures supply their own via the newdraw_zcallback. Return shape gainscorrection,pooled, anddrawsfields when a correction is requested. No ABI bump (R-side only); closesTODO.mdP3.8.feat: ABI v14 — SPDE nested-Laplace upgraded to the v10-style universal shim (store_modes, store_Q, paired range/sigma grids, formula-side iid-RE block). Replaces the v0
cpp_nested_laplace_spdeentry and folds the SPDE C-callable into the sharedNestedLaplaceShimResultblock used by ICAR / BYM2 / NNGP / HSGP. The dedicatedSpdeNestedLaplaceShimResultstruct is removed. Latent layout:[beta (p)] [re (n_re_groups)] [w_mesh (n_mesh)].TULPA_ABI_VERSIONbumped 13 → 14; downstream packages must rebuild.
tulpa 2026-05-13 — ABI v13: Phase D — delete legacy ratio path
Closes the tulpaRatio migration tracker (gcol33/tulpa#15). After v12 gated the legacy ratio body of compute_log_post_impl<T> behind a generic-layout check, Phase D removes the body itself and every consumer of LegacyRatioData / LegacyRatioLayout.
-
TULPA_ABI_VERSIONbumped 12 → 13. Downstream packages must rebuild against the v13 headers. -
Removed exported types.
ModelData::LegacyRatioData legacy(inst/include/tulpa/model_data.h) andParamLayout::LegacyRatioLayout legacy(inst/include/tulpa/param_layout.h) are gone.n_processes > 0with a non-nulldata.likelihood_specis now the only supported configuration. -
Removed Rcpp entry points (D-1):
cpp_hmc_fit,cpp_hmc_fit_gp,cpp_hmc_fit_gp_v2,cpp_ess_fit,cpp_ess_get_n_params,cpp_vi_fit,cpp_vi_get_n_params,cpp_sghmc_fit,cpp_sgld_fit,cpp_compute_log_post_test,cpp_compute_log_prior_test,cpp_compute_log_lik_only_test,cpp_log_post_split_n_params. Internal samplers (run_ess_sampler,run_sghmc_sampler,fit_vi,run_mclmc_sampler) and their C-callable shims (tulpa_run_ess_sampler,tulpa_sghmc_fit,tulpa_fit_vi,tulpa_mclmc_fit) remain — downstream packages reach them via the generic ModelData/ParamLayout API. Dev toolstools/icar_collapsed_check.Randtools/bym2_gradient_check.Ralso removed. -
Removed dispatcher branches (D-2).
resolve_gradient_fn(src/hmc_gradient_dispatch.h) now only resolves the genericspec->gradient_fn/compute_gradient_generic_arena/compute_gradient_generic_numericalpaths. Mode overrides (AUTODIFF_TAPE,AUTODIFF_ARENA,AUTODIFF_FWD) and the H-mode specialized fallthroughs are gone. Callers reaching the dispatcher withn_processes == 0getRcpp::stopwith a pointer to this entry.hmc_gradient_dispatch_predicates.hdeleted. -
Removed log-posterior orchestrators’ legacy body (D-2).
compute_log_post,compute_log_prior,compute_log_lik_only(src/hmc_sampler.cpp) now forward tocompute_log_post_generic_spec_double; theaccumulate_log_prior_and_state/accumulate_obs_log_likbody and its 5hmc_sampler_log_prior_*.hfragments are gone, along withhmc_log_posterior_split.h.compute_log_post_impl<T>(src/log_post_impl.h) reduces to the same forward forT = doubleand a defensiveT(0)no-op for autodiffT(arena AD now routes throughcompute_log_post_generic<Var>). -
Removed gradient kernels (D-3, ~30 files, ~17 KLOC). All hand-coded H-mode kernels (composite + 4 phases, vectorized + 5 fragments, analytical, autodiff, feature, gp, hsgp, msgp, svc, tvc, st, temporal_gp, ms_temporal, latent), the collapsed-spatial machinery (
hmc_icar_collapsed_*×9,hmc_gp_collapsed_*×5), the legacy ratio likelihood (hmc_likelihood.h,hmc_observation_likelihood.h), and the legacy fallback gradients (compute_gradient_numerical/_autodiff/_arena/_forward/_numerical_impl) are deleted. The 6log_post_impl_*_block.hfragments and the 2 Rcpp ModelData populators (model_data_rcpp.h,hmc_modeldata_builders.h) follow.verify_gradient_runtimenow always usescompute_gradient_generic_numericalas the reference. -
Simplified samplers (D-4).
compute_param_layout(src/hmc_param_layout.cpp) requiresn_processes > 0; model packages place model-specific scalars (overdispersion etc.) in the LikelihoodSpec extra-parameter block atlayout.extra_offset. ESS’sbuild_gaussian_priorsandget_non_gaussian_params(src/ess_sampler.h) walkprocess_beta_startandextra_offsetonly.hmc_nuts_mass_init.cppdrops the family-specific block-spec heuristics (NB+ICAR / Bin+ICAR forced DENSE, NB phi-pair 2×2 block) — re-introducing them would need a LikelihoodSpec hint. -
ST_IV mass-matrix override disabled. The precision-informed diagonal mass setup at warmup end (
src/hmc_nuts_chain_iter_nuts.h) reconstructedetafromdata.legacy.X_num_flatand branched on the legacyModelType. ST_IV chains now fall back to the adapted DIAG mass matrix until the override is re-expressed throughspec->eta_weights_fn. One no-op per chain at warmup end; practical impact on sampling efficiency is small. -
Removed skipped tests.
tests/testthat/test-log-post-split.Randtests/testthat/test-hmc-modeldata-builders.Rare deleted (every test was a Phase-D skip). The legacy-ratio gradient-check test intest-spatial-car-proper.Ris removed; the two R-sidespatial_car_proper()construction tests stay. -
Cumulative numbers. Phase D-1..D-5 deletes ~57 files and ~18 000 lines of legacy ratio infrastructure across
src/,inst/include/tulpa/,tests/, andtools/. Net code reduction before the v13 maintenance window starts.
Downstream rebuild notes: * tulpaRatio already routes through the generic LikelihoodSpec path via tulpa_bridge.cpp + per-family payloads in lik_specs/ (B1+B2 of the migration); rebuild against v13 headers, no logic changes needed. * tulpaObs never used the legacy ratio path; rebuild against v13. * tulpaGlmm Day-22+ already targets the generic path; rebuild against v13.
tulpa 2026-05-12 — ABI v12: generic-layout safety in compute_log_post_impl + ESS port
-
TULPA_ABI_VERSIONbumped 11 → 12. Downstream packages must rebuild against the v12 headers. -
Critical fix.
compute_log_post_impl<T>(src/log_post_impl.h) now early-returns tocompute_log_post_generic_spec_doublewhen the caller builtModelDatawithn_processes > 0and a non-nulllikelihood_spec. Previously the function reached lines 83-84 and unconditionally readparams[layout.legacy.beta_num_start], which isparams[-1]for generic-layout callers — segfault. This was the blocker for tulpaGlmm Day-22inference = "ess"(see deferredfix.mdentry from 2026-05-06). The early-return makes the function safe for both layouts; the legacy ratio body remains in place forn_processes == 0callers (i.e. nobody outside this file at the moment, but tulpaRatio’shmc_sampler.cppkeeps its own copy). -
ESS generic-layout port.
tulpa_ess::build_gaussian_priors(src/ess_sampler.h) now walks every process’s β block (layout.process_beta_start[k]forkin0..n_processes) whendata.n_processes > 0, instead of onlylayout.legacy.beta_num_start / beta_denom_start. Previously generic-layout ESS produced an empty β prior block and β was never sampled. -
ESS RWMH coverage of model-specific extras.
tulpa_ess::get_non_gaussian_paramsnow appends every parameter in[layout.extra_offset, layout.extra_offset + n_extra_params)to the RWMH list. LikelihoodSpec authors pack their model-specific scalars (e.g. log_phi for negative-binomial, log_sigma for Gaussian) into that block; ESS now walks them. Legacy ratiolog_phi_num / log_phi_denomindices remain in the list forn_processes == 0. -
LegacyRatioData/LegacyRatioLayout(inst/include/tulpa/model_data.h,param_layout.h) are still exported but stay deprecated — the in-engine consumers are the H-mode gradient kernels, the legacy AD fallback (hmc_gradient_fallback.cpp), the composite gradient, andtulpa_hmc::compute_log_postinsidehmc_sampler.cpp. None of those are reached when the dispatcher (hmc_gradient_dispatch.h) seesn_processes > 0. Full removal is a follow-up cut after the collapsed-spatial double-evaluator (MCLMC / SGHMC consumer) is reworked. - Downstream rebuild notes: tulpaRatio uses the generic-LikelihoodSpec path via
tulpa_bridge.cpp+ per-family payloads inlik_specs/; it never touchedModelData::legacyand rebuilds cleanly against v12 headers. tulpaGlmm Day-22 ESS shim can now calltulpa::get_ess_fn()(...)end-to-end on a generic-layoutModelDatawithout segfaulting.
tulpa 2026-05-12 — ABI v11: caller-supplied inv-mass diagonal for NUTS
-
TULPA_ABI_VERSIONbumped 10 → 11. Downstream packages must rebuild against the v11 headers. - Registered C-callable
tulpa_run_nuts_generic(nuts_api.hNUTSFn) gains a new positional parameterconst double* inv_metric_diagimmediately beforeNUTSResult* result_out. Passnullptrto keep the v10 behaviour (default structural warm-start of the mass matrix). Pass a length-n_paramsvector to seed the diagonal inverse-mass — useful for warm-starting NUTS from an analytical-approximation method (Laplace, VI, etc.). -
run_hmc_chain_cpp/run_hmc_chain(hmc_sampler_funcs.h) take a matching trailinginv_metric_initstd::vector<double>(default empty). Withinrun_hmc_chain_cpp(hmc_nuts_chain_setup.h), a non-empty caller diagonal overrides the structural diagonal set bywarm_start_mass_matrix. Values are clamped to[1e-3, 1e3]before being installed viamass.set_diagonal, thenfind_reasonable_epsilonre-runs against the seeded metric. - Mass-matrix adaptation is unchanged: the standard dual-averaging
- expanding-windows path still refines the diagonal during warmup. The caller’s vector is the initial metric, not a frozen one.
- Internal callers of
run_hmc_chain_cpp(hmc_nuts_parallel.cpp×3,tulpa_generic_sampler.cpp×1) continue to pass noinv_metric_initvia the default empty vector; the local forward declaration intulpa_generic_sampler.cppwas updated to match the canonical declaration’s parameter list. - Downstream rebuild notes:
tulpaGlmmexercises this end-to-end via Day-32’shmc_warm_start = "laplace"argument.tulpaObsandtulpaRationeed to be reinstalled against v11 — both already updated to passnullptrfor the new parameter (no logic change).
tulpa 2026-05-11 — NNGP Laplace: full off-diagonal precision scatter
-
laplace_mode_gp(and the spatial-only / ST-combo NNGP entries innested_laplace.cpp) now assemble the full NNGP precision matrixΛ = (I - A)' D⁻¹ (I - A)in every Newton iteration, replacing the diagonal-on-w approximation that only kept1/v_ion the focal diagonal of each row. - What was missed before: the gradient contribution to neighbours (
+a_{i,k}·q_i/v_i), the off-diagonal Hessian entries (focal, neighbour_k) and (neighbour_k, neighbour_kp), and the pairwise precision between members of every conditioning set. The Newton mode forwwas therefore shrunk toward zero and pointwise field recovery on smooth latent fields collapsed (cor ≈ 0). - New helpers in
gpu_nngp_laplace.h:-
batch_nngp_scatter(..., alpha_out = nullptr)— backward-compatible extra optional output capturing the per-row conditional regression weights (already computed internally; just exposed). -
apply_nngp_full_prior_dense— scatters the full precision contribution into a dense(grad, H)pair via the alpha + cv bundle. -
apply_nngp_full_prior_sparse— same, into aSparseHessianBuilder. -
make_nngp_prior_sparsity_pattern— emits the(row, col)pairs required to back the sparse path.
-
- Wired into the four scatter call sites:
laplace_mode_gpdense Newton,laplace_mode_gpsparse Newton (with pattern expansion), the spatial-onlycpp_nested_laplace_nngpscatter lambda, andmake_nngp_spatial_ops::add_prior_at_k(the ST-combo NNGP block). Log-prior calls (log_priorlambdas) are unchanged — they only needcmandcv, and the existingbatch_nngp_scattersignature still supports that withoutalpha_out. - Effect (downstream measurement from tulpaGlmm Day-31 smoke):
cor(w_mean, f_true)jumps from near zero to ≈ 0.81 Pearson on a 120-location Poisson + smooth-GP simulation. β recovery unchanged. - Additive: no
TULPA_ABI_VERSIONbump (still v10). Public shim signatures are unchanged; only the inner Laplace scatter is upgraded. Downstream packages must rebuild against this commit to pick up the new behaviour (no source changes required).
tulpa 2026-05-11 — nested-Laplace ST family: 5 more indexed × indexed combos
- Adds five additional joint spatial × temporal nested-Laplace shims, built on the same
run_two_indexed_nested_laplacedriver and joint inner Newton introduced earlier today:tulpa_nested_laplace_st_icar_rw1tulpa_nested_laplace_st_icar_rw2tulpa_nested_laplace_st_car_proper_rw1tulpa_nested_laplace_st_car_proper_rw2-
tulpa_nested_laplace_st_car_proper_ar1Each routes through a per-combo Rcpp entry plus a C-callable_implwrapper; matching typedefs + getters live innested_laplace_api.h.
- New internal factory pattern
IndexedPriorOpsinnested_laplace.cppwith per-kind buildersmake_icar_ops,make_car_proper_ops,make_rw1_ops,make_rw2_ops,make_ar1_ops. The sharedrun_two_indexed_nested_laplacedriver now consumesstd::function-typed callbacks, so adding the next indexed × indexed combination is a few lines of Rcpp glue rather than a re-derivation. - Refactored
cpp_nested_laplace_st_icar_ar1to use the new factories (identical behavior; just dropped the inline lambdas). - Additive: no
TULPA_ABI_VERSIONbump (still v9). The new shims are resolved viaR_GetCCallableat first use; downstream packages rebuilt against ABI v9 pick them up automatically.
tulpa 2026-05-11 — nested-Laplace joint spatial × temporal (ICAR × AR1)
- New shim
tulpa_nested_laplace_st_icar_ar1for joint nested-Laplace inference with an ICAR spatial field AND an AR1 temporal field in the same fit. The joint inner Newton solves over the full latent vector[beta] [re] [w_spatial (n_s)] [w_temporal (n_t)]at each grid point; the cross-blockH[w_s, w_t]is non-zero, so the two fields cannot be Laplace-marginalized separately. The hyperparameter grid is supplied caller-side as paired vectors of lengthn_grid(Cartesian product ofτ_spatial × τ_temporal × ρ_temporalbuilt on the R side). - New C-callable typedef
NestedLaplaceStIcarAr1Fn+ getterget_nested_laplace_st_icar_ar1_fn()innested_laplace_api.h. - New internal building blocks in
nested_laplace.cpp: the templatedrun_two_indexed_nested_laplacedriver and helpersnl_compute_eta_two_indexed/nl_scatter_obs_two_indexed. These are the shared substrate for the remaining 11 (spatial_kind × temporal_kind) combinations. -
TULPA_ABI_VERSIONbumped 8 → 9. Downstream packages must be rebuilt against this header set.
tulpa 2026-05-11 — nested-Laplace HSGP returns modes + store_Q
-
tulpa_nested_laplace_hsgpnow setsstore_modes = 1(was 0) and gained astore_Qflag matching the rest of the nested-Laplace family. The basis-coefficient latent[beta] [re] [beta_M (n_basis)]is returned per(σ², ℓ)grid point, and withstore_Q = 1the joint Q at the mode is retained in the standardNestedLaplaceShimResult::Q_*_flatslots. -
cpp_nested_laplace_hsgpgained a trailingbool store_Q = falseargument and now passesstore_modes = trueto the grid driver. The C-callabletulpa_nested_laplace_hsgp_implsignature picks up the matchingint store_Qparameter; the public typedefNestedLaplaceHsgpFninnested_laplace_api.his updated to match. - This unblocks HSGP mixture-of-MVN sampling in tulpaGlmm — the observation-level spatial effect
f_i = Σ_j Φ_ij · √S(λ_j; σ²_k, ℓ_k) · β_M_jcan be reconstructed caller-side from modes + posterior draws over the basis coefficients plus the per-draw grid index. -
TULPA_ABI_VERSIONbumped 7 → 8. Downstream packages (tulpaGlmm, tulpaObs) must be rebuilt against the updated headers.
tulpa 2026-05-11 — nested-Laplace BYM2 returns modes + store_Q
-
tulpa_nested_laplace_bym2now setsstore_modes = 1(was 0) and gained astore_Qflag matching the rest of the nested-Laplace family. The reparameterised latent[beta] [re] [phi (n_spatial)] [theta (n_spatial)]is returned per grid point, and withstore_Q = 1the joint Q at the mode is retained in the standardNestedLaplaceShimResult::Q_*_flatslots. -
cpp_nested_laplace_bym2gained a trailingbool store_Q = falseargument and now passesstore_modes = trueto the grid driver. The C-callabletulpa_nested_laplace_bym2_implsignature picks up the matchingint store_Qparameter; the public typedefNestedLaplaceBym2Fninnested_laplace_api.his updated to match. - This unblocks BYM2 mixture-of-MVN sampling in tulpaGlmm — the total spatial effect
w_s = σ·(√ρ · scale · φ_s + √(1−ρ) · θ_s)can be reconstructed caller-side from modes + posterior draws over the (σ, ρ) grid. -
TULPA_ABI_VERSIONbumped 6 → 7. Downstream packages (tulpaGlmm, tulpaObs) must be rebuilt against the updated headers.
tulpa 2026-05-11 — nested-Laplace store_Q on RW1/RW2/AR1/CAR_proper
-
tulpa_nested_laplace_rw1,tulpa_nested_laplace_rw2,tulpa_nested_laplace_ar1, andtulpa_nested_laplace_car_propernow accept astore_Qflag (matching the ICAR shim added in v5). When set the shim retains the joint negative-Hessian Q at each grid point’s mode inNestedLaplaceShimResult::Q_*_flat, so downstream packages can draw mixture-of-MVN posteriorssum_k w_k · N(mode_k, Q_k^{-1})without re-doing the Newton assembly R-side. - The underlying
cpp_nested_laplace_<rw1|rw2|ar1|car_proper>entries gained a trailingbool store_Q = falseargument. Default isfalse, so existing callers that don’t ask for Q keep the previous behaviour and footprint. -
TULPA_ABI_VERSIONbumped 5 → 6. Downstream packages (tulpaGlmm, tulpaObs) must be rebuilt against the updated headers.
tulpa 2026-05-06 — Takahashi partial inverse as a registered C-callable
- New free function
tulpa::takahashi_partial_inverse_dense(n, Lp, Li, Lx, Z_out)insparse_cholesky.{h,cpp}runs the Takahashi recursion on a caller-supplied lower-triangularL(CSC) and writes a dense column-majorn*nZwithQ^{-1}onpattern(L + L^T)and zeros elsewhere. A matchingtakahashi_partial_inverse_cscreturns just theZxvalues on pattern(L). The existingSparseCholeskySolver::selected_inversion_diagonalnow routes through the new helper so there is one source of truth for the recursion (no copy-paste). - Registered C-callable
tulpa_takahashi_partial_inverse_denseexposes the pure-function variant to downstream packages. Resolved viatulpa::get_takahashi_partial_inverse_dense_fn()ininst/include/tulpa/sparse_solver_api.h; the getterRf_errors if the symbol is missing (i.e. caller built against newer headers than the loaded tulpa). - No struct layout changes;
TULPA_ABI_VERSIONstays at 4. Downstream packages that want the new shim need only rebuild against the updatedsparse_solver_api.h.
tulpa 2026-05-05 — multi-term + slope REs on the spec-Laplace path
-
tulpa_laplace_spec_dense(and its public C ABI shim) now accepts the full multi-term, multi-coefficient RE structure populated bypopulate_rein downstream model packages: K =data.n_re_termsrandom-effect terms, each withq_t = re_n_coefs[t]coefficients per group (intercept-only whenq_t == 1, intercept + slopes whenq_t > 1), uncorrelated ((x||g)) or correlated ((x|g)) prior covariance. Per-process sharing is uniform across terms viadata.sharing.re. - The legacy single-term path (
layout.re_start/layout.re_end/log_sigma_re_idxwithdata.n_re_terms == 0) is preserved and stays bit-identical numerically. - The
result_out->modewriteout fromtulpa_laplace_spec_dense_implnow concatenates every RE term’s block in term order (re_start_multi[t]..re_end_multi[t]), matching the newSpecLatentLayoutordering. - New internal test fixture
cpp_laplace_spec_test_multi_reexercises the multi-term path end-to-end against hand-derived linear-Gaussian reference solutions; new tests intests/testthat/test-laplace-spec.Rcover (a) two crossed intercept-only terms, (b) one correlated random slope, (c) one uncorrelated random slope. -
TULPA_ABI_VERSIONbumped from 3 → 4. Downstream packages that ship with the spec-Laplace dispatcher (e.g. tulpaGlmm) must be rebuilt against the new headers.