diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 729b6d2b..f9194e28 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -102,6 +102,7 @@ boundaries above remain the target modular MSA architecture. | `compute_backend` | VRAM-budgeted streamed planning, executable OOM retry plans, and a compensated CPU `f64` reference | | `episode_membership` | episode membership cannot escape the episode event-time interval | | `membership_target` | language, episode, template, department, and opportunity-pool targets cannot collapse into entity or project | +| `psychometric_core` | posterior-aware structural input gates, CWC within/between OLS plus the contextual effect, event-time log-rate, unequal-interval discrete-lag remapping, constant-predictor discrete effect, time-varying-predictor discrete effect (Eq. 14), exact scalar discrete process noise (Driver et al., 2017, Eq. 3), lagged latent covariance and unconditional latent variance (Driver et al., 2017, Eq. 3–4), stationary within-subject variance (Driver et al., 2017, Eq. 4 as `Δt → ∞`; `asymDIFFUSION`), trait-plus-state variance (Driver et al., 2017, §4.3 `TRAITVAR`; not process noise), observed-indicator variance and lagged observed covariance (Driver et al., 2017, Eq. 5; Table 2 `MANIFESTVAR` is `Θ`, not `Var(y)`; `MANIFESTTRAITVAR` is not `MANIFESTVAR`; `Θ` does not enter lagged observed covariance; observed-indicator mean is `τ + λ μ`; `MANIFESTMEANS` is not `E(y)`; `CINT` is not `MANIFESTMEANS`; discrete latent mean is `exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ`; `T0MEANS` is not `μ_t`; `CINT` is not the discrete increment; evolved observed mean is `τ + λ μ_t`; `τ + λ μ_0` is not `E(y_t)`; contemporaneous `TDPREDEFFECT` impulse is `m x`, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; Eq. 5 of that contemporaneous impulse is `τ + λ(μ_t + m x)`, and `τ + λ μ_t` is not that observed mean; time-independent `TIPREDEFFECT` increment is `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, not `M x`, not Voelkle Eq. 14, and not the coefficient `B`; Eq. 5 of that increment is `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that observed mean; `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t`; within-interval `TDPREDEFFECT` carry is `e^{A(t−u)} M x` for `t0 < u < t`, not the contemporaneous Dirac, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; Eq. 5 of that carry is `τ + λ(μ_t + e^{a(t−u)} m x)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that carried observed mean when `u ≠ t`; first-occasion `T0TIPREDEFFECT` shift is `t0_b z` and Eq. 3 first-summand carry is `e^{A Δt} t0_b z` (`T0TIPREDEFFECT` is not `TIPREDEFFECT` `B`; `t0_b z` is not `A^{-1}[e^{A Δt} − I] B z`; `e^{A Δt} t0_b z` is not `t0_b z`; Eq. 5 of that carry is `τ + λ(μ_t + e^{a Δt} t0_b z)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not that observed mean), first-occasion `T0TDPREDEFFECT` shift is `t0_m x0` and Eq. 3 first-summand carry is `e^{A Δt} t0_m x0` (`T0TDPREDEFFECT` is not `TDPREDEFFECT` `M`; `t0_m x0` is not `M x`; `e^{A Δt} t0_m x0` is not `t0_m x0`; `e^{A Δt} t0_m x0` is not `e^{A(t−u)} M x` for `t0 < u < t`; `t0_m x0` is not `t0_b z`; an impulse at `u ≤ t0` that used `M` is already in `η(t0)` as `TDPREDEFFECT`, not as `T0TDPREDEFFECT`; Eq. 5 of that carry is `τ + λ(μ_t + e^{a Δt} t0_m x0)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not that observed mean; `τ + λ(μ_t + e^{a Δt} t0_b z)` is not that observed mean; §7.2 level-change `CINT` is `κ = −a m x` with `a < 0` so `−κ / a = m x` (`−a m x` is not the dissipating Dirac, not a free `CINT`, not `TIPREDEFFECT`, and not the extra near-zero-drift latent process also named in §7.2; Eq. 3 of that setting is `(1 − e^{a Δt}) m x`, which is not `m x`, not `κ`, and not `TIPREDEFFECT`; §7.2 extra-process contribution is `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` (`ε = a` is `a_{ηξ} x Δt e^{a Δt}`; identification `TDPREDEFFECT` on the extra process is 1; printed extra `DRIFT` is `−0.000001`; not `κ = −a m x`, not `(1 − e^{a Δt}) m x`, and not the dissipating Dirac `m x`; `ε ≥ 0` fails closed; Eq. 5 of that contribution is `τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)`; the extra process has `LAMBDA` 0 and is not an observed indicator; `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that observed mean; the contribution is not `E(y_t)`; the evolved-plus-contribution latent mean is not `E(y_t)`; after-t0 extra-process `TDPREDEFFECT` is `a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a)` for `t0 < u < t` while `μ_t` uses `Δt`; Eq. 5 of that after-t0 contribution is `τ + λ(μ_t + a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a)`; the first-occasion extra-process observed mean is not that observed mean when `u ≠ t0`; `e^{a(t−u)} m x` is a Dirac on the original process, not this `DRIFT` drive; §7.2 `asymTIPREDEFFECT` is `-B z / a` for `a < 0` (`-B z / a` is not the coefficient `B`, not `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, and not `M x`; §7.2 `addedTIPREDVAR` is `(B / a)² v`, not `TRAITVAR`, not `asymDIFFUSION`, and not `-B z / a`; Table 2 `asymCINT` is `-κ / a` for `a < 0` and is not `κ`, not `A^{-1}[e^{A Δt} − I] κ`, not `T0MEANS`, and not `-B z / a`; p. 16 stationary `T0MEANS` is `-κ / a + −B z / a` and is not free `T0MEANS`, not `asymCINT` alone, not `asymTIPREDEFFECT` alone, and not the finite-interval discrete latent mean; Eq. 5 of that constrained mean is `τ + λ(−κ / a + −B z / a)`; `τ + λ μ_0` is not that observed mean; `τ + λ(−κ / a)` is not that observed mean when `B z ≠ 0`; `τ + λ μ_t` is not that observed mean; `MANIFESTMEANS` is not `E(y_0)`; the constrained latent mean is not `E(y_0)`; stationary `T0VAR` is `trait + −q / (2 a) + (B / a)² v` (not free `T0VAR`, not `asymDIFFUSION` alone, not `TRAITVAR` alone, not `addedTIPREDVAR` alone, and not the finite-interval discrete latent variance. Eq. 5 of that constrained variance is `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` (JSS PDF re-opened 2026-08-22T03:20Z; form the stationary latent variance first, then `λ² p + θ + ψ`; `λ² p_0` is not that observed variance; `λ²(−q / (2 a)) + θ` is not that observed variance when `TRAITVAR` or `addedTIPREDVAR` is nonzero; `MANIFESTVAR` is not `Var(y_0)`; the constrained latent variance is not `Var(y_0)`); lagged stationary `T0VAR` is `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` (trait and `addedTIPREDVAR` do not decay; contemporaneous `T0VAR` is not that lagged map; decaying the constrained total as if it were all state is not that lagged map; Eq. 5 of that lagged covariance is `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ`; `Θ` does not enter; contemporaneous `Var(y_0)` is not that lagged observed covariance; the lagged latent covariance is not that observed covariance); later-occasion stationary `T0VAR` is `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v` (trait and `addedTIPREDVAR` do not enter `Q_Δt`; under stationarity that composition equals contemporaneous `T0VAR`; evolving the constrained total as if it were all state is not that later map; the lagged covariance omits `Q_Δt`; `Q_Δt` is not that later map; Eq. 5 of that later-occasion variance is `λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ`; lagged observed covariance omits `Q_Δt` and `θ`; `MANIFESTVAR` is not `Var(y_t)`; the later-occasion latent variance is not `Var(y_t)`))), irregular already-centered residual lag, Rubin `T` on OLS loadings, and strong-gated latent means (two-observation residual variance is identically `0` and caps at strong/scalar; Putnick & Bornstein, 2016) | Foundation crates expose only tested contracts. Empty façades are not public diff --git a/CHANGELOG.md b/CHANGELOG.md index 20b7289a..132ea0a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,65 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] +- Branch coverage JSON now unique-folds `files[].branches` True/False counts across instantiations. Nightly totals on #49 head `1e3e2eb` reported `event_time.rs` 505/506 while every unique site had both arms taken (253 sites × 2 instantiations). Summary-only reports without branch arrays still fail closed on totals. The 100% contract is unique production arms, matching the LCOV authored-line gate. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. +- `psychometric_core` maps overflowing `expm1(a Δt)` / `expm1(2 a Δt)` in `recover_discrete_constant_predictor_effect` and `recover_discrete_process_noise` through the log-space rewrite without a redundant `if !argument.is_finite()` after overflow. Local crate llvm-cov on #49 head `559e7b399473ee90ba3234677dd9ef7f05f7fd2e` was 509/510: the same LLVM `exp`/`expm1` finite-argument proof as L768/L5040. Existing rewrite (`a = 800` / `a = 400`) and overflow (`a = 1e308`) tests remain the contract. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. +- `psychometric_core` maps overflowing `e^{a Δt}` / `e^{a(t−u)}` through the log-space rewrite without redundant `if !argument.is_finite()` after `exp` overflow on lagged covariance, T0 TI/TD carry, and impulse carry. Nightly branch coverage on #49 head `7e669babcc54408dd8407bbac56be0f304fa99e5` was 1713/1714: LLVM counted `event_time.rs` L5040 True and treated the finite-argument overflow False as uncovered after proving `exp` of a finite argument is finite, which binary64 overflow falsifies. `fit_scalar_log_rate` now also skips a zero earlier residual and a negative lag while still recovering from a valid pair. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. +- `psychometric_core` maps overflowing `e^{2 a Δt}` in `recover_discrete_latent_variance` through the log-space rewrite `(ln p + 2 a Δt).exp()` without a redundant `if !2 a Δt.is_finite()` after `exp` overflow. Nightly branch coverage on #49 head `e301e9706c0bd671ccad533063fb624cc568d0b3` was 1715/1716: LLVM counted `event_time.rs` L768 True and treated the finite-argument overflow False as uncovered after proving `exp` of a finite argument is finite, which binary64 overflow falsifies. Existing rewrite (`p = 1e-308`, `a = 400`, `Δt = 1`) and overflow (`a = 1e308`) tests remain the contract. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. +- `psychometric_core` executes the later `|| !log_rate.is_finite()` operand on `recover_discrete_time_independent_predictor_effect` from both the lib tests and the multilevel integration crate. Nightly branch coverage on #49 head `90b08bbe82cbe7776365a6c04df38857dfe5e53c` was 1714/1716: both True arms at `event_time.rs` L2480 were unhit because fail-closed tests supplied a non-finite `TIPREDEFFECT` or predictor before `a`. Direct `a = NaN` now takes those arms. `LagClock::as_str` is called through `black_box` so the outlined instantiation is not const-folded away. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. +- `psychometric_core` evaluates the Driver, Oud, and Voelkle (2017, §7.2) extra-process lag as `e^{ε Δt}` even when `ε Δt` underflows to `0` (`exp(0) = 1`). Nightly branch coverage on #49 head `22b8e68813ad59a9a91689bacfa4cf033dfad158` was 1718/1720: LLVM deleted `if extra_argument == 0.0` / `original_argument == 0.0` True after proving `ε < 0` and `Δt > 0` imply a nonzero product, which binary64 underflow falsifies. The public map now uses `exp` directly; `original_log_rate == 0` remains the Brownian `e^{0} = 1` path. Recovery tests assert the §7.2 identity `a_{ηξ} x e^{a Δt}(e^{(ε−a)Δt} − 1)/(ε − a)` on `(-min_subnormal) * 1e-320`. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. +- `psychometric_core` integration tests now execute the Driver, Oud, and Voelkle (2017, Eq. 3 first-summand carry) overflow rewrite of Table 3 `T0TIPREDEFFECT` / `T0TDPREDEFFECT` (`sign(t0_b z) exp(ln|t0_b z| + a Δt)` and the same form for `t0_m x0`). Nightly branch coverage on #49 head `d634f5849ed8e9f75af1b43c2e59d8e7d6301b45` was 1718/1720: the two missing records were the unused non-`cfg(test)` instantiations of `if !drift_interval.is_finite()` at the T0 TI and T0 TD carry overflow rewrites (`event_time.rs` L4245 and L4628). Lib tests already covered both sides; integration tests now take overflowing `a Δt` (`1e308 * 2`) and finite-`a Δt` overflowed `exp` (`710`) on those public maps. Meredith (1993) remains unread (Unpaywall 2026-08-22T23:12Z: `is_oa: false`; title *Measurement Invariance, Factor Analysis and Factorial Invariance*). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread (Unpaywall 2026-08-22T23:12Z: `is_oa: false`; title *Randomization-Based Inference about Latent Variables from Complex Samples*). Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, §4.3, pp. 9–10; Eq. 3–5, pp. 4–5; Table 2, p. 12; p. 16; §7.2, pp. 20–21; JSS PDF re-opened 2026-08-22T23:12Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar later-occasion variance of §4.3 stationary `T0VAR`. Section 4.3 constrains first-occasion variance according to the model-predicted variances across all time points. Equation 3 writes `η(t) = exp(A Δt) η(t0) + … +` the stochastic integral. Equation 4 writes that the integral exhibits covariance `Q_Δt`. The law of total variance on the within-subject state is `e^{2 a Δt}(−q / (2 a)) + Q_Δt`. Trait variance and `addedTIPREDVAR` are time-invariant between-subject and do not enter that process-noise integral. The later-occasion composition is `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v`. Form the evolved within-subject variance first, then include the trait, then include the TI extra variance, then add. Under stationarity that composition equals contemporaneous `T0VAR`. Evolving the constrained total as if it were all state (`e^{2 a Δt} p_stat + Q_Δt`) is not this map. The lagged covariance `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` omits `Q_Δt` and is not this map. `Q_Δt` is not this map. The interval must be event time and strictly positive. Equation 5 of that later-occasion variance is `λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ`. The lagged observed covariance omits `Q_Δt` and `θ`. `MANIFESTVAR` is not that later-occasion observed variance. The later-occasion latent variance is not the later-occasion observed variance. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) remains unread (Unpaywall 2026-08-22T23:12Z: `is_oa: false`; title *Measurement Invariance, Factor Analysis and Factorial Invariance*). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread (Unpaywall 2026-08-22T23:12Z: `is_oa: false`; title *Randomization-Based Inference about Latent Variables from Complex Samples*). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, §4.3, pp. 9–10; Eq. 3–5, pp. 4–5; Table 2, p. 12; p. 16; §7.2, pp. 20–21; JSS PDF re-opened 2026-08-22T19:13Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar lagged covariance of §4.3 stationary `T0VAR`. Equation 3 writes `η(t) = exp(A Δt) η(t0) + …`. Equation 4 writes `cov(η_t, η_{t-1}) = A_Δt cov(η_{t-1})`. The contemporaneous constraint is `trait + −q / (2 a) + (B / a)² v`. Trait variance and `addedTIPREDVAR` are time-invariant between-subject and do not decay with `e^{a Δt}`. The lagged composition is `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v`. Form the lagged within-subject covariance first, then include the trait, then include the TI extra variance, then add. As `Δt → ∞` with stable `a < 0` the state term vanishes. As `Δt → 0+` the lagged map approaches contemporaneous `T0VAR`. Those limits are not this finite-lag map. Evolving the constrained total as if it were all state is not this map. `trait + e^{a Δt} p` is not this map when `addedTIPREDVAR` is nonzero. Contemporaneous `T0VAR` is not this map. The interval must be event time and strictly positive. Equation 5 of that lagged covariance is `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ`. Independent `ε_t` does not enter. `MANIFESTVAR` is not that lagged observed covariance. Contemporaneous `Var(y_0)` includes `θ` and is not that lagged observed covariance. The lagged latent covariance is not the lagged observed covariance. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) remains unread (Unpaywall 2026-08-22T19:13Z: `is_oa: false`; title *Measurement Invariance, Factor Analysis and Factorial Invariance*). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread (Unpaywall 2026-08-22T19:13Z: `is_oa: false`; title *Randomization-Based Inference about Latent Variables from Complex Samples*). +- `psychometric_core` rustfmt-sorts the `event_time` test import list and wraps four long test signatures so `cargo fmt --check` matches 1.97.1. Nightly branch coverage on #49 head `a6bea47acc38de1a33ed403360eb5cacd3023df6` was 1691/1712: the 21 missing True sides in `event_time.rs` were later `||` operands and `!Δt.is_finite()` (as opposed to `Δt <= 0`) on lagged observed covariance, extra-process, asymptotic TI/CINT, T0 carry, and impulse-carry guards. Direct inner-map calls now execute those arms. `as_measurement_invariance_wire_name` maps only Configural/Metric/Strong onto `#84` `configural`/`metric`/`scalar` and returns `None` for local Strict (`as_str` remains `"strict"`). Parent-head coverage note on `ebd01c4` is historical. Meredith (1993) remains unread (Unpaywall 2026-08-22T16:13Z: `is_oa: false`; Springer `content/pdf` is HTML 200). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread (Unpaywall 2026-08-22T16:13Z: `is_oa: false`; Springer `content/pdf` is HTML 200). Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. +- `psychometric_core` covers the remaining fail-closed arms on `event_time.rs` lines 1995, 1998, 2063, 2065, 3695, 3706, 3751, 4078, 4089, and 4134 (nightly line/branch gaps on predecessor #49 head `ebd01c4`). Non-event clocks, non-positive and non-finite `Δt`, after-t0 extra-process interval errors after a successful `μ_t`, a zero extra-process contribution returning `μ_t`, Table 3 `T0TIPREDEFFECT`/`T0TDPREDEFFECT` effect errors through the carry, overflowing `a Δt` products, and those carry errors through the evolved-mean composition now execute. Meredith (1993) remains unread (Unpaywall 2026-08-22T12:15Z: `is_oa: false`; Springer `content/pdf` is HTML 200; Cambridge Core PDF 302 to a closed product page). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread (Unpaywall 2026-08-22T12:15Z: `is_oa: false`; Springer `content/pdf` is HTML 200; NCES 404; ETS RR-91-18 404). Driver, Oud, and Voelkle (2017) JSS PDF re-opened 2026-08-22T12:17Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, §4.3, pp. 9–10; Eq. 5, p. 5; Table 2, p. 12; p. 16; §7.2, pp. 20–21; JSS PDF re-opened 2026-08-22T03:20Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar observed-indicator variance of §4.3 stationary `T0VAR`. Section 4.3 constrains the first-occasion variance to the model-predicted variance when `stationary` includes `"T0VAR"`. Equation 5 writes `y_i(t) = Γ + Λ η_i(t) + ζ_i(t)` with `ζ ~ N(0, Θ)` and `Γ ~ N(τ, Ψ)`. The constrained latent variance is `trait + −q / (2 a) + (B / a)² v`. The scalar composition is `Var(y_0) = λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ`. Form the stationary latent variance first, then `λ² p + θ + ψ`. A zero loading is exactly `θ + ψ`. A zero trait, a zero diffusion, and a zero TI contribution is exactly `θ + ψ`. `λ² p_0` for free `T0VAR` is not this composition. `λ²(−q / (2 a)) + θ` is not this composition when `TRAITVAR` or `addedTIPREDVAR` is nonzero. Evolving the constrained variance as if it were all state is not this composition when the trait or TI contribution is nonzero. `MANIFESTVAR` is not `Var(y_0)`. The constrained latent variance is not `Var(y_0)`. `TRAITVAR` is latent and is scaled by `λ²`; `MANIFESTTRAITVAR` is not. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) remains unread (Unpaywall 2026-08-22T03:20Z: `is_oa: false`). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread (Unpaywall 2026-08-22T03:20Z: `is_oa: false`; Springer `content/pdf` is HTML 200). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, §4.3, pp. 9–10; p. 16; Table 2, p. 12; §7.2, pp. 20–21; Eq. 4, p. 5; JSS PDF re-opened 2026-08-22T03:07Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar stationary `T0VAR`. Section 4.3 constrains the first-occasion variance to the model-predicted variance when `stationary` includes `"T0VAR"`. Page 16 names `asymDIFFUSION` the total within-subject variance `-q / (2 a)`. Section 4.3 (p. 9) adds `TRAITVAR`. Section 7.2 names `addedTIPREDVAR` the stable between-subject variance accounted for by time-independent predictors, `(B / a)² v`. The scalar composition is `trait + −q / (2 a) + (B / a)² v`. Form the within-subject contribution first, then include the trait, then include the TI extra variance, then add. A zero trait, a zero diffusion, and a zero TI contribution is exactly zero. A zero diffusion and a zero TI contribution is exactly the trait. `a ≥ 0` cannot hold a finite process variance when the diffusion or the TI contribution is nonzero and fails closed. Trait-only variance does not require a stable drift. That constrained first-occasion variance is not free `T0VAR`, not `asymDIFFUSION` alone, not `TRAITVAR` alone, not `addedTIPREDVAR` alone, and not the finite-interval discrete latent variance. The printed 2-latent `addedTIPREDVAR` 2.838 is not this scalar map. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) remains unread (Unpaywall 2026-08-22T03:07Z: request empty). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread (Unpaywall 2026-08-22T03:07Z: request empty; ETS RR-88-45 PDF 404; Wiley PDF 403). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, §4.3, pp. 9–10; Eq. 5, p. 5; Table 2, p. 12; Eq. 3, p. 5; JSS PDF re-opened 2026-08-21T20:07Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar observed-indicator mean of §4.3 stationary `T0MEANS`. Section 4.3 constrains the first-occasion mean to the model-predicted mean when `stationary` includes `"T0MEANS"`. Equation 5 writes `y_i(t) = Γ + Λ η_i(t) + ζ_i(t)` with `ζ ~ N(0, Θ)` and `Γ ~ N(τ, Ψ)`. The constrained latent mean is `-κ / a + −B z / a`. The scalar composition is `E(y_0) = τ + λ(−κ / a + −B z / a)`. Form the stationary latent mean first, then `τ + λ` of that mean. A zero loading is exactly `τ`. A zero intercept and a zero TI contribution is exactly `τ`. Evolving from that stationary start with `CINT` and `TIPREDEFFECT` stays at the stationary mean. `τ + λ μ_0` for free `T0MEANS` is not this composition. `τ + λ(−κ / a)` is not this composition when `B z ≠ 0`. `τ + λ μ_t` is not this composition. `MANIFESTMEANS` is not `E(y_0)`. The constrained latent mean is not `E(y_0)`. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) remains unread (Unpaywall request empty this cycle). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread (Unpaywall 2026-08-21T20:10Z: `is_oa: false`; Springer `content/pdf` is HTML 200). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, p. 16; Table 2, p. 12; Eq. 3, p. 5; JSS PDF opened 2026-08-21T16:13Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar stationary `T0MEANS`. Page 16 constrains `T0MEANS` to the model-implied values using `T0MEANSbase` / `T0MEANSfree` when the first observation is determined by the process in the same way as later observations. Those constraints include extra effects due to time-independent predictors (`asymTIPREDEFFECT`). For stable `a < 0` the scalar composition is `-κ / a + −B z / a`. Form the intercept contribution first, then include the TI extra effect, then add. A zero intercept and a zero TI contribution is exactly zero. `a ≥ 0` cannot hold a finite process-mean change when either contribution is nonzero and fails closed. That constrained first-occasion mean is not free `T0MEANS`, not `asymCINT` alone, not `asymTIPREDEFFECT` alone, and not the finite-interval discrete latent mean. The printed 2-latent `T0MEANS` 2.823 is not this scalar map. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-21T16:21Z: `is_oa: false`; Springer `content/pdf` is HTML 200). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Table 2, p. 12; Eq. 3, p. 5; §4.3 / p. 16; JSS PDF opened 2026-08-21T16:13Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar `asymCINT`. Table 2 names `κ` `CINT` and names `asymCINT` the asymptotic (`Δt = ∞`) expected change in processes for a 1 unit change in intercept. Equation 3 maps a finite event interval as `A^{-1}[e^{A Δt} − I] κ`. For stable `a < 0` that `Δt → ∞` limit is `-A^{-1} κ`. The scalar map is `-κ / a`. A unit intercept is `-1 / a`. Form `κ` first, then divide by `-a`. A zero intercept is exactly zero. `a ≥ 0` cannot hold a finite process-mean change and fails closed. `-κ / a` is not `κ`, not the finite-interval increment `A^{-1}[e^{A Δt} − I] κ`, not `T0MEANS`, and not `asymTIPREDEFFECT` `-B z / a`. Page 16 notes that a `T0MEANS` stationarity constraint includes time-independent predictors; that composition is not this intercept-only map. The printed 2-latent `CINT` values are not this scalar map. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-21T16:21Z: `is_oa: false`; Springer `content/pdf` is HTML 200). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, §7.2, pp. 20–21; Eq. 3, p. 5; Table 2, p. 12; JSS PDF opened 2026-08-21T13:08Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar `addedTIPREDVAR`. Section 7.2 names that matrix the stable between-subject variance accounted for by time-independent predictors. For stable `a < 0` and predictor variance `v ≥ 0` the scalar map is `(B / a)² v`. Form the unit asymptotic effect `-B / a` first, then square, then multiply by `v`. A zero coefficient or zero predictor variance is exactly zero. `v < 0` fails closed. `a ≥ 0` cannot hold a finite process-mean change and fails closed. `(B / a)² v` is not `TRAITVAR`, not `asymDIFFUSION`, and not the expected total change `-B z / a`. The printed 2-latent `addedTIPREDVAR` 2.838 is not this scalar map. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-21T06:24Z: `is_oa: false`; Springer `content/pdf` is HTML 200). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, §7.2, pp. 20–21; Eq. 3, p. 5; Table 2, p. 12; JSS PDF opened 2026-08-21T13:08Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar `asymTIPREDEFFECT`. Table 2 names `B` `TIPREDEFFECT`. Equation 3 maps a finite event interval as `A^{-1}[e^{A Δt} − I] B z`. Section 7.2 names `asymTIPREDEFFECT` the expected total change in process means given an increase of 1 on a time-independent predictor. For stable `a < 0` that total change is `-A^{-1} B`. The scalar map is `-B z / a`. Form `B z` first, then divide by `-a`. A zero coefficient or zero predictor is exactly zero. `a ≥ 0` cannot hold a finite process-mean change and fails closed. `-B z / a` is not the coefficient `B`, not the finite-interval increment `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, and not `M x`. Printed LeisureTime `TIPREDEFFECT` `−0.225` / `asymTIPREDEFFECT` `−1.673` and Happiness `0.549` / `0.219` reconstruct under this map. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-21T06:24Z: `is_oa: false`; Springer `content/pdf` is HTML 200). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; §7.2, pp. 22–23; JSS PDF re-opened 2026-08-21T06:32Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar observed-indicator mean of an extra-process `TDPREDEFFECT` after `t0`. Section 7.2 names `T0TDPREDEFFECT` when the extra process begins at `t = 0` and `TDPREDEFFECT` when it begins after `t = 0`. The printed extra process has `LAMBDA` 0. Original indicators load on the original process after the `DRIFT` coupling over `t − u` with `t0 < u < t` while `μ_t` still uses `Δt = t − t0`. The scalar composition is `E(y_t) = τ + λ(μ_t + a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a))`. The first-occasion extra-process observed mean uses `Δt` for both the evolution and the extra drive and is not this composition when `u ≠ t0`. The evolved observed mean `τ + λ μ_t` is not this composition. The impulse-carry map `τ + λ(μ_t + e^{a(t−u)} m x)` is a Dirac on the original process and is not this `DRIFT` drive. An impulse at `u = t0` or `u = t` is not interior. A zero original-indicator loading is exactly `τ`. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-21T06:24Z: `is_oa: false`; Springer `content/pdf` is HTML 200). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; §7.2, pp. 22–23; Eq. 1–3, pp. 4–5; Table 2, p. 12; JSS PDF re-opened 2026-08-21T06:12Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar observed-indicator mean of the extra near-zero-drift latent process contribution. Section 7.2's printed extra process has `LAMBDA` 0 and is not an observed indicator. Original indicators load on the original process after the `DRIFT` coupling `a_{ηξ}`. After a unit identification impulse the latent process at `t` is `μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)`. The scalar composition is `E(y_t) = τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a))`. Form the evolved-plus-contribution latent mean first, then `τ + λ` of that mean. The evolved observed mean `τ + λ μ_t` is not this composition. The contemporaneous map `τ + λ(μ_t + m x)` is not this composition. The extra-process contribution is not `E(y_t)`. The evolved-plus-contribution latent mean is not `E(y_t)`. A zero original-indicator loading is exactly `τ`. A zero coupling recovers `τ + λ μ_t`. `ε ≥ 0` fails closed. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-21T06:24Z: `is_oa: false`; Springer `content/pdf` is HTML 200). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, §7.2, pp. 22–23; Eq. 1–3, pp. 4–5; Table 2, p. 12; JSS PDF re-opened 2026-08-20T23:10Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar contribution of the extra near-zero-drift latent process. Section 7.2 specifies a lasting level change by that extra process: `T0MEANS`, `CINT`, `T0VAR`, `DIFFUSION`, and `TRAITVAR` of it are fixed to 0; `TDPREDEFFECT` on it is fixed to 1; its `DRIFT` diagonal is very close to 0 (printed example `−0.000001`; precisely 0 causes computational problems); and its effect on the original process is the `DRIFT` coupling `a_{ηξ}`. After a unit identification impulse the scalar contribution is `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` (`ε = a` is `a_{ηξ} x Δt e^{a Δt}`). Form `a_{ηξ} x` first. A zero coupling or zero predictor is exactly zero. `ε ≥ 0` fails closed. That contribution is not `κ = −a m x`, not `(1 − e^{a Δt}) m x`, and not the dissipating Dirac `m x`. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-20T23:10Z: `is_oa: false`; Springer `content/pdf` is HTML 200). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, §7.2, pp. 20–21; Eq. 3, pp. 4–5; Table 2, p. 12; JSS PDF re-opened 2026-08-20T19:50Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar discrete increment of the lasting level-change `CINT`. Section 7.2 sets `CINT` to `TDPREDEFFECT * −DRIFT` (`κ = −a m x`). Equation 3 maps that intercept through `A^{-1}[e^{A Δt} − I] κ`. With `κ = −a m x` the scalar increment is `(e^{a Δt} − 1)/a · (−a m x) = (1 − e^{a Δt}) m x`. Form the level-change `CINT` first, then the discrete intercept map. Underflow of `e^{a Δt}` to `+0` keeps the equilibrium offset `m x`. `(1 − e^{a Δt}) m x` is not the contemporaneous jump `m x`. `(1 − e^{a Δt}) m x` is not `κ`. `(1 − e^{a Δt}) m x` is not `A^{-1}[e^{A Δt} − I] B z`. Stable `a < 0` is required. A zero effect or zero predictor is exactly zero. Still not a Kalman filter, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-20T19:45Z: `is_oa: false`; Springer `content/pdf` is HTML 200). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, §7.2, pp. 20–21; Eq. 1–3, pp. 4–5; Table 2, p. 12; JSS PDF re-opened 2026-08-20T19:45Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar lasting level-change `CINT`. Section 7.2 contrasts a sudden Dirac that dissipates back to the process mean with a lasting level change. To generate that lasting change, `CINT` is set to `TDPREDEFFECT * −DRIFT`. The scalar setting is `κ = −a m x`. Form `m x` first, then multiply by `−a`. Stable `a < 0` is required so `−κ / a = m x` is an equilibrium offset. `a ≥ 0` cannot hold a new process mean. `−a m x` is not the contemporaneous jump `m x`. `−a m x` is not a free `CINT`. `−a m x` is not `A^{-1}[e^{A Δt} − I] B z`. The extra near-zero-drift latent process also named in §7.2 is a different specification and is not this `CINT` setting. A zero effect or zero predictor is exactly zero. Still not a Kalman filter, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-20T19:45Z: `is_oa: false`; Springer `content/pdf` is HTML 200). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; Eq. 3 first summand, p. 5; Table 3, p. 13; JSS PDF re-opened 2026-08-20T19:07Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar observed-indicator mean of a first-occasion time-dependent predictor. Equation 5 writes `y_i(t) = Γ + Λ η_i(t) + ζ_i(t)` with `ζ ~ N(0, Θ)` and `Γ ~ N(τ, Ψ)`. Table 3 names `T0TDPREDEFFECT` the effect of time-dependent predictors on latents at `T0`. Equation 3's first summand carries that shift as `e^{A Δt} t0_m x0`. The scalar composition is `E(y_t) = τ + λ(μ_t + e^{a Δt} t0_m x0)`. Form the evolved-plus-carry latent mean first, then `τ + λ` of that mean. The evolved observed mean `τ + λ μ_t` is not this composition. The process-increment map `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not this composition. The contemporaneous map `τ + λ(μ_t + m x)` is not this composition. The impulse-carry map `τ + λ(μ_t + e^{a(t−u)} m x)` is not this composition when `u ≠ t0`. The first-occasion TI map `τ + λ(μ_t + e^{a Δt} t0_b z)` is not this composition. `MANIFESTMEANS` is not `E(y_t)`. The evolved-plus-carry latent mean is not `E(y_t)`. `T0TDPREDEFFECT` is the coefficient, not that observed mean. Same numbers as `T0TIPREDEFFECT` yield the same product; Table 3 names a different matrix. A zero loading is exactly `τ`. Still not a Kalman filter, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-20T19:09Z: `is_oa: false`). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Table 3, p. 13; Eq. 3 first summand, p. 5; JSS PDF re-opened 2026-08-20T19:10Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar first-occasion time-dependent predictor shift and its carry. Table 3 names `T0TDPREDEFFECT` the effect of time-dependent predictors on latents at `T0`. Table 2 / Table 3 name `TDPREDEFFECT` `M`, which enters Equation 3 as the printed fourth-summand Dirac `M x` at `u = t`. Those are not the same matrix. The scalar first-occasion shift is `t0_m x0`. Equation 3's first summand carries that shift as `e^{A Δt} t0_m x0`. Form `t0_m x0` first, then `e^{a Δt} t0_m x0`. Form `μ_t` first, then add that carry. A zero drift is `t0_m x0` with no dissipation. Binary64 underflow of `e^{a Δt}` to `+0` is a vanishing carry of the first-occasion shift and is kept. `t0_m x0` is not `M x`, not `e^{A(t−u)} M x` for `t0 < u < t`, not `t0_b z`, not `A^{-1}[e^{A Δt} − I] B z`, and not `κ`. `e^{A Δt} t0_m x0` is not `t0_m x0`. `T0TDPREDEFFECT` is the coefficient, not `t0_m x0`. An impulse at `u ≤ t0` that used `M` is already in `η(t0)` as `TDPREDEFFECT`, not as `T0TDPREDEFFECT`. Still not a Kalman filter, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-20T19:09Z: `is_oa: false`). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; Eq. 3 first summand, p. 5; Table 3, p. 13; JSS PDF re-opened 2026-08-20T15:28Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar observed-indicator mean of a first-occasion time-independent predictor. Equation 5 writes `y_i(t) = Γ + Λ η_i(t) + ζ_i(t)` with `ζ ~ N(0, Θ)` and `Γ ~ N(τ, Ψ)`. Table 3 names `T0TIPREDEFFECT` the effect of time-independent predictors on latents at `T0`. Equation 3's first summand carries that shift as `e^{A Δt} t0_b z`. The scalar composition is `E(y_t) = τ + λ(μ_t + e^{a Δt} t0_b z)`. Form the evolved-plus-carry latent mean first, then `τ + λ` of that mean. The evolved observed mean `τ + λ μ_t` is not this composition. The process-increment map `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not this composition. The contemporaneous map `τ + λ(μ_t + m x)` is not this composition. The impulse-carry map `τ + λ(μ_t + e^{a(t−u)} m x)` is not this composition when `u ≠ t0`. `MANIFESTMEANS` is not `E(y_t)`. The evolved-plus-carry latent mean is not `E(y_t)`. `T0TIPREDEFFECT` is the coefficient, not that observed mean. A zero loading is exactly `τ`. Still not a Kalman filter, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-20T15:14Z: `is_oa: false`; Springer `content/pdf` is HTML 200). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Table 3, p. 13; Eq. 3 first summand, p. 5; JSS PDF opened 2026-08-20T15:14Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar first-occasion time-independent predictor shift and its carry. Table 3 names `T0TIPREDEFFECT` the effect of time-independent predictors on latents at `T0`. Table 2 / Table 3 name `TIPREDEFFECT` `B`, which enters Equation 3 as `A^{-1}[e^{A(t−t0)} − I] B z`. Those are not the same matrix. The scalar first-occasion shift is `t0_b z`. Equation 3's first summand carries that shift as `e^{A Δt} t0_b z`. Form `t0_b z` first, then `e^{a Δt} t0_b z`. Form `μ_t` first, then add that carry. A zero drift is `t0_b z` with no dissipation. Binary64 underflow of `e^{a Δt}` to `+0` is a vanishing carry of the first-occasion shift and is kept. `t0_b z` is not `A^{-1}[e^{A Δt} − I] B z`, not `κ`, and not `M x`. `e^{A Δt} t0_b z` is not `t0_b z`. `T0TIPREDEFFECT` is the coefficient, not `t0_b z`. Still not a Kalman filter, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-20T15:14Z: `is_oa: false`; Springer `content/pdf` is HTML 200). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; Eq. 3, p. 5; Table 2, p. 12; JSS PDF re-opened 2026-08-20T12:12Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar observed-indicator mean of a time-independent predictor. Equation 5 writes `y_i(t) = Γ + Λ η_i(t) + ζ_i(t)` with `ζ ~ N(0, Θ)` and `Γ ~ N(τ, Ψ)`. Equation 3 prints the `TIPREDEFFECT` increment as the addend `A^{-1}[e^{A(t−t0)} − I] B z_i` after the `T0MEANS` carry and the `CINT` increment. Table 2 names `B` `TIPREDEFFECT`. The scalar composition is `E(y_t) = τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`. Form the evolved-plus-increment latent mean first, then `τ + λ` of that mean. The evolved observed mean `τ + λ μ_t` is not this composition. The contemporaneous map `τ + λ(μ_t + m x)` is not this composition. The carry map `τ + λ(μ_t + e^{a(t−u)} m x)` is not this composition when `u ≠ t`. `MANIFESTMEANS` is not `E(y_t)`. The evolved-plus-increment latent mean is not `E(y_t)`. `TIPREDEFFECT` is `B`, not that observed mean. A zero loading is exactly `τ`. Still not a Kalman filter, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993), Vandenberg and Lance (2000), and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-20T05:12Z: `is_oa: false`). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; Eq. 1–3, pp. 4–5; Table 2, p. 12; §7.2, pp. 20–21; JSS PDF re-opened 2026-08-20T09:01Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar observed-indicator mean of a contemporaneous time-dependent impulse. Equation 5 writes `y_i(t) = Γ + Λ η_i(t) + ζ_i(t)` with `ζ ~ N(0, Θ)` and `Γ ~ N(τ, Ψ)`. The latent process at `t` after a contemporaneous Dirac (`u = t`) is `μ_t + m x`. The scalar composition is `E(y_t) = τ + λ(μ_t + m x)`. Form the evolved-plus-impulse latent mean first, then `τ + λ` of that mean. The evolved observed mean `τ + λ μ_t` is not this composition. The carry map `τ + λ(μ_t + e^{a(t−u)} m x)` is not this composition when `u ≠ t`. `MANIFESTMEANS` is not `E(y_t)`. The evolved-plus-impulse latent mean is not `E(y_t)`. A zero loading is exactly `τ`. The §7.2 level-change form is a different specification and is not this map. Still not a Kalman filter, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993), Vandenberg and Lance (2000), and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-20T05:12Z: `is_oa: false`). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; Eq. 1–2, pp. 4–5; Eq. 3 exponential map; Table 2, p. 12; §7.2, pp. 20–21; JSS PDF re-opened 2026-08-20T05:12Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar observed-indicator mean of a within-interval time-dependent impulse carry. Equation 5 writes `y_i(t) = Γ + Λ η_i(t) + ζ_i(t)` with `ζ ~ N(0, Θ)` and `Γ ~ N(τ, Ψ)`. The latent process at `t` after a Dirac that occurred strictly inside `(t0, t)` is `μ_t + e^{a(t−u)} m x`. The scalar composition is `E(y_t) = τ + λ(μ_t + e^{a(t−u)} m x)`. Form the carried latent mean first, then `τ + λ` of that mean. The evolved observed mean `τ + λ μ_t` is not this composition. The contemporaneous map `τ + λ(μ_t + m x)` is not this composition when `u ≠ t`. `MANIFESTMEANS` is not `E(y_t)`. The carried latent mean is not `E(y_t)`. A zero loading is exactly `τ`. The §7.2 level-change form is a different specification and is not this map. Still not a Kalman filter, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993), Vandenberg and Lance (2000), and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-20T05:12Z: `is_oa: false`). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Eq. 1–2, pp. 4–5; Eq. 3 exponential map; Table 2, p. 12; §7.2, pp. 20–21; JSS PDF re-opened 2026-08-20T10:33Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar within-interval time-dependent predictor impulse carry. Equation 1 writes `dη = (A η + ξ + B z + M χ(t)) dt + G dW`. Equation 2 writes `χ_i(t) = Σ x_{i,u} δ(t − u)`. The Green-function integral of that Dirac on `(t0, t)` is `e^{A(t−u)} M x`. The printed Eq. 3 fourth summand is the contemporaneous jump `M x` at `u = t`. This map is the strictly within-interval case `t0 < u < t`. Form `m x` first, then `e^{a(t−u)} m x`. A zero drift is `m x` with no dissipation. Underflow of `e^{a(t−u)}` to `+0` is vanishing dissipation back to the process mean and is kept. Form `μ_t` first, then add the carry. `e^{A(t−u)} M x` is not the contemporaneous Dirac, not `CINT`, not `A^{-1}[e^{A Δt} − I] B z` (`TIPREDEFFECT`), and not Voelkle et al. (2012, Eq. 14) `a_{yx} Δt`. An impulse at `u = t` is the contemporaneous map. An impulse at `u ≤ t0` is already in `η(t0)`. The §7.2 level-change form is a different specification and is not this map. Still not a Kalman filter, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993), Vandenberg and Lance (2000), and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-20T10:33Z: `is_oa: false`). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Eq. 1–3, pp. 4–5; Table 2, p. 12; JSS PDF re-opened 2026-08-20T10:13Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar time-independent predictor increment. Equation 1 writes `dη = (A η + b + A_{ηξ} ξ + B z) dt + G dW + M dχ`. Equation 3's second summand is `A^{-1}[e^{A Δt} − I](b + A_{ηξ} ξ + B z)`. Table 2 names `B` `TIPREDEFFECT`. Form `B z` first, then the discrete intercept map. A zero drift is `B z Δt`. Form `μ_t` first, then add that increment. `TIPREDEFFECT` is `B`, not the discrete increment. `A^{-1}[e^{A Δt} − I] B z` is not `CINT`, not `M x` (`TDPREDEFFECT`), and not Voelkle et al. (2012, Eq. 14) `a_{yx} Δt`. Still not a Kalman filter, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993), Vandenberg and Lance (2000), and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-20T10:13Z: `is_oa: false`). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Eq. 1–3, pp. 4–5; Table 2, p. 12; §7.2, pp. 20–21; JSS PDF re-opened 2026-08-20T07:10Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar contemporaneous time-dependent predictor impulse. Equation 2 writes `χ_i(t) = Σ x_{i,u} δ(t − u)`. Equation 3's fourth summand is `M Σ x_{i,u} δ(t − u)`. Table 2 names `M` `TDPREDEFFECT`. Section 7.2 calls this a sudden impulse that dissipates back to the process mean and reports `TDPREDEFFECT` as the initial impact. The scalar jump is `m x`. Form `μ_t` first, then add `m x`. `TDPREDEFFECT` is not `CINT`. `M x` is not `A^{-1}[e^{A Δt} − I] B z` (`TIPREDEFFECT`). `M x` is not Voelkle et al. (2012, Eq. 14) `a_{yx} Δt`. The §7.2 level-change form is a different specification and is not this map. Still not a Kalman filter, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993), Vandenberg and Lance (2000), and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-20T07:10Z: `is_oa: false`). +- `psychometric_core` caps two-observation two-group OLS residual invariance at strong/scalar. `ordinary_least_squares_fit` returns residual variance `0` when `n ≤ 2`; that identity is not an estimated residual and is not strict. Putnick and Bornstein (2016, PMC author manuscript PMC5145197 opened 2026-08-19T22:15Z from https://pmc.ncbi.nlm.nih.gov/articles/PMC5145197/) require scalar invariance before latent-mean comparison; residual invariance is not a prerequisite because residuals are not part of the latent factor. Matching loading and intercept with `n = 2` therefore stay strong/scalar and still license `(ȳ_c − ȳ_r)/λ`. This is still two-group OLS, not MGCFA. Meredith (1993) remains unread (OpenAlex/Semantic Scholar 2026-08-19T22:15Z: closed; Springer `content/pdf` is HTML 200). Vandenberg and Lance (2000) remains unread (cited by Putnick for the residual-not-required claim). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread. +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Eq. 3, p. 5; Eq. 5, p. 5; Table 2, p. 12; JSS PDF re-opened 2026-08-19T22:10Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar evolved observed-indicator mean. Equation 3 writes `η_i(t) = exp(A Δt) η_i(t0) + A^{-1}[exp(A Δt) − I] ξ_i + …` with `ξ_i ~ N(κ, φ_ξ)` (p. 4) and a stochastic integral of mean zero. Equation 5 writes `y_i(t) = Γ_i + Λ η_i(t) + ζ_i(t)` with `Γ ~ N(τ, Ψ)` and `ζ ~ N(0, Θ)`. The scalar composition is `E(y_t) = τ + λ μ_t` with `μ_t` the Eq. 3 expected-value map. Form `μ_t` first, then `τ + λ μ_t`. The first-occasion map `τ + λ μ_0` is not `E(y_t)`. `MANIFESTMEANS` is not `E(y_t)`. `T0MEANS` is not `E(y_t)`. `μ_t` is not `E(y_t)`. A zero-CINT overflow of `exp(a Δt)` fails the carried `T0MEANS` term closed (`a = 710`, `Δt = 1`). Still not a Kalman filter, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) remains unread (OpenAlex 2026-08-19T22:10Z: closed; Springer `content/pdf` is HTML 200). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread (OpenAlex/Semantic Scholar 2026-08-19T22:10Z: closed). Putnick and Bornstein (2016) PMC PDF was HTML/500 on this cycle. Oud and Jansen (2000) remains unread. ZORA Anubis-blocked. Asparouhov and Muthén (2009) statmodel PDF re-opened 2026-08-19T22:10Z; it cites Meredith (1993) and discusses multiple-group intercept/mean structures but does not license this map. +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Eq. 3, p. 4; Table 2, p. 12; JSS PDF re-opened 2026-08-19T18:10Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar expected-value latent mean. Equation 3 writes `η(t) = exp(A Δt) η(t0) + ∫ exp(A(t−s)) (b + …) ds` plus a stochastic integral of mean zero. Table 2 names the first-occasion latent mean `T0MEANS` and `κ` `CINT`. The scalar map is `μ_t = exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ`. Form the CINT increment first, then add the carried `T0MEANS` term. A zero drift is the Eq. 3 integral `κ Δt` (`A = 0` has no inverse). As `Δt → ∞` with stable `a < 0`, `μ_t → −κ / a`. Binary64 underflow of `exp(a Δt)` to `+0` drops the carried `T0MEANS` and keeps that equilibrium increment. `T0MEANS` is not `μ_t`. `CINT` is not the discrete increment. `CINT` is not `T0MEANS`. Still not a Kalman filter, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (OpenAlex 2026-08-19T18:10Z: closed; Springer `content/pdf` is HTML 200). Oud and Jansen (2000) remains unread. ZORA Anubis-blocked. +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; Table 2, p. 12; JSS PDF re-opened 2026-08-19T14:08Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar observed-indicator mean. Equation 5 writes `y_i(t) = Γ + Λ η_i(t) + ζ_i(t)` with `ζ ~ N(0, Θ)` and `Γ ~ N(τ, Ψ)`. Table 2 names `τ` `MANIFESTMEANS`, `κ` `CINT`, and the first-occasion latent mean `T0MEANS`. The scalar map is `E(y) = τ + λ μ`. Form `λ μ` then add `τ`. Do not treat `MANIFESTMEANS` as `E(y)`. `E(η)` is not `E(y)`. `CINT` is not `MANIFESTMEANS`. `T0MEANS` is not `E(y)`. Forming `λ²` is the variance path, not this mean. Still not a Kalman filter, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-19T14:08Z: closed). Oud and Jansen (2000) remains unread. ZORA Anubis-blocked. +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; Eq. 3–4, pp. 4–5; Table 2, p. 12; JSS PDF re-opened 2026-08-19T04:18Z) scalar lagged observed-indicator covariance. Independent measurement error does not enter `cov(y_t, y_{t-1})`. The scalar map is `λ² cov(η_t, η_{t-1}) + ψ`. Form `(λ c) λ` then add `ψ`. Do not form `λ²` first (`λ = 1e308`, `c = 1e-308` → `1e308`). `MANIFESTVAR` is not lagged observed covariance. Lagged `Var(η)` path is not `cov(y)`. Still not a Kalman filter, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall 2026-08-19T04:25Z: closed). +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; Table 2, p. 12; JSS PDF re-opened 2026-08-19T04:18Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar observed-indicator variance with `MANIFESTTRAITVAR`. Equation 5 writes `y_i(t) = τ_i + Λ η_i(t) + ε_i(t)` with `ε ~ N(0, Θ)` and `τ_i ~ N(μ_τ, Ψ_τ)`. Equation 1 (p. 4) is the latent SDE, not the measurement model. Table 2 names `Θ` `MANIFESTVAR` and `Ψ_τ` `MANIFESTTRAITVAR`; p. 16 restates those names. The scalar map is `Var(y) = λ² Var(η) + θ` when `Ψ_τ = 0` and `λ² Var(η) + θ + ψ` otherwise. Form `(λ p) λ` then add `θ`, then add `ψ`. `MANIFESTVAR` is not `Var(y)`. `MANIFESTTRAITVAR` is not `MANIFESTVAR`. `TRAITVAR` is latent additional variance and is scaled by `λ²`; `MANIFESTTRAITVAR` is not. Still not a Kalman filter, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall/OpenAlex 2026-08-19T04:18Z: closed). Oud and Jansen (2000) remains unread. ZORA Anubis-blocked. +- `psychometric_core` evaluates the Driver, Oud, and Voelkle (2017, Eq. 4, p. 5; JSS PDF re-opened 2026-08-19T04:10Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) stationary within-subject variance as `q / -(2 a)` when the scalar Kronecker sum `2 a` is finite. The paper limit is `-q / (2 a)` (`A# = A ⊗ I + I ⊗ A`; p. 16 `asymDIFFUSION`). Forming `q / a` first overflows at `q = MAX`, `a = -0.75` while `MAX / 1.5` is finite (CodeRabbit finding on `75ecdd3`). When `2 a` overflows (`a = -1e308`), form `(q / a) * -0.5`. Do not form `0.5 q` first (`q = from_bits(1)` underflows). Still not a Kalman filter, not DSEM, not a matrix `expm`, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall/OpenAlex 2026-08-19T04:10Z: closed). Oud and Jansen (2000) remains unread. ZORA Anubis-blocked. +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Eq. 1, p. 4; p. 16 `MANIFESTVAR`; JSS PDF re-opened 2026-08-19T00:14Z) scalar observed-indicator variance. Equation 1 writes `y_i(t) = Λ η_i(t) + τ + ε_i(t)` with `ε ~ N(0, Θ)`. The scalar map is `Var(y) = λ² Var(η) + θ`. Form `(λ p) λ` then add `θ`. Do not form `λ²` first (`λ = 1e308`, `p = 1e-308` → `1e308`). `MANIFESTVAR` is not `Var(y)`. `Var(η)` is not `Var(y)`. Still not a Kalman filter, not ESEM estimation, not DSEM, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall/OpenAlex 2026-08-19T00:14Z: closed). Oud and Jansen (2000) remains unread. ZORA Anubis-blocked. +- `psychometric_core` evaluates the Driver, Oud, and Voelkle (2017, Eq. 4, p. 5; JSS PDF re-opened 2026-08-19T00:14Z) stationary within-subject variance as `(q / a) * -0.5`. The paper limit is `-q / (2 a)`. Forming `2 a` first overflows at `a = -1e308`. Forming `0.5 q` first underflows at `q = from_bits(1)`, `a = -from_bits(1)` and returns `+0` (CodeRabbit finding on `556e23d`); the representable Lyapunov solution is `0.5`. Still not a Kalman filter, not DSEM, not a matrix `expm`, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall/OpenAlex 2026-08-19T00:14Z: closed; Springer `content/pdf` is HTML 200). Oud and Jansen (2000) remains unread. ZORA Anubis-blocked. +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, §4.3, p. 9; JSS PDF re-opened 2026-08-18T21:07Z) scalar trait-plus-state latent variance and lagged covariance. A stable trait process has `DRIFT` and `DIFFUSION` fixed to zero, so `Var = trait + state` and `cov(t, t-1) = trait + exp(a Δt) p`. The ctsem `TRAITVAR` rewrite that adds the trait to `DIFFUSION` does not license treating trait variance as process noise. Trait variance is not `asymDIFFUSION`. Evolving the summed variance as if it were all state is not this map. Still not RI-CLPM, not a Kalman filter, not DSEM, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall/OpenAlex 2026-08-18T21:07Z: closed). Oud and Jansen (2000) remains unread. ZORA accepted manuscript re-opened via bitstream `424f9082-0eeb-4a67-b687-9845a4ed892f`. +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Eq. 4, p. 5; §4.3 pp. 9–10; p. 16 `asymDIFFUSION`; JSS PDF re-opened 2026-08-18T18:03Z) scalar stationary within-subject variance. Eq. 4 writes `Q_Δt = irow(A#^{-1}[e^{A# Δt} − I] row(Q))` with `A# = A ⊗ I + I ⊗ A`. The scalar Kronecker sum is `2 a`. As `Δt → ∞` with stable `a < 0`, that limit is `-q / (2 a)`. Form `-0.5 q / a`; do not form `2 a` first (`a = -1e308`, `q = 1e308` → `0.5`). Starting from that variance, `Var(η_t)` is invariant across finite event intervals. A zero diffusion is exactly zero. `a ≥ 0` has no finite stationary variance. Finite-interval `Q_Δt` is not `asymDIFFUSION`. Still not a Kalman filter, not DSEM, not a matrix `expm`, and not ctsem estimation. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall/OpenAlex 2026-08-18T18:03Z: closed). Oud and Jansen (2000) remains unread. ZORA Anubis-blocked. +- `psychometric_core` refuses a zero-diffusion Driver, Oud, and Voelkle (2017, Eq. 3–4, pp. 4–5; JSS PDF re-opened 2026-08-18T14:04Z) latent variance when `2 (a Δt)` overflows to `+∞`. Zero diffusion is exactly `Q_Δt = 0` (Eq. 3 integral of a zero `G`). That skip does not license `Var(η_t) = exp(2 a Δt) p + 0` when the carried term is non-finite (`p = 2`, `q = 0`, `a = 1e308`, `Δt = 2`). Nightly uncovered production line `event_time.rs:596` on predecessor `321568a` is this arm. Still not a Kalman filter, not DSEM, and not a matrix `expm`. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall/OpenAlex/Semantic Scholar 2026-08-18T14:04Z: closed; Springer `content/pdf` is HTML 200). Oud and Jansen (2000) remains unread. +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Eq. 3–4, pp. 4–5; JSS PDF re-opened 2026-08-18T11:20Z) lagged latent covariance `cov(η_ti, η_{t-1,i}) = A_Δt cov(η_{t-1,i})` and the law-of-total-variance map `Var(η_ti) = A_Δt Var(η_{t-1,i}) A_Δt⊤ + Q_Δt`. Eq. 3 writes `η(t) = exp(A Δt) η(t0) + … +` the stochastic integral; Eq. 4 writes that the integral exhibits covariance `Q_Δt`. `Q_Δt` remains `cov(η_ti | η_{t-1,i})` for the homogeneous process (`ξ`, `z` given) and is refused as the unconditional variance. The JSS article has no numbered §2.2 (2.1 is Continuous time and SEM; §3 follows). Scalar `exp(a Δt) p` underflow to `+0` is a vanishing covariance and is kept. Finite-`a Δt` exponential overflow rewrites as `exp(ln p + a Δt)`. A finite `exp(a Δt)` whose product with `p` overflows fails closed. Still not a Kalman filter, not DSEM, and not a matrix `expm`. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall/OpenAlex/Semantic Scholar 2026-08-18T11:05Z: closed). Oud and Jansen (2000) remains unread (Radboud 403). ZORA Anubis-blocked. +- `psychometric_core` closes nightly branch coverage on `event_time.rs` (160/164 on `6c13dfb`). The four remaining False sides were `assert!(a && b)` in the unit-test module (Eq. 12 equilibrium-increment and expm1-overflow oracles); they cannot take False on a passing test. Split into independent asserts. Lib-instantiation fail-closed arms for Voelkle et al. (2012, Eq. 14) and Driver, Oud, and Voelkle (2017, Eq. 3) stay in the integration contract. Driver JSS PDF already opened 2026-08-18T07:06Z, p. 4 (`L` remains identity; this is not a Kalman filter). Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall/OpenAlex 2026-08-18T07:17Z: closed; Springer `content/pdf` is HTML 200, not a PDF). ERIC ED334221 is Singer and Willett (1991). ZORA Anubis-blocked. Oud and Jansen (2000) remains unread. +- `psychometric_core` refuses an overflowing Driver Eq. 3 rewrite scale `0.5 q / a` (JSS PDF re-opened 2026-08-18T03:07Z, p. 4). When `expm1(z)` overflows at a finite `z = 2(a Δt)` and `0.5 q / a` is non-finite (`q = 1e308`, `a = 0.1`, `Δt = 4000` → `z = 800`), `Q_Δt = q(e^{2aΔt}−1)/(2a)` is not finite and fails closed. Algebraically identical to the licensed integral. Still not a Kalman filter, not DSEM, and not a matrix `expm`. Meredith (1993) and Mislevy (1991, *Psychometrika, 56*, 177–196) remain unread (Unpaywall/OpenAlex/Semantic Scholar/CORE 2026-08-18T03:07Z: closed). ERIC ED334221 is Singer and Willett (1991), not Mislevy (1991). ERIC ED333032 remains Mislevy, Sheehan, and Wingersky (1990). ZORA Anubis-blocked. Oud and Jansen (2000) remains unread. +- `psychometric_core` evaluates Driver, Oud, and Voelkle (2017, Eq. 3; JSS PDF re-opened 2026-08-17T21:03Z, p. 4) as \(0.5 q(\operatorname{expm1}(z)/a)\) with \(z=2(a\Delta t)\). Forming \(2a\) first overflows when \(|a|\) is at the binary64 extreme even if \(a\Delta t\) and \(Q_{\Delta t}\) are finite (`a=1e308`, `Δt=1e-308` → \(0.5(\mathrm{e}^{2}-1)/10^{308}\); `a=-1e308`, `q=1e308`, `Δt=2` → \(0.5\)). Algebraically identical to \(q(\mathrm{e}^{2a\Delta t}-1)/(2a)\). `expm1(−∞)` keeps \(-0.5 q/a\). \(z\to+\infty\) still fails closed. Still not a Kalman filter, not DSEM, and not a matrix `expm`. Meredith (1993), Mislevy (1991), and Oud and Jansen (2000) remain unread (Unpaywall/OpenAlex/Semantic Scholar 2026-08-17T21:03Z: closed; ZORA Anubis-blocked). +- `psychometric_core` recovers Voelkle et al. (2012, Eq. 14; ZORA accepted manuscript re-opened 2026-08-17T14:20Z, Introducing Intercepts, manuscript p. 21): the discrete effect of a time-varying predictor whose sampling interval equals its constancy interval is \(b^{*}_{y.x}(\Delta t)=a_{yx}\Delta t\). It does not depend on the predictor auto-effect. That product is not Eq. 12. Unmatched sampling and constancy intervals fail closed (Oud & Jansen, 2000, unread). An overflowing Eq. 12 rewrite scale \(a_{yx}/a_{xx}\) also fails closed. Still not DSEM. +- `psychometric_core` recovers Voelkle et al. (2012, Eq. 12) when `expm1(z)` overflows to `+∞` at a finite `z`. The rewrite is `sign(a_{yx}/a_{xx})\exp(\ln|a_{yx}|+z-\ln|a_{xx}|)-a_{yx}/a_{xx}` (algebraically identical to `(a_{yx}/a_{xx})(\exp(a_{xx}\Delta t)-1)`). A zero continuous effect is exactly zero even when `expm1` overflows (`0\cdot+\infty` is `NaN`). `z\to+\infty` remains fail-closed. ZORA accepted manuscript re-opened 2026-08-17T13:26Z, Introducing Intercepts, manuscript p. 20; Driver, Oud, and Voelkle (2017, Eq. 3) restated `A^{-1}[e^{A\Delta t}-I]\xi`. Still not DSEM. +- `psychometric_core` evaluates Voelkle et al. (2012, Eq. 12) as \(a_{yx}(\operatorname{expm1}(z)/a_{xx})\) with \(z=a_{xx}\Delta t\). That order is algebraically identical to \((a_{yx}/a_{xx})(\exp(a_{xx}\Delta t)-1)\). Dividing the increment by the finite auto-effect keeps the equilibrium increment \(-a_{yx}/a_{xx}\) when \(z\) overflows to \(-\infty\) (ZORA accepted manuscript, Introducing Intercepts: the exponential vanishes as \(\Delta t\) grows; CodeRabbit finding on `7ffb65b`). The prior \(a_{yx}\Delta t\) overflow case remains finite. Binary64 underflow of \(z\) to `+0` recovers the Eq. 12 limit \(a_{yx}\Delta t\); the first-order product is still not the general discrete effect. Still not DSEM. +- `psychometric_core` evaluates Voelkle et al. (2012, Eq. 12) as \(a_{yx}(\operatorname{expm1}(z)/z\cdot\Delta t)\) with \(z=a_{xx}\Delta t\). That order is algebraically identical to \((a_{yx}/a_{xx})(\exp(a_{xx}\Delta t)-1)\). Multiplying the unitless scale by \(\Delta t\) before \(a_{yx}\) keeps a finite Eq. 12 result when \(a_{yx}\Delta t\) overflows (ZORA accepted manuscript re-opened 2026-08-17T13:13Z, p. 16). Binary64 underflow of \(z\) to `+0` recovers the Eq. 12 limit \(a_{yx}\Delta t\); the first-order product is still not the general discrete effect. Driver, Oud, and Voelkle (2017, p. 4) restated the discrete intercept as a function of \(A\) and \(\Delta t\). Still not DSEM. +- `psychometric_core` evaluates Voelkle et al. (2012, Eq. 12) as \(a_{yx}\Delta t\,(\operatorname{expm1}(z)/z)\) with \(z=a_{xx}\Delta t\). That order is algebraically identical to \((a_{yx}/a_{xx})(\exp(a_{xx}\Delta t)-1)\). Binary64 underflow of \(z\) to `+0` recovers the Eq. 12 limit \(a_{yx}\Delta t\); the first-order product is still not the general discrete effect. Driver, Oud, and Voelkle (2017, p. 4) restated the discrete intercept as a function of \(A\) and \(\Delta t\) (PDF re-opened 2026-08-17T12:04Z). Still not DSEM. +- `psychometric_core` exact scalar discrete effect of a constant event-time predictor (Voelkle et al., 2012, Eq. 12): \(b^{*}_{y.x}(\Delta t)=(a_{yx}/a_{xx})(\exp(a_{xx}\Delta t)-1)\) for \(a_{xx}\neq 0\). The first-order product \(a_{yx}\Delta t\) is not that discrete effect. Still not DSEM. +- `psychometric_core` exact scalar forward map `φ(Δt) = exp(a Δt)` and interval remapping: a discrete lag at one event interval maps onto another through the Voelkle et al. (2012, Eq. 7) log-rate. Pooling discrete lags from unequal intervals fails closed. Still not a matrix `expm` and not DSEM. +- Fail-closed CWC-lag coverage in `psychometric_core`: singleton clusters are skipped, all-singleton series and overflowing CWC residuals fail closed, later-only residual overflow is checked with bitwise `is_finite`, the scalar Newton step refuses a non-finite exponential, score, start-skip, or deriv-INF, and Pearson empty/mismatch/left-INF paths are recovered. Dead post-OLS `pred_ss` and midpoint `require_finite` guards were removed because those values are already finite after OLS. +- `psychometric_core` multilevel/event-time recovery on the stacked psychometric PR: cluster-mean CWC within/between OLS, Kish ESS weighted slopes, event-time-only discrete lag-1 and exact scalar local log-rate, CWC-then-event-time residual lag, Rubin `T_m = Ū_m + (1+1/m) B_m` on draw-level OLS loadings, and two-group OLS strong/strict-gated latent-mean difference. Metric/weak is not a mean license. Not DSEM, not RI-CLPM, not MGCFA, not Mislevy PVs (ADR 0005; no new migration). +- `psychometric_core` posterior-aware structural input gates: construct classification, refusal of raw-proportion Pearson/OLS, explicit ALR-versus-ILR geometry boundaries, CPU `f64` OLS recovery, posterior-draw loading point-estimate averaging without Rubin uncertainty claims, invariance-gated latent-mean comparison, and causal-heuristic refusal (ADR 0005 first production slice; no new migration). ### Added - `persistence_postgres` entity/project target SQL now rejects empty, oversized, or hostile type/status labels before insert; interpolated codes are restricted to lowercase ASCII `snake_case` characters so membership foreign keys remain referentially safe (ADR 0003 / ADR 0013). @@ -187,6 +246,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Changed +- `psychometric_core` scalar forward map `φ(Δt) = exp(a Δt)` now refuses binary64 underflow to `+0`. Voelkle et al. (2012, Eq. 7; ZORA accepted manuscript p. 16) write discrete auto-effects as `e^{a Δt}`, which are strictly positive; `a = ln(φ) / Δt` requires `φ > 0`. Direct overflow already failed closed. The Newton residual path refuses a mapped `+0` the same way. - The docstring discovery test compares crate-root names to `EXPECTED_CRATES` instead of a hardcoded count of 10, so `semantic_core` is required and an unapproved extra crate fails closed. - The LineageWeave temporal-context read exchange no longer emits a fabricated `idempotency-key`; that header remains reserved for retryable write/export diff --git a/CLAUDE.md b/CLAUDE.md index 42d14d84..a7a04122 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,10 @@ Read and follow `AGENTS.md` before changing this repository. The repository-wide - Do not convert association, temporal precedence, or document links into causal language without identification evidence. - Do not remove repeated report language with global stopword lists or use TF-IDF/BM25 as inferential weights. Model template, section, copied-text, style, modality, and corpus-background sources explicitly. - Do not treat raw topic proportions as ordinary Euclidean indicators. Use logistic-normal coordinates or valid log-ratio coordinates and propagate posterior uncertainty into ESEM/DSEM. +- Do not treat metric/weak invariance as a latent-mean license. Strong (equal loading and intercept) or strict is required; `#84` `metric` licenses shared metric meaning only. Putnick and Bornstein (2016, PMC5145197 opened 2026-08-19T22:15Z) require scalar invariance before latent-mean comparison; residual invariance is not a prerequisite. Two-observation series have no residual degrees of freedom (`ordinary_least_squares_fit` returns residual variance `0`) and cap at strong/scalar; they still license means. This is two-group OLS, not MGCFA. Meredith (1993) names remain unread labels. +- Do not use the difference quotient as a continuous-time rate. The scalar map is `a = ln(φ) / Δt` on event time. Discrete lags from unequal event intervals are not one coefficient; remap them through that log-rate. Binary64 `exp(a Δt) = 0` is not a discrete lag. A constant predictor's discrete effect is Voelkle et al. (2012, Eq. 12), evaluated as `a_yx (expm1(z) / a_xx)` with `z = a_xx Δt` so a finite result is not lost when `z` overflows to `-∞` or when `a_yx Δt` overflows. When `expm1(z)` overflows at a finite `z`, rewrite in log space; a zero continuous effect is exactly zero; an overflowing `a_yx/a_xx` rewrite term fails closed. The first-order product is the underflow limit of that equation, not the general constant-predictor discrete effect. A time-varying predictor whose sampling interval equals its constancy interval uses Voelkle et al. (2012, Eq. 14): `b* = a_yx Δt`. Unmatched intervals fail closed (Oud & Jansen, 2000, unread). Discrete process noise is Driver et al. (2017, Eq. 3): `Q_Δt = 0.5 q (expm1(z) / a)` with `z = 2 (a Δt)` and `q = G G⊤ ≥ 0`; do not form `2 a` first; `a = 0` and `z → 0` recover `q Δt`; a zero diffusion is exactly zero; an overflowing rewrite scale `0.5 q / a` fails closed; this is not a Kalman filter. `Q_Δt` is `cov(η_t | η_{t-1})`, not `Var(η_t)`. The lagged covariance is `exp(a Δt) p` and the unconditional variance is `exp(2 a Δt) p + Q_Δt` (Driver et al., 2017, Eq. 3–4, pp. 4–5; JSS has no numbered §2.2). A zero diffusion whose `2 (a Δt)` overflows to `+∞` is not a finite `Var(η_t)`. The stationary within-subject variance is the `Δt → ∞` limit of Eq. 4: `-q / (2 a)` for stable `a < 0` (JSS p. 16 `asymDIFFUSION`; §4.3). When `2 a` is finite, form `q / -(2 a)` so `q / a` overflow does not lose a finite result (`q = MAX`, `a = -0.75` → `MAX / 1.5`). When `2 a` overflows, form `(q / a) * -0.5`. Do not form `0.5 q` first (`q = from_bits(1)` underflows). `a ≥ 0` has no finite stationary variance. Finite-interval `Q_Δt` is not that limit. Trait-plus-state variance is `trait + state` and lagged covariance is `trait + exp(a Δt) p` (Driver et al., 2017, §4.3, p. 9). Trait variance is not process noise and not `asymDIFFUSION`. Evolving the summed variance as if it were all state is not that map. This is not RI-CLPM. Observed-indicator variance is `λ² Var(η) + θ` when `MANIFESTTRAITVAR` is zero and `λ² Var(η) + θ + ψ` otherwise (Driver et al., 2017, Eq. 5, p. 5; Table 2, p. 12). Lagged observed covariance is `λ² cov(η_t, η_{t-1}) + ψ`; `MANIFESTVAR` does not enter. Observed-indicator mean is `τ + λ μ` (Driver et al., 2017, Eq. 5; Table 2, p. 12). `MANIFESTMEANS` is `τ`, not `E(y)`. `E(η)` is not `E(y)`. `CINT` is not `MANIFESTMEANS`. `T0MEANS` is not `E(y)`. The discrete latent mean is `μ_t = exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ` (Driver et al., 2017, Eq. 3, p. 4; Table 2, p. 12). `T0MEANS` is not `μ_t`. `CINT` is not that discrete increment. A zero drift is `κ Δt`. Underflow of `exp(a Δt)` to `+0` drops the carried `T0MEANS` and keeps `−κ / a`. The evolved observed mean is `τ + λ μ_t` (Driver et al., 2017, Eq. 5 of that Eq. 3 map). The first-occasion map `τ + λ μ_0` is not `E(y_t)`. `μ_t` is not `E(y_t)`. The contemporaneous time-dependent predictor impulse is `m x` (Driver et al., 2017, Eq. 3 fourth summand; Table 2 `TDPREDEFFECT` is `M`). Form `μ_t` first, then add `m x`. `TDPREDEFFECT` is not `CINT`. `M x` is not `A^{-1}[e^{A Δt} − I] B z` and is not Voelkle et al. (2012, Eq. 14). The §7.2 level-change form is not that impulse. The observed mean of that contemporaneous impulse is `τ + λ(μ_t + m x)` (Driver et al., 2017, Eq. 5 of the Eq. 3 fourth-summand composition). The evolved map `τ + λ μ_t` is not that observed mean. The carry map `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t`. The evolved-plus-impulse latent mean is not `E(y_t)`. The time-independent predictor increment is `A^{-1}[e^{A Δt} − I] B z` (Driver et al., 2017, Eq. 3 second summand; Table 2 `TIPREDEFFECT` is `B`). Form `B z` first, then the discrete intercept map. A zero drift is `B z Δt`. `TIPREDEFFECT` is `B`, not that discrete increment. `A^{-1}[e^{A Δt} − I] B z` is not `CINT`, not `M x`, and not Voelkle et al. (2012, Eq. 14). The observed mean of that increment is `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` (Driver et al., 2017, Eq. 5 of the Eq. 3 printed addend after the `T0MEANS` carry and the `CINT` increment). The evolved map `τ + λ μ_t` is not that observed mean. The contemporaneous map `τ + λ(μ_t + m x)` is not that observed mean. The carry map `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t`. The evolved-plus-increment latent mean is not `E(y_t)`. The within-interval time-dependent impulse carry is `e^{A(t−u)} M x` for `t0 < u < t` (Driver et al., 2017, Eq. 1–2 Green-function integral of Eq. 2; §7.2 dissipation). Form `m x` first, then `e^{a(t−u)} m x`. A zero drift is `m x` with no dissipation. Underflow of `e^{a(t−u)}` to `+0` is vanishing dissipation and is kept. `e^{A(t−u)} M x` is not the contemporaneous Dirac, not `CINT`, not `TIPREDEFFECT`, and not Voelkle et al. (2012, Eq. 14). An impulse at `u = t` is the contemporaneous map. An impulse at `u ≤ t0` is already in `η(t0)`. The observed mean of that carry is `τ + λ(μ_t + e^{a(t−u)} m x)` (Driver et al., 2017, Eq. 5 of the Eq. 1–2 carried latent mean). The evolved map `τ + λ μ_t` is not that observed mean. The contemporaneous map `τ + λ(μ_t + m x)` is not that observed mean when `u ≠ t`. `MANIFESTMEANS` is not `E(y_t)`. The carried latent mean is not `E(y_t)`. The first-occasion time-independent predictor shift is `t0_b z` (Driver et al., 2017, Table 3 `T0TIPREDEFFECT`; Eq. 3 first summand). Form `t0_b z` first, then `e^{a Δt} t0_b z`. Form `μ_t` first, then add that carry. A zero drift is `t0_b z`. Underflow of `e^{a Δt}` to `+0` is a vanishing carry of the first-occasion shift and is kept. `t0_b z` is not `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, and not `M x`. `e^{A Δt} t0_b z` is not `t0_b z`. `T0TIPREDEFFECT` is the coefficient, not the shift. The observed mean of that first-occasion carry is `τ + λ(μ_t + e^{a Δt} t0_b z)` (Driver et al., 2017, Eq. 5 of the Table 3 / Eq. 3 first-summand composition). The evolved map `τ + λ μ_t` is not that observed mean. The process-increment map `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not that observed mean. The contemporaneous map `τ + λ(μ_t + m x)` is not that observed mean. The impulse-carry map `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t0`. The evolved-plus-carry latent mean is not `E(y_t)`. The first-occasion time-dependent predictor shift is `t0_m x0` (Driver et al., 2017, Table 3 `T0TDPREDEFFECT`; Eq. 3 first summand; JSS PDF re-opened 2026-08-20T19:10Z). Form `t0_m x0` first, then `e^{a Δt} t0_m x0`. Form `μ_t` first, then add that carry. A zero drift is `t0_m x0`. Underflow of `e^{a Δt}` to `+0` is a vanishing carry of the first-occasion shift and is kept. `t0_m x0` is not `M x`, not `e^{A(t−u)} M x` for `t0 < u < t`, not `t0_b z`, not `A^{-1}[e^{A Δt} − I] B z`, and not `CINT`. `e^{A Δt} t0_m x0` is not `t0_m x0`. `T0TDPREDEFFECT` is the coefficient, not the shift. An impulse at `u ≤ t0` that used `M` is already in `η(t0)` as `TDPREDEFFECT`, not as `T0TDPREDEFFECT`. The observed mean of that first-occasion TD carry is `τ + λ(μ_t + e^{a Δt} t0_m x0)` (Driver et al., 2017, Eq. 5 of the Table 3 / Eq. 3 first-summand TD composition; JSS PDF re-opened 2026-08-20T19:07Z). The evolved map `τ + λ μ_t` is not that observed mean. The process-increment map `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not that observed mean. The contemporaneous map `τ + λ(μ_t + m x)` is not that observed mean. The impulse-carry map `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t0`. The first-occasion TI map `τ + λ(μ_t + e^{a Δt} t0_b z)` is not that observed mean. The evolved-plus-carry latent mean is not `E(y_t)`. The lasting level-change `CINT` is `κ = −a m x` (Driver et al., 2017, §7.2, pp. 20–21; JSS PDF re-opened 2026-08-20T19:45Z). Form `m x` first, then multiply by `−a`. Stable `a < 0` is required so `−κ / a = m x` is an equilibrium offset. `a ≥ 0` cannot hold a new process mean. `−a m x` is not the dissipating Dirac `m x`, not a free `CINT`, and not `A^{-1}[e^{A Δt} − I] B z`. The extra near-zero-drift latent process also named in §7.2 is a different specification and is not this `CINT` setting. Equation 3 maps that intercept as `(1 − e^{a Δt}) m x` (JSS PDF re-opened 2026-08-20T19:50Z). Form the level-change `CINT` first, then the discrete intercept map. Underflow of `e^{a Δt}` to `+0` keeps `m x`. `(1 − e^{a Δt}) m x` is not `m x`, not `κ`, and not `A^{-1}[e^{A Δt} − I] B z`. The printed §7.2 lasting level change is an extra near-zero-drift latent process (Driver et al., 2017, §7.2, pp. 22–23; JSS PDF re-opened 2026-08-20T23:10Z). `T0MEANS`, `CINT`, `T0VAR`, `DIFFUSION`, and `TRAITVAR` of that process are fixed to 0; `TDPREDEFFECT` on it is fixed to 1; its `DRIFT` diagonal is very close to 0 (printed example `−0.000001`; precisely 0 causes computational problems); the original process is driven by the `DRIFT` coupling `a_{ηξ}`. After a unit identification impulse the scalar contribution is `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` (`ε = a` is `a_{ηξ} x Δt e^{a Δt}`). Form `a_{ηξ} x` first. A zero coupling or zero predictor is exactly zero. `ε ≥ 0` fails closed. That contribution is not `κ = −a m x`, not `(1 − e^{a Δt}) m x`, and not the dissipating Dirac `m x`. The observed mean of that extra-process contribution is `τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a))` (Driver et al., 2017, Eq. 5 of that §7.2 contribution; JSS PDF re-opened 2026-08-21T06:12Z). The extra process has `LAMBDA` 0 and is not an observed indicator. Original indicators load on the original process after the `DRIFT` coupling. The evolved map `τ + λ μ_t` is not that observed mean. The contemporaneous map `τ + λ(μ_t + m x)` is not that observed mean. The contribution is not `E(y_t)`. The evolved-plus-contribution latent mean is not `E(y_t)`. `T0TDPREDEFFECT` on the extra process begins at `t = 0` and uses `Δt = t − t0` for both the original-process evolution and the extra drive. `TDPREDEFFECT` after `t0` uses `t − u` with `t0 < u < t` for the extra drive while `μ_t` still uses `Δt`. The observed mean of that after-t0 extra-process contribution is `τ + λ(μ_t + a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a))` (Driver et al., 2017, Eq. 5 of that §7.2 after-t0 contribution; JSS PDF re-opened 2026-08-21T06:32Z). The first-occasion extra-process observed mean is not that observed mean when `u ≠ t0`. The impulse-carry map `τ + λ(μ_t + e^{a(t−u)} m x)` is a Dirac on the original process and is not that `DRIFT` drive. An impulse at `u = t0` or `u = t` is not interior. The asymptotic time-independent predictor effect is `-B z / a` (Driver et al., 2017, §7.2, pp. 20–21; JSS PDF opened 2026-08-21T13:08Z). Form `B z` first, then divide by `-a`. Stable `a < 0` is required. `a ≥ 0` cannot hold a finite process-mean change. `-B z / a` is not the coefficient `B`, not `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, and not `M x`. The asymptotic time-independent predictor variance is `(B / a)² v` (Driver et al., 2017, §7.2, pp. 20–21 `addedTIPREDVAR`). Form the unit asymptotic effect first, then square, then multiply by `v`. `(B / a)² v` is not `TRAITVAR`, not `asymDIFFUSION`, and not `-B z / a`. The asymptotic continuous intercept is `-κ / a` (Driver et al., 2017, Table 2, p. 12 `asymCINT`; Eq. 3 as `Δt → ∞`; JSS PDF opened 2026-08-21T16:13Z). Form `κ` first, then divide by `-a`. Stable `a < 0` is required. `-κ / a` is not `κ`, not `A^{-1}[e^{A Δt} − I] κ`, not `T0MEANS`, and not `-B z / a`. The p. 16 stationary `T0MEANS` constraint is `-κ / a + −B z / a`. Form the intercept contribution first, then include the TI extra effect, then add. That constrained first-occasion mean is not free `T0MEANS`, not `asymCINT` alone, not `asymTIPREDEFFECT` alone, and not the finite-interval discrete latent mean. Equation 5 of that constrained mean is `τ + λ(−κ / a + −B z / a)` (Driver et al., 2017, §4.3, pp. 9–10; Eq. 5, p. 5; JSS PDF re-opened 2026-08-21T20:07Z). Form the stationary latent mean first, then `τ + λ` of that mean. `τ + λ μ_0` for free `T0MEANS` is not that composition. `τ + λ(−κ / a)` is not that composition when `B z ≠ 0`. `τ + λ μ_t` is not that composition. `MANIFESTMEANS` is not `E(y_0)`. The constrained latent mean is not `E(y_0)`. The p. 16 constrained first-occasion variance `trait + −q / (2 a) + (B / a)² v` is not free `T0VAR`, not `asymDIFFUSION` alone, not `TRAITVAR` alone, not `addedTIPREDVAR` alone, and not the finite-interval discrete latent variance. Eq. 5 of that constrained variance is `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` (JSS PDF re-opened 2026-08-22T03:20Z; form the stationary latent variance first, then `λ² p + θ + ψ`; `λ² p_0` is not that observed variance; `λ²(−q / (2 a)) + θ` is not that observed variance when `TRAITVAR` or `addedTIPREDVAR` is nonzero; `MANIFESTVAR` is not `Var(y_0)`; the constrained latent variance is not `Var(y_0)`). The lagged covariance of that constrained process is `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` (Driver et al., 2017, Eq. 3–4 of §4.3 / p. 16 `T0VAR`; JSS PDF re-opened 2026-08-22T19:13Z). Trait and `addedTIPREDVAR` do not decay with `e^{a Δt}`. Contemporaneous `T0VAR` is not that lagged map. Decaying the constrained total as if it were all state is not that lagged map. Equation 5 of that lagged covariance is `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ`. `Θ` does not enter. Contemporaneous `Var(y_0)` is not that lagged observed covariance. The lagged latent covariance is not that observed covariance. The later-occasion variance of that constrained process is `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v` (Driver et al., 2017, Eq. 3–4 of §4.3 / p. 16 `T0VAR`; JSS PDF re-opened 2026-08-22T23:12Z). Trait and `addedTIPREDVAR` do not enter `Q_Δt`. Under stationarity that composition equals contemporaneous `T0VAR`. Evolving the constrained total as if it were all state is not that later map. The lagged covariance omits `Q_Δt` and is not that later map. `Q_Δt` is not that later map. Equation 5 of that later-occasion variance is `λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ`. The lagged observed covariance omits `Q_Δt` and `θ`. `MANIFESTVAR` is not `Var(y_t)`. The later-occasion latent variance is not `Var(y_t)`. Evolving from that stationary start with `CINT` and `TIPREDEFFECT` stays at the stationary mean. Equation 1 is the latent SDE, not the measurement model. Form `(λ p) λ` then add `θ`, then add `ψ`. `MANIFESTVAR` is `Θ`, not `Var(y)`. `MANIFESTTRAITVAR` is `Ψ_τ`, not `Θ`. `TRAITVAR` is latent and scaled by `λ²`. `Var(η)` is not `Var(y)`. +- Separate cluster means before within-unit lag. CWC plus an event-time lag is not DSEM. Subtracting the person-specific mean from a raw autoregressive series does not isolate the lagged within-person effect (Curran & Bauer, 2011, pp. 607–608); already-centered residuals with irregular event intervals use the exact scalar map. +- Do not treat the CWC cluster-mean coefficient as the between-cluster effect. It is the contextual effect `between − within` (Enders & Tofighi, 2007, Table 2, pp. 124–127). - Never use future-available evidence in historical model fits. - Do not blanket-mask PII when identity/role/linkage is scientifically required. Follow the purpose-bound separation, opaque-ID, encryption, retention, and audit contract in `docs/PRIVACY_DATA_GOVERNANCE.md`. - Treat documents and LLM outputs as untrusted. Model routing/orchestration may vary reasoning effort, decomposition, recursion and roles, but deterministic/statistical gates remain authoritative. diff --git a/Cargo.lock b/Cargo.lock index 330696fc..e10e5441 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1126,6 +1126,10 @@ version = "0.1.0" name = "provider_receipt" version = "0.1.0" +[[package]] +name = "psychometric_core" +version = "0.1.0" + [[package]] name = "psychometric_fit" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8586f16f..cfe6ee51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/location_membership", "crates/prompt_source", "crates/corpus_background", @@ -52,6 +53,7 @@ members = [ "crates/compute_backend", "crates/episode_membership", "crates/membership_target", + "crates/psychometric_core", ] default-members = [ "crates/evidence_core", @@ -65,6 +67,7 @@ default-members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/location_membership", "crates/prompt_source", "crates/corpus_background", @@ -105,6 +108,7 @@ default-members = [ "crates/compute_backend", "crates/episode_membership", "crates/membership_target", + "crates/psychometric_core", ] [workspace.package] diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 7bf758d8..07c2d341 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -36,6 +36,10 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Hourly NIM product-development operations | [`docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md`](docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md) | | Actions workflow fleet audit | [`docs/operations/ACTIONS_WORKFLOW_FLEET.md`](docs/operations/ACTIONS_WORKFLOW_FLEET.md) | | Actions fleet research doctoring | [`docs/research/actions-workflow-fleet.md`](docs/research/actions-workflow-fleet.md) | +| Posterior ESEM/DSEM input-gate doctoring | [`docs/research/posterior-esem-input-gates.md`](docs/research/posterior-esem-input-gates.md) | +| Multilevel/event-time recovery doctoring | [`docs/research/multilevel-event-time-recovery.md`](docs/research/multilevel-event-time-recovery.md) | +| Rubin total-variance doctoring | [`docs/research/rubin-total-variance.md`](docs/research/rubin-total-variance.md) | +| Strong-invariance latent-mean doctoring | [`docs/research/strong-invariance-latent-means.md`](docs/research/strong-invariance-latent-means.md) | | Mention-confidence Brier doctoring | [`docs/research/mention-confidence-brier.md`](docs/research/mention-confidence-brier.md) | | Event-intelligence status-gate doctoring | [`docs/research/event-intelligence-status-gates.md`](docs/research/event-intelligence-status-gates.md) | | VRAM budget / GPU fallback doctoring | [`docs/research/vram-budget-types.md`](docs/research/vram-budget-types.md) | diff --git a/README.md b/README.md index fbed41ea..f58a3487 100644 --- a/README.md +++ b/README.md @@ -29,23 +29,15 @@ compile independently. `longitudinal_core` exposes within/between decomposition and component RMSE APIs; the remaining crates expose no placeholder production APIs, and domain behavior for them begins in Task 2 with immutable evidence identifiers and source records. -This branch establishes the Task 1 Rust workspace and quality-gate foundation. -The twelve bounded crates compile independently but intentionally expose no -The eleven bounded crates compile independently; Task 1 includes the -implemented `encrypted_mapping` crate with AES-256-GCM sealing and -purpose-bound opening, while the remaining domain behavior begins in Task 2 -with immutable evidence identifiers and source records. -The eleven bounded crates compile independently. `derived_sensitivity` inherits -source Restricted/Internal classes onto topic, factor, and relation artifacts -and fails closed on unknown kinds; derivation and blanket PII masking are not -declassification. Other crates still begin domain behavior in Task 2 with -immutable evidence identifiers and source records. - -The eleven bounded crates compile independently but intentionally expose no -placeholder production APIs. Domain behavior begins in Task 2 with immutable -evidence identifiers and source records. +This branch establishes the Rust workspace and quality-gate foundation. The +bounded crates compile independently. Domain crates expose only validated +production APIs; placeholder surfaces are prohibited. ```text +crates/assertion_clock +crates/available_clock +crates/checkpoint_authority +crates/citation_edge crates/evidence_core crates/semantic_core crates/temporal_core @@ -62,34 +54,52 @@ crates/prompt_source crates/corpus_background crates/modality_source crates/copied_text -crates/style_source -crates/stopword_deletion crates/copy_identity -crates/provider_receipt -crates/intake_authorization -crates/summarizes_edge -crates/outcome_order -crates/retrospective_edge -crates/payload_bound -crates/inferred_status -crates/support_edge -crates/system_clock -crates/event_clock -crates/assertion_clock +crates/corpus_background +crates/corpus_split crates/cutoff_clock -crates/available_clock +crates/derived_sensitivity crates/document_clocks -crates/revision_order crates/encrypted_mapping -crates/citation_edge -crates/psychometric_fit -crates/subevent_containment +crates/event_clock +crates/event_core +crates/evidence_core +crates/inferred_status +crates/intake_authorization +crates/interpretation_gateway +crates/location_membership +crates/longitudinal_core +crates/membership_core +crates/membership_target +crates/modality_source +crates/model_selection +crates/network_analysis +crates/operational_log +crates/outcome_order +crates/payload_bound +crates/persistence_postgres crates/prediction_contradiction +crates/prompt_source +crates/provider_receipt +crates/psychometric_core +crates/psychometric_fit +crates/relation_graph +crates/retrospective_edge +crates/revision_order +crates/semantic_core crates/operational_log crates/service_tls -crates/derived_sensitivity -crates/longitudinal_core +crates/stopword_deletion +crates/style_source +crates/subevent_containment +crates/summarizes_edge +crates/support_edge +crates/system_clock +crates/temporal_core +crates/tepp_api +crates/tepp_simulation crates/topic_lineage +crates/validation_core crates/network_analysis crates/interpretation_gateway crates/model_selection @@ -120,7 +130,8 @@ cargo deny check Stable Rust line coverage is measured with `cargo-llvm-cov`. Branch coverage is measured in a separately pinned nightly lane because Rust branch coverage remains an unstable compiler capability. A zero denominator is reported explicitly for -this skeleton-only slice; it must never conceal uncovered production behavior. +any crate whose lane still ships no executable behavior; it must never conceal +uncovered production behavior. ## Normative documents @@ -130,5 +141,12 @@ this skeleton-only slice; it must never conceal uncovered production behavior. - `docs/superpowers/plans/2026-08-05-temporal-event-foundation.md` - `docs/research/standards-and-literature.md` -No release, production-readiness, GPU, database, or statistical-recovery claim is -made by this foundation slice. +Validated statistical-recovery APIs exist only inside `psychometric_core`: OLS +loading recovery on already-mapped coordinates, posterior-draw point estimates, +the Rubin total-variance identity `T = U_bar + (1 + 1/m) B`, CWC/event-time/ +contextual recovery maps, and two-group OLS latent-mean comparison gated behind +typed strong/strict invariance evidence (`LatentMeanComparisonEvidence`; metric, +weak, or configural status cannot reduce to a passing flag). No release, +production-readiness, GPU, or database claim is made by this foundation slice, +and no crate yet implements a full ESEM/DSEM estimator (the two-group OLS +invariance gate is not MGCFA). diff --git a/crates/psychometric_core/Cargo.toml b/crates/psychometric_core/Cargo.toml new file mode 100644 index 00000000..5cb09c8b --- /dev/null +++ b/crates/psychometric_core/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "psychometric_core" +description = "Posterior-aware ESEM/DSEM input gates, multilevel/event-time recovery, CWC contextual effect, Voelkle Eqs. 12 and 14, Driver Eq. 3 TDPRED/TIPRED maps including within-interval impulse carry, Rubin T, and strong-invariance latent means." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true +publish = false + +[dependencies] + +[lints] +workspace = true diff --git a/crates/psychometric_core/src/causality.rs b/crates/psychometric_core/src/causality.rs new file mode 100644 index 00000000..50a1d8fe --- /dev/null +++ b/crates/psychometric_core/src/causality.rs @@ -0,0 +1,65 @@ +//! Refusal of causal language from non-identifying heuristics. + +use crate::error::PsychometricError; + +/// A heuristic that is not, by itself, causal identification. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum CausalHeuristic { + /// Event-time or document-time precedence. + TemporalPrecedence, + /// A citation, revision, or other document link. + DocumentLinkage, + /// TDT-style event tracking or coreference. + EventTracking, + /// A model prediction or schema completion. + ModelPrediction, +} + +impl CausalHeuristic { + /// Stable wire name for the heuristic. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::TemporalPrecedence => "temporal_precedence", + Self::DocumentLinkage => "document_linkage", + Self::EventTracking => "event_tracking", + Self::ModelPrediction => "model_prediction", + } + } +} + +/// Refuse a causal-effect claim that rests only on a non-identifying heuristic. +/// +/// ADR 0005: temporal precedence, document linkage, event tracking, or model +/// prediction alone do not justify causal language. +/// +/// # Errors +/// +/// Always returns [`PsychometricError::CausalUnderidentified`]. +pub fn claim_causal_effect(_heuristic: CausalHeuristic) -> Result<(), PsychometricError> { + Err(PsychometricError::CausalUnderidentified) +} + +#[cfg(test)] +mod tests { + use super::{CausalHeuristic, claim_causal_effect}; + use crate::error::PsychometricError; + + #[test] + fn every_heuristic_is_underidentified() { + assert_eq!( + claim_causal_effect(CausalHeuristic::DocumentLinkage), + Err(PsychometricError::CausalUnderidentified) + ); + assert_eq!( + CausalHeuristic::TemporalPrecedence.as_str(), + "temporal_precedence" + ); + assert_eq!(CausalHeuristic::EventTracking.as_str(), "event_tracking"); + assert_eq!( + CausalHeuristic::ModelPrediction.as_str(), + "model_prediction" + ); + } +} diff --git a/crates/psychometric_core/src/cluster_mean.rs b/crates/psychometric_core/src/cluster_mean.rs new file mode 100644 index 00000000..b32bd6f1 --- /dev/null +++ b/crates/psychometric_core/src/cluster_mean.rs @@ -0,0 +1,439 @@ +//! Cluster-mean within/between OLS and Kish-weighted slopes. +//! +//! This is a two-level OLS decomposition after centering within cluster (CWC). +//! It is not DSEM, not RI-CLPM, and not a random-effects sampler. +//! +//! Enders and Tofighi (2007, Table 2, pp. 124–127) separate the +//! **within-cluster** slope, the **between-cluster** slope, and the +//! **contextual** effect. The CWC cluster-mean coefficient is the contextual +//! effect (`between − within`), not the between-cluster effect. + +use std::collections::BTreeMap; + +use crate::error::PsychometricError; +use crate::indicator::require_finite; +use crate::loading::ordinary_least_squares_slope; + +/// One clustered predictor–outcome pair on already-mapped coordinates. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ClusteredScore { + /// Cluster identity (person, document family, or membership unit). + pub cluster_key: u64, + /// Already-mapped predictor coordinate. + pub predictor: f64, + /// Already-mapped outcome coordinate. + pub outcome: f64, +} + +/// Recovered within-cluster, between-cluster, and contextual OLS slopes. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WithinBetweenSlopes { + /// OLS slope of cluster-mean-centered outcomes on centered predictors. + pub within_slope: f64, + /// OLS slope of cluster-mean outcomes on cluster-mean predictors. + pub between_slope: f64, + /// CWC cluster-mean coefficient: `between_slope - within_slope`. + pub contextual_effect: f64, +} + +/// Recover within-cluster and between-cluster OLS slopes after CWC. +/// +/// Between components use unweighted cluster means. Within components use the +/// stacked cluster-mean-centered residuals. A grand-mean pooled slope is not +/// returned because it confounds the two (Enders & Tofighi, 2007; Curran & +/// Bauer, 2011; Hamaker, Kuiper, & Grasman, 2015). +/// +/// `contextual_effect` is `between_slope − within_slope`. Enders and Tofighi +/// (2007, Table 2, pp. 124–127) show that this is the cluster-mean coefficient +/// under CWC (`γ01` in their Equations 4–5), **not** the between-cluster +/// effect. The between-cluster effect is the cluster-mean-only slope (CGM +/// `γ01` in their Equations 7–8). Adding the CWC contextual coefficient to the +/// within slope recovers the between-cluster effect. This is two-level OLS, not +/// the multilevel maximum-likelihood model they estimate. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidNumericInput`] for empty, singleton, or +/// non-finite rows, [`PsychometricError::InsufficientClusters`] when fewer than +/// two clusters are present, and [`PsychometricError::SingularDesign`] when +/// either the within or the between predictor has zero variance. +pub fn recover_cluster_mean_within_between_slopes( + rows: &[ClusteredScore], +) -> Result { + if rows.len() < 2 { + return Err(PsychometricError::InvalidNumericInput); + } + let mut groups: BTreeMap> = BTreeMap::new(); + for row in rows { + if !row.predictor.is_finite() || !row.outcome.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + groups + .entry(row.cluster_key) + .or_default() + .push((row.predictor, row.outcome)); + } + if groups.len() < 2 { + return Err(PsychometricError::InsufficientClusters); + } + + let mut within_predictors = Vec::new(); + let mut within_outcomes = Vec::new(); + let mut between_predictors = Vec::new(); + let mut between_outcomes = Vec::new(); + for pairs in groups.values() { + let count = pairs.len() as f64; + let mut pred_sum = 0.0_f64; + let mut out_sum = 0.0_f64; + for &(predictor, outcome) in pairs { + pred_sum += predictor; + out_sum += outcome; + } + let pred_mean = pred_sum / count; + let out_mean = out_sum / count; + between_predictors.push(pred_mean); + between_outcomes.push(out_mean); + for &(predictor, outcome) in pairs { + within_predictors.push(predictor - pred_mean); + within_outcomes.push(outcome - out_mean); + } + } + + let within_slope = ordinary_least_squares_slope(&within_predictors, &within_outcomes)?; + let between_slope = ordinary_least_squares_slope(&between_predictors, &between_outcomes)?; + let contextual_effect = contextual_effect_from_slopes(within_slope, between_slope)?; + Ok(WithinBetweenSlopes { + within_slope, + between_slope, + contextual_effect, + }) +} + +/// Enders and Tofighi (2007, p. 127) identity: CWC `γ01 = β_between − β_within`. +/// +/// This helper is crate-visible so overflow of the subtraction can be recovered +/// in unit tests. It is not a random-effects estimator. +pub(crate) fn contextual_effect_from_slopes( + within_slope: f64, + between_slope: f64, +) -> Result { + require_finite(between_slope - within_slope) +} + +/// Kish effective sample size `ESS = (Σ w)² / Σ w²` for non-negative weights. +/// +/// This is the same Kish (1965) formula used by `membership_core`. It is +/// reimplemented here so `psychometric_core` stays standalone. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidWeight`] for empty, negative, non-finite, +/// or all-zero weights. +pub fn kish_effective_sample_size(weights: &[f64]) -> Result { + if weights.is_empty() { + return Err(PsychometricError::InvalidWeight); + } + let mut sum = 0.0_f64; + let mut sum_sq = 0.0_f64; + for &weight in weights { + if !weight.is_finite() || weight < 0.0 { + return Err(PsychometricError::InvalidWeight); + } + sum += weight; + sum_sq += weight * weight; + } + if sum <= 0.0 { + return Err(PsychometricError::InvalidWeight); + } + require_finite((sum * sum) / sum_sq) +} + +/// Weighted least-squares slope using Kish membership/survey weights. +/// +/// The slope is the ordinary WLS estimator. Kish ESS is the information +/// diagnostic, not a second slope. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidNumericInput`] for length or finiteness +/// failures, [`PsychometricError::InvalidWeight`] for invalid weights, and +/// [`PsychometricError::SingularDesign`] when the weighted predictor has zero +/// variance. +pub fn recover_kish_weighted_slope( + predictor: &[f64], + outcome: &[f64], + weights: &[f64], +) -> Result { + if predictor.len() < 2 || predictor.len() != outcome.len() || predictor.len() != weights.len() { + return Err(PsychometricError::InvalidNumericInput); + } + let _ess = kish_effective_sample_size(weights)?; + let mut weight_sum = 0.0_f64; + let mut pred_sum = 0.0_f64; + let mut out_sum = 0.0_f64; + for index in 0..predictor.len() { + let pred = predictor[index]; + let out = outcome[index]; + let weight = weights[index]; + if !pred.is_finite() || !out.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + weight_sum += weight; + pred_sum += weight * pred; + out_sum += weight * out; + } + let pred_mean = pred_sum / weight_sum; + let out_mean = out_sum / weight_sum; + let mut cross = 0.0_f64; + let mut pred_ss = 0.0_f64; + for index in 0..predictor.len() { + let pred_dev = predictor[index] - pred_mean; + let out_dev = outcome[index] - out_mean; + let weight = weights[index]; + cross += weight * pred_dev * out_dev; + pred_ss += weight * pred_dev * pred_dev; + } + if pred_ss <= 0.0 { + return Err(PsychometricError::SingularDesign); + } + require_finite(cross / pred_ss) +} + +#[cfg(test)] +mod tests { + use super::{ + ClusteredScore, contextual_effect_from_slopes, kish_effective_sample_size, + recover_cluster_mean_within_between_slopes, recover_kish_weighted_slope, + }; + use crate::error::PsychometricError; + + #[test] + fn noiseless_cwc_recovers_distinct_within_between_and_contextual() { + let rows = [ + ClusteredScore { + cluster_key: 1, + predictor: 0.0, + outcome: 2.0, + }, + ClusteredScore { + cluster_key: 1, + predictor: 2.0, + outcome: 3.0, + }, + ClusteredScore { + cluster_key: 2, + predictor: 4.0, + outcome: 10.0, + }, + ClusteredScore { + cluster_key: 2, + predictor: 6.0, + outcome: 11.0, + }, + ]; + // cluster 1 mean x=1 y=2.5; cluster 2 mean x=5 y=10.5 → between = 2 + // within: (-1,-0.5),(1,0.5) and (-1,-0.5),(1,0.5) → within = 0.5 + // contextual = 2 − 0.5 = 1.5 (CWC γ01; not the between slope) + let recovered = recover_cluster_mean_within_between_slopes(&rows).expect("cwc"); + assert!((recovered.within_slope - 0.5).abs() < 1e-12); + assert!((recovered.between_slope - 2.0).abs() < 1e-12); + assert!((recovered.contextual_effect - 1.5).abs() < 1e-12); + assert!((recovered.contextual_effect - recovered.between_slope).abs() > 1e-9); + assert!( + ((recovered.contextual_effect + recovered.within_slope) - recovered.between_slope) + .abs() + < 1e-15 + ); + } + + #[test] + fn overflowing_contextual_subtraction_fails_closed() { + assert_eq!( + contextual_effect_from_slopes(-f64::MAX, f64::MAX), + Err(PsychometricError::InvalidNumericInput) + ); + let ok = contextual_effect_from_slopes(0.5, 2.0).expect("finite"); + assert!((ok - 1.5).abs() < 1e-15); + } + + #[test] + fn empty_or_one_cluster_or_nonfinite_rows_fail_closed() { + assert_eq!( + recover_cluster_mean_within_between_slopes(&[]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_cluster_mean_within_between_slopes(&[ClusteredScore { + cluster_key: 1, + predictor: 0.0, + outcome: 1.0, + }]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_cluster_mean_within_between_slopes(&[ + ClusteredScore { + cluster_key: 1, + predictor: 0.0, + outcome: 1.0, + }, + ClusteredScore { + cluster_key: 1, + predictor: 1.0, + outcome: 2.0, + }, + ]), + Err(PsychometricError::InsufficientClusters) + ); + assert_eq!( + recover_cluster_mean_within_between_slopes(&[ + ClusteredScore { + cluster_key: 1, + predictor: f64::NAN, + outcome: 1.0, + }, + ClusteredScore { + cluster_key: 2, + predictor: 1.0, + outcome: 2.0, + }, + ]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_cluster_mean_within_between_slopes(&[ + ClusteredScore { + cluster_key: 1, + predictor: 0.0, + outcome: f64::INFINITY, + }, + ClusteredScore { + cluster_key: 2, + predictor: 1.0, + outcome: 2.0, + }, + ]), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn singular_within_or_between_predictor_fails() { + let no_within = [ + ClusteredScore { + cluster_key: 1, + predictor: 0.0, + outcome: 1.0, + }, + ClusteredScore { + cluster_key: 1, + predictor: 0.0, + outcome: 2.0, + }, + ClusteredScore { + cluster_key: 2, + predictor: 1.0, + outcome: 3.0, + }, + ClusteredScore { + cluster_key: 2, + predictor: 1.0, + outcome: 4.0, + }, + ]; + assert_eq!( + recover_cluster_mean_within_between_slopes(&no_within), + Err(PsychometricError::SingularDesign) + ); + let no_between = [ + ClusteredScore { + cluster_key: 1, + predictor: 0.0, + outcome: 1.0, + }, + ClusteredScore { + cluster_key: 1, + predictor: 2.0, + outcome: 2.0, + }, + ClusteredScore { + cluster_key: 2, + predictor: 0.0, + outcome: 3.0, + }, + ClusteredScore { + cluster_key: 2, + predictor: 2.0, + outcome: 4.0, + }, + ]; + assert_eq!( + recover_cluster_mean_within_between_slopes(&no_between), + Err(PsychometricError::SingularDesign) + ); + } + + #[test] + fn kish_ess_and_weighted_slope_oracles() { + let ess = kish_effective_sample_size(&[1.0, 1.0, 1.0, 1.0]).expect("eq"); + assert!((ess - 4.0).abs() < 1e-12); + let unequal = kish_effective_sample_size(&[1.0, 0.0, 0.0, 0.0]).expect("one"); + assert!((unequal - 1.0).abs() < 1e-12); + let slope = + recover_kish_weighted_slope(&[0.0, 1.0, 2.0], &[0.0, 2.0, 4.0], &[1.0, 1.0, 1.0]) + .expect("wls"); + assert!((slope - 2.0).abs() < 1e-12); + assert_eq!( + kish_effective_sample_size(&[]), + Err(PsychometricError::InvalidWeight) + ); + assert_eq!( + kish_effective_sample_size(&[-0.1]), + Err(PsychometricError::InvalidWeight) + ); + assert_eq!( + kish_effective_sample_size(&[f64::NAN]), + Err(PsychometricError::InvalidWeight) + ); + assert_eq!( + kish_effective_sample_size(&[0.0, 0.0]), + Err(PsychometricError::InvalidWeight) + ); + assert_eq!( + kish_effective_sample_size(&[f64::MAX, f64::MAX]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_kish_weighted_slope(&[0.0], &[1.0], &[1.0]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_kish_weighted_slope(&[0.0, 1.0], &[1.0], &[1.0, 1.0]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_kish_weighted_slope(&[0.0, 1.0], &[1.0, 2.0], &[1.0]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_kish_weighted_slope(&[0.0, f64::NAN], &[1.0, 2.0], &[1.0, 1.0]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_kish_weighted_slope(&[0.0, 1.0], &[1.0, f64::INFINITY], &[1.0, 1.0]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_kish_weighted_slope(&[0.0, 1.0], &[1.0, 2.0], &[-1.0, 1.0]), + Err(PsychometricError::InvalidWeight) + ); + assert_eq!( + recover_kish_weighted_slope(&[1.0, 1.0], &[2.0, 3.0], &[1.0, 1.0]), + Err(PsychometricError::SingularDesign) + ); + assert_eq!( + recover_kish_weighted_slope(&[0.0, f64::MAX], &[0.0, f64::MAX], &[1.0, 1.0]), + Err(PsychometricError::InvalidNumericInput) + ); + } +} diff --git a/crates/psychometric_core/src/construct.rs b/crates/psychometric_core/src/construct.rs new file mode 100644 index 00000000..673cfec4 --- /dev/null +++ b/crates/psychometric_core/src/construct.rs @@ -0,0 +1,275 @@ +//! Construct-class classification and interpretation gates. + +use crate::error::PsychometricError; +use crate::latent_mean::{MeanInvarianceStatus, TwoGroupMeasurement}; + +/// Higher-order construct class before ESEM, composite, or network modeling. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ConstructClass { + /// Reflective indicators of a common latent factor. + Reflective, + /// Formative or composite indicators that define the construct. + Formative, + /// Interacting indicators that belong in a network model. + Network, + /// Insufficient evidence to classify the construct. + Unresolved, +} + +impl ConstructClass { + /// Stable wire name for the construct class. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Reflective => "reflective", + Self::Formative => "formative", + Self::Network => "network", + Self::Unresolved => "unresolved", + } + } + + /// Return whether reflective ESEM/set-ESEM is admissible. + #[must_use] + pub const fn admits_reflective_esem(self) -> bool { + matches!(self, Self::Reflective) + } +} + +/// Interpret a classified construct as reflective. +/// +/// A good global fit statistic is not authority to reinterpret a formative or +/// network structure as reflective (ADR 0005). +/// +/// # Errors +/// +/// Returns [`PsychometricError::FormativeReinterpretationForbidden`] for +/// formative or network classes and +/// [`PsychometricError::UnresolvedConstruct`] when the class is unresolved. +pub fn interpret_as_reflective( + classified: ConstructClass, + global_fit_acceptable: bool, +) -> Result { + match (classified, global_fit_acceptable) { + (ConstructClass::Reflective, true | false) => Ok(ConstructClass::Reflective), + (ConstructClass::Unresolved, true | false) => Err(PsychometricError::UnresolvedConstruct), + (ConstructClass::Formative | ConstructClass::Network, true | false) => { + Err(PsychometricError::FormativeReinterpretationForbidden) + } + } +} + +/// Typed evidence required before a latent-mean or path comparison. +/// +/// The evidence replaces the former bare-boolean gate: an invariance +/// classification cannot be collapsed into a passing flag because the +/// [`MeanInvarianceStatus`] travels inside the evidence and +/// [`compare_latent_means`] re-inspects it against the strong/strict +/// requirement. Metric evidence still licenses shared metric meaning only. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LatentMeanComparisonEvidence { + /// Classified measurement-invariance status backing the comparison. + pub status: MeanInvarianceStatus, + /// Description of what is being compared (constructs, groups, paths). + pub comparison_scope: String, + /// Version label of the fitted model that produced `status`. + pub model_version: String, +} + +impl LatentMeanComparisonEvidence { + /// Build typed evidence from a two-group OLS classification result. + /// + /// Any classified status is accepted here so the evidence records what + /// was actually measured; [`compare_latent_means`] fails closed on + /// insufficient levels. + /// + /// # Errors + /// + /// Returns [`PsychometricError::MalformedInvarianceEvidence`] when either + /// label is empty. + pub fn from_two_group_measurement( + measurement: &TwoGroupMeasurement, + comparison_scope: &str, + model_version: &str, + ) -> Result { + if comparison_scope.is_empty() || model_version.is_empty() { + return Err(PsychometricError::MalformedInvarianceEvidence); + } + Ok(Self { + status: measurement.status, + comparison_scope: String::from(comparison_scope), + model_version: String::from(model_version), + }) + } +} + +/// Permit a latent-mean or path comparison only when typed invariance +/// evidence carries strong or strict status. +/// +/// Configural and metric evidence fail closed; a good global fit or shared +/// metric meaning is not authority to compare latent means. +/// +/// # Errors +/// +/// Returns [`PsychometricError::StrongInvarianceRequired`] when the carried +/// status does not license latent-mean comparison. +pub fn compare_latent_means( + evidence: &LatentMeanComparisonEvidence, +) -> Result<(), PsychometricError> { + if evidence.status.licenses_latent_mean_comparison() { + Ok(()) + } else { + Err(PsychometricError::StrongInvarianceRequired) + } +} + +#[cfg(test)] +mod tests { + use super::{ + ConstructClass, LatentMeanComparisonEvidence, compare_latent_means, interpret_as_reflective, + }; + use crate::error::PsychometricError; + use crate::indicator::IndicatorKind; + use crate::latent_mean::{ + GroupIndicatorSeries, MeanInvarianceStatus, TwoGroupMeasurement, + classify_two_group_ols_invariance, + }; + + fn series(factors: &[f64], intercept: f64, loading: f64) -> GroupIndicatorSeries { + GroupIndicatorSeries { + factor_scores: factors.to_vec(), + indicators: factors + .iter() + .map(|score| intercept + loading * score) + .collect(), + } + } + + fn classify( + reference: &GroupIndicatorSeries, + comparison: &GroupIndicatorSeries, + ) -> TwoGroupMeasurement { + classify_two_group_ols_invariance( + reference, + comparison, + IndicatorKind::AdditiveLogRatio, + 1e-9, + 1e-9, + 1e-9, + ) + .expect("classification succeeds on finite three-point series") + } + + fn evidence_for(status: MeanInvarianceStatus) -> LatentMeanComparisonEvidence { + LatentMeanComparisonEvidence { + status, + comparison_scope: String::from("construct mean across two groups"), + model_version: String::from("construct-tests-v1"), + } + } + + #[test] + fn reflective_only_admits_esem_and_strong_evidence_licenses_means() { + assert!(ConstructClass::Reflective.admits_reflective_esem()); + assert!(!ConstructClass::Formative.admits_reflective_esem()); + compare_latent_means(&evidence_for(MeanInvarianceStatus::Strong)).expect("strong"); + assert_eq!( + interpret_as_reflective(ConstructClass::Reflective, true).expect("fit unused"), + ConstructClass::Reflective + ); + assert_eq!( + interpret_as_reflective(ConstructClass::Network, false), + Err(PsychometricError::FormativeReinterpretationForbidden) + ); + } + + #[test] + fn strict_evidence_and_classified_strong_evidence_pass_the_gate() { + compare_latent_means(&evidence_for(MeanInvarianceStatus::Strict)).expect("strict"); + let reference = series(&[-1.0, 0.0, 1.0], 0.5, 1.2); + let comparison = series(&[1.0, 2.0, 3.0], 0.5, 1.2); + let measurement = classify(&reference, &comparison); + assert_eq!(measurement.status, MeanInvarianceStatus::Strict); + let evidence = LatentMeanComparisonEvidence::from_two_group_measurement( + &measurement, + "two-group OLS latent means", + "construct-tests-v1", + ) + .expect("non-empty labels"); + assert_eq!(evidence.status, MeanInvarianceStatus::Strict); + assert_eq!( + evidence.comparison_scope, + String::from("two-group OLS latent means") + ); + assert_eq!(evidence.model_version, String::from("construct-tests-v1")); + compare_latent_means(&evidence).expect("classified strict licenses means"); + } + + #[test] + fn metric_status_evidence_cannot_reduce_to_a_passing_flag() { + // Hand-assembled metric evidence still fails: no boolean input can + // bypass the carried status. + let hand_built = evidence_for(MeanInvarianceStatus::Metric); + assert_eq!( + compare_latent_means(&hand_built), + Err(PsychometricError::StrongInvarianceRequired) + ); + + let reference = series(&[-1.0, 0.0, 1.0], 0.5, 1.2); + let metric_only = series(&[1.0, 2.0, 3.0], 1.5, 1.2); + let measurement = classify(&reference, &metric_only); + assert_eq!(measurement.status, MeanInvarianceStatus::Metric); + assert!(measurement.status.licenses_shared_metric_meaning()); + let evidence = LatentMeanComparisonEvidence::from_two_group_measurement( + &measurement, + "metric-only two-group comparison", + "construct-tests-v1", + ) + .expect("non-empty labels"); + assert_eq!( + compare_latent_means(&evidence), + Err(PsychometricError::StrongInvarianceRequired) + ); + } + + #[test] + fn configural_status_evidence_is_refused() { + let reference = series(&[-1.0, 0.0, 1.0], 0.5, 1.2); + let configural = series(&[1.0, 2.0, 3.0], 0.5, 0.4); + let measurement = classify(&reference, &configural); + assert_eq!(measurement.status, MeanInvarianceStatus::Configural); + let evidence = LatentMeanComparisonEvidence::from_two_group_measurement( + &measurement, + "configural two-group comparison", + "construct-tests-v1", + ) + .expect("non-empty labels"); + assert_eq!( + compare_latent_means(&evidence), + Err(PsychometricError::StrongInvarianceRequired) + ); + } + + #[test] + fn empty_scope_or_model_version_labels_fail_closed() { + let reference = series(&[-1.0, 0.0, 1.0], 0.5, 1.2); + let comparison = series(&[1.0, 2.0, 3.0], 0.5, 1.2); + let measurement = classify(&reference, &comparison); + assert_eq!( + LatentMeanComparisonEvidence::from_two_group_measurement( + &measurement, + "", + "construct-tests-v1", + ), + Err(PsychometricError::MalformedInvarianceEvidence) + ); + assert_eq!( + LatentMeanComparisonEvidence::from_two_group_measurement( + &measurement, + "two-group OLS latent means", + "", + ), + Err(PsychometricError::MalformedInvarianceEvidence) + ); + } +} diff --git a/crates/psychometric_core/src/error.rs b/crates/psychometric_core/src/error.rs new file mode 100644 index 00000000..a1ddfe25 --- /dev/null +++ b/crates/psychometric_core/src/error.rs @@ -0,0 +1,1518 @@ +//! Fail-closed psychometric input and recovery errors. + +use std::fmt; + +/// A fail-closed psychometric-domain error. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum PsychometricError { + /// Raw simplex proportions were offered as Euclidean indicators. + RawProportionForbidden, + /// Empty, unequal-length, or non-finite numeric input. + InvalidNumericInput, + /// A predictor or indicator vector has zero variance. + SingularDesign, + /// A good global fit was used to reinterpret a formative or network + /// construct as reflective. + FormativeReinterpretationForbidden, + /// Temporal precedence, linkage, tracking, or prediction was treated as + /// causal identification. + CausalUnderidentified, + /// The construct class is unresolved and cannot support a reflective + /// interpretation. + UnresolvedConstruct, + /// A structural lag or local log-rate was requested on a non-event clock. + EventTimeRequired, + /// The Voelkle–Oud difference quotient was offered as a continuous-time + /// rate. + DifferenceQuotientForbidden, + /// Discrete lags from unequal event intervals were treated as one coefficient. + UnequalIntervalPoolingForbidden, + /// A time-varying predictor was mapped with unmatched sampling and + /// constancy intervals. Oud and Jansen (2000) is unread. + UnmatchedTimeVaryingInterval, + /// Fewer than two clusters were supplied for a within/between decomposition. + InsufficientClusters, + /// A membership or survey weight is empty, negative, or non-finite. + InvalidWeight, + /// An event-time interval is non-positive. + NonPositiveInterval, + /// Fewer than two posterior draws were supplied for Rubin combining. + InsufficientDraws, + /// Latent-mean comparison was requested at metric/weak invariance. + StrongInvarianceRequired, + /// Invariance evidence carried an empty comparison-scope or + /// model-version label. + MalformedInvarianceEvidence, + /// Driver Eq. 3 process noise was treated as the unconditional latent + /// variance. `Q_Δt` is `cov(η_ti | η_{t-1,i})`. + ProcessNoiseIsConditionalVariance, + /// Stationary within-subject variance was requested for a non-stable + /// drift. Driver et al. (2017, Eq. 4 as `Δt → ∞`; §4.3; p. 16 + /// `asymDIFFUSION`) require `a < 0`. + StationaryVarianceRequiresStableDrift, + /// Finite-interval Driver Eq. 3 process noise was treated as the + /// asymptotic within-subject variance. `Q_Δt` at a finite `Δt` is not + /// `asymDIFFUSION`. + FiniteIntervalProcessNoiseIsNotStationary, + /// Driver §4.3 trait variance was treated as process noise or diffusion. + /// A stable trait has `DRIFT` and `DIFFUSION` fixed to zero. + TraitVarianceIsNotProcessNoise, + /// Driver §4.3 trait variance was treated as the stationary + /// within-subject variance. `TRAITVAR` is between-subject and + /// time-invariant; `asymDIFFUSION` is the `Δt → ∞` state variance. + TraitVarianceIsNotStationaryWithinSubject, + /// Driver Eq. 5 measurement-error variance was treated as the + /// observed-indicator variance. Table 2 (p. 12) names + /// `MANIFESTVAR` as `Θ`, not `Var(y)`. + MeasurementErrorIsNotObservedVariance, + /// Driver Eq. 5 latent variance was treated as the observed-indicator + /// variance. `Var(η)` is not `Var(y)`. + LatentVarianceIsNotObservedVariance, + /// Driver Eq. 5 `MANIFESTTRAITVAR` was treated as `MANIFESTVAR`. + /// Table 2 (p. 12) names `Ψ_τ` separately from `Θ`. + ManifestTraitVarianceIsNotMeasurementError, + /// Driver Eq. 3–4 lagged latent covariance was treated as the + /// lagged observed-indicator covariance. Equation 5 maps + /// `cov(y_t, y_{t-1}) = λ² cov(η_t, η_{t-1}) + ψ`. + LatentLaggedCovarianceIsNotObservedCovariance, + /// Driver Eq. 5 measurement-error variance was treated as the + /// lagged observed-indicator covariance. Independent `ε` does + /// not enter `cov(y_t, y_{t-1})`. + MeasurementErrorIsNotLaggedObservedCovariance, + /// Driver Eq. 5 `MANIFESTMEANS` was treated as `E(y)`. Table 2 + /// (p. 12) names `τ` the expected intercept `Γ`, not `τ + λ μ`. + ManifestMeansIsNotObservedMean, + /// Driver Eq. 5 latent mean was treated as `E(y)`. `E(η)` is + /// not `τ + λ E(η)`. + LatentMeanIsNotObservedMean, + /// Driver Table 2 `CINT` was treated as `MANIFESTMEANS`. `κ` is + /// the latent continuous intercept, not the expected `Γ`. + ContinuousInterceptIsNotManifestMeans, + /// Driver Table 2 `T0MEANS` was treated as the evolved latent mean. + /// Equation 3 maps `μ_t = exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ`. + InitialLatentMeanIsNotEvolvedMean, + /// Driver Table 2 `CINT` was treated as the discrete mean increment. + /// `κ` is not `A^{-1}[e^{A Δt} − I] κ`. + ContinuousInterceptIsNotDiscreteMeanIncrement, + /// Driver Table 2 `CINT` was treated as `T0MEANS`. `κ` is not + /// the first-occasion latent mean. + ContinuousInterceptIsNotInitialLatentMean, + /// Driver Eq. 5 of the first-occasion mean was treated as + /// `E(y_t)`. `τ + λ μ_0` is not `τ + λ μ_t`. + InitialObservedMeanIsNotEvolvedObservedMean, + /// Driver Eq. 3 fourth-summand impulse was treated as `CINT`. + /// Table 2 names `M` `TDPREDEFFECT`, not `κ`. + TimeDependentImpulseIsNotContinuousIntercept, + /// Driver Eq. 3 fourth-summand impulse was treated as the + /// time-independent discrete effect. `M x` is not + /// `A^{-1}[e^{A Δt} − I] B z`. + TimeDependentImpulseIsNotTimeIndependentEffect, + /// Driver Eq. 3 fourth-summand impulse was treated as Voelkle + /// et al. (2012, Eq. 14). `M x` is not `a_{yx} Δt`. + TimeDependentImpulseIsNotTimeVaryingDiscreteEffect, + /// Driver Eq. 3 time-independent discrete effect was treated as + /// `CINT`. `A^{-1}[e^{A Δt} − I] B z` is not `κ`. + TimeIndependentEffectIsNotContinuousIntercept, + /// Driver Eq. 3 time-independent discrete effect was treated as + /// the fourth-summand impulse. `A^{-1}[e^{A Δt} − I] B z` is not + /// `M x`. + TimeIndependentEffectIsNotTimeDependentImpulse, + /// Driver Eq. 3 time-independent discrete effect was treated as + /// Voelkle et al. (2012, Eq. 14). `A^{-1}[e^{A Δt} − I] B z` is + /// not `a_{yx} Δt`. + TimeIndependentEffectIsNotTimeVaryingDiscreteEffect, + /// Driver Table 2 `TIPREDEFFECT` was treated as the discrete + /// increment. `B` is not `A^{-1}[e^{A Δt} − I] B z`. + TimeIndependentCoefficientIsNotDiscreteEffect, + /// Driver Eq. 1–2 within-interval impulse carry was treated as + /// the contemporaneous Dirac. `e^{A(t−u)} M x` for `t0 < u < t` + /// is not `M x`. + TimeDependentImpulseCarryIsNotContemporaneousImpulse, + /// Driver Eq. 1–2 within-interval impulse carry was treated as + /// `CINT`. `e^{A(t−u)} M x` is not `κ`. + TimeDependentImpulseCarryIsNotContinuousIntercept, + /// Driver Eq. 1–2 within-interval impulse carry was treated as + /// the time-independent discrete effect. `e^{A(t−u)} M x` is not + /// `A^{-1}[e^{A Δt} − I] B z`. + TimeDependentImpulseCarryIsNotTimeIndependentEffect, + /// Driver Eq. 1–2 within-interval impulse carry was treated as + /// Voelkle et al. (2012, Eq. 14). `e^{A(t−u)} M x` is not + /// `a_{yx} Δt`. + TimeDependentImpulseCarryIsNotTimeVaryingDiscreteEffect, + /// Driver Eq. 5 of the Eq. 3 evolved mean was treated as + /// Equation 5 of the Eq. 1–2 carried latent mean. + /// `τ + λ μ_t` is not `τ + λ(μ_t + e^{a(t−u)} m x)`. + EvolvedObservedMeanIsNotImpulseCarryObservedMean, + /// Driver Eq. 5 of the Eq. 3 evolved mean was treated as + /// Equation 5 of the contemporaneous impulse. + /// `τ + λ μ_t` is not `τ + λ(μ_t + m x)`. + EvolvedObservedMeanIsNotImpulseObservedMean, + /// Driver Eq. 5 of the contemporaneous impulse was treated as + /// Equation 5 of the Eq. 1–2 carried latent mean. + /// `τ + λ(μ_t + m x)` is not `τ + λ(μ_t + e^{a(t−u)} m x)`. + ImpulseObservedMeanIsNotImpulseCarryObservedMean, + /// Driver Eq. 5 of the Eq. 3 evolved mean was treated as + /// Equation 5 of the time-independent predictor. + /// `τ + λ μ_t` is not `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`. + EvolvedObservedMeanIsNotTimeIndependentObservedMean, + /// Driver Eq. 5 of the contemporaneous impulse was treated as + /// Equation 5 of the time-independent predictor. + /// `τ + λ(μ_t + m x)` is not `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`. + ImpulseObservedMeanIsNotTimeIndependentObservedMean, + /// Driver Eq. 5 of the Eq. 1–2 carried latent mean was treated as + /// Equation 5 of the time-independent predictor. + /// `τ + λ(μ_t + e^{a(t−u)} m x)` is not + /// `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`. + ImpulseCarryObservedMeanIsNotTimeIndependentObservedMean, + /// Driver Table 3 `T0TIPREDEFFECT` first-occasion shift was treated + /// as the Eq. 3 process increment. `t0_b z` is not + /// `A^{-1}[e^{A Δt} − I] B z`. + InitialTimeIndependentEffectIsNotProcessIncrement, + /// Driver Eq. 3 carry of `T0TIPREDEFFECT` was treated as the + /// first-occasion shift. `e^{A Δt} t0_b z` is not `t0_b z`. + InitialTimeIndependentCarryIsNotInitialEffect, + /// Driver Table 3 `T0TIPREDEFFECT` first-occasion shift was treated + /// as `CINT`. `t0_b z` is not `κ`. + InitialTimeIndependentEffectIsNotContinuousIntercept, + /// Driver Table 3 `T0TIPREDEFFECT` first-occasion shift was treated + /// as the fourth-summand impulse. `t0_b z` is not `M x`. + InitialTimeIndependentEffectIsNotTimeDependentImpulse, + /// Driver Table 3 `T0TIPREDEFFECT` was treated as the first-occasion + /// shift. The coefficient is not `t0_b z`. + InitialTimeIndependentCoefficientIsNotInitialEffect, + /// Driver Eq. 5 of the Eq. 3 evolved mean was treated as + /// Equation 5 of the Table 3 first-occasion TI predictor. + /// `τ + λ μ_t` is not `τ + λ(μ_t + e^{a Δt} t0_b z)`. + EvolvedObservedMeanIsNotInitialTimeIndependentObservedMean, + /// Driver Eq. 5 of the Eq. 3 process increment was treated as + /// Equation 5 of the Table 3 first-occasion TI predictor. + /// `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not + /// `τ + λ(μ_t + e^{a Δt} t0_b z)`. + TimeIndependentObservedMeanIsNotInitialTimeIndependentObservedMean, + /// Driver Eq. 5 of the contemporaneous impulse was treated as + /// Equation 5 of the Table 3 first-occasion TI predictor. + /// `τ + λ(μ_t + m x)` is not `τ + λ(μ_t + e^{a Δt} t0_b z)`. + ImpulseObservedMeanIsNotInitialTimeIndependentObservedMean, + /// Driver Eq. 5 of the Eq. 1–2 carried latent mean was treated as + /// Equation 5 of the Table 3 first-occasion TI predictor. + /// `τ + λ(μ_t + e^{a(t−u)} m x)` is not + /// `τ + λ(μ_t + e^{a Δt} t0_b z)`. + ImpulseCarryObservedMeanIsNotInitialTimeIndependentObservedMean, + /// Driver Table 3 `T0TDPREDEFFECT` first-occasion shift was treated + /// as the contemporaneous Dirac. `t0_m x0` is not `M x`. + InitialTimeDependentEffectIsNotContemporaneousImpulse, + /// Driver Eq. 3 carry of `T0TDPREDEFFECT` was treated as the + /// first-occasion shift. `e^{A Δt} t0_m x0` is not `t0_m x0`. + InitialTimeDependentCarryIsNotInitialEffect, + /// Driver Table 3 `T0TDPREDEFFECT` first-occasion shift was treated + /// as `CINT`. `t0_m x0` is not `κ`. + InitialTimeDependentEffectIsNotContinuousIntercept, + /// Driver Table 3 `T0TDPREDEFFECT` first-occasion shift was treated + /// as the Eq. 3 process increment. `t0_m x0` is not + /// `A^{-1}[e^{A Δt} − I] B z`. + InitialTimeDependentEffectIsNotProcessIncrement, + /// Driver Table 3 `T0TDPREDEFFECT` first-occasion shift was treated + /// as the Table 3 `T0TIPREDEFFECT` shift. `t0_m x0` is not `t0_b z`. + InitialTimeDependentEffectIsNotInitialTimeIndependentEffect, + /// Driver Table 3 `T0TDPREDEFFECT` was treated as the first-occasion + /// shift. The coefficient is not `t0_m x0`. + InitialTimeDependentCoefficientIsNotInitialEffect, + /// Driver Eq. 3 carry of `T0TDPREDEFFECT` was treated as the + /// within-interval impulse carry. `e^{A Δt} t0_m x0` is not + /// `e^{A(t−u)} M x` for `t0 < u < t`. + InitialTimeDependentCarryIsNotImpulseCarry, + /// Driver Eq. 5 of the Eq. 3 evolved mean was treated as + /// Equation 5 of the Table 3 first-occasion TD predictor. + /// `τ + λ μ_t` is not `τ + λ(μ_t + e^{a Δt} t0_m x0)`. + EvolvedObservedMeanIsNotInitialTimeDependentObservedMean, + /// Driver Eq. 5 of the Eq. 3 process increment was treated as + /// Equation 5 of the Table 3 first-occasion TD predictor. + /// `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not + /// `τ + λ(μ_t + e^{a Δt} t0_m x0)`. + TimeIndependentObservedMeanIsNotInitialTimeDependentObservedMean, + /// Driver Eq. 5 of the contemporaneous impulse was treated as + /// Equation 5 of the Table 3 first-occasion TD predictor. + /// `τ + λ(μ_t + m x)` is not `τ + λ(μ_t + e^{a Δt} t0_m x0)`. + ImpulseObservedMeanIsNotInitialTimeDependentObservedMean, + /// Driver Eq. 5 of the Eq. 1–2 carried latent mean was treated as + /// Equation 5 of the Table 3 first-occasion TD predictor. + /// `τ + λ(μ_t + e^{a(t−u)} m x)` is not + /// `τ + λ(μ_t + e^{a Δt} t0_m x0)`. + ImpulseCarryObservedMeanIsNotInitialTimeDependentObservedMean, + /// Driver Eq. 5 of Table 3 `T0TIPREDEFFECT` was treated as + /// Equation 5 of Table 3 `T0TDPREDEFFECT`. + /// `τ + λ(μ_t + e^{a Δt} t0_b z)` is not + /// `τ + λ(μ_t + e^{a Δt} t0_m x0)`. + InitialTimeIndependentObservedMeanIsNotInitialTimeDependentObservedMean, + /// Driver §7.2 level-change `CINT` was requested for a non-stable + /// drift. Lasting level change via `CINT = TDPREDEFFECT × (−DRIFT)` + /// requires `a < 0` so `−κ / a = m x` is an equilibrium offset. + LevelChangeRequiresStableDrift, + /// Driver §7.2 level-change `CINT` was treated as the + /// contemporaneous Dirac. `−a m x` is not `m x`. + LevelChangeInterceptIsNotImpulse, + /// Driver §7.2 level-change `CINT` was treated as a free `CINT`. + /// `−a m x` is not an arbitrary `κ`. + LevelChangeInterceptIsNotFreeContinuousIntercept, + /// Driver §7.2 level-change `CINT` was treated as the Eq. 3 + /// process increment. `−a m x` is not + /// `A^{-1}[e^{A Δt} − I] B z`. + LevelChangeInterceptIsNotProcessIncrement, + /// Driver §7.2 level-change CINT increment was treated as the + /// contemporaneous Dirac. `(1 − e^{a Δt}) m x` is not `m x`. + LevelChangeIncrementIsNotImpulse, + /// Driver §7.2 level-change CINT increment was treated as `CINT`. + /// `(1 − e^{a Δt}) m x` is not `κ = −a m x`. + LevelChangeIncrementIsNotIntercept, + /// Driver §7.2 level-change CINT increment was treated as the + /// Eq. 3 process increment. `(1 − e^{a Δt}) m x` is not + /// `A^{-1}[e^{A Δt} − I] B z`. + LevelChangeIncrementIsNotProcessIncrement, + /// Driver §7.2 extra-process contribution was requested for a + /// non-negative extra drift. Lasting level change via the extra + /// latent process requires `ε < 0`. Precisely `ε = 0` causes + /// computational problems in the printed ctsem specification. + LevelChangeExtraProcessRequiresNegativeDrift, + /// Driver §7.2 extra-process contribution was treated as the + /// contemporaneous Dirac. `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` + /// is not `m x`. + LevelChangeExtraProcessIsNotImpulse, + /// Driver §7.2 extra-process contribution was treated as the + /// level-change `CINT`. `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` + /// is not `κ = −a m x`. + LevelChangeExtraProcessIsNotIntercept, + /// Driver §7.2 extra-process contribution was treated as the + /// Eq. 3 level-change increment. `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` + /// is not `(1 − e^{a Δt}) m x`. + LevelChangeExtraProcessIsNotIncrement, + /// Driver Eq. 5 of the evolved mean was treated as the Eq. 5 + /// extra-process observed mean. `τ + λ μ_t` is not + /// `τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a))`. + EvolvedObservedMeanIsNotExtraProcessObservedMean, + /// Driver Eq. 5 of the contemporaneous impulse was treated as + /// the Eq. 5 extra-process observed mean. `τ + λ(μ_t + m x)` is + /// not `τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a))`. + ImpulseObservedMeanIsNotExtraProcessObservedMean, + /// Driver §7.2 extra-process contribution was treated as `E(y_t)`. + /// The contribution is not `τ + λ` of the evolved-plus-contribution + /// latent mean. The extra process has `LAMBDA` 0 in the printed + /// specification. + ExtraProcessContributionIsNotObservedMean, + /// Driver §7.2 evolved-plus-contribution latent mean was treated + /// as `E(y_t)`. Equation 5 maps `E(y_t) = τ + λ` of that mean. + ExtraProcessLatentMeanIsNotObservedMean, + /// Driver Eq. 5 of the first-occasion extra process was treated + /// as the Eq. 5 after-t0 extra-process observed mean. `T0TDPREDEFFECT` + /// on the extra process uses `Δt = t − t0`. `TDPREDEFFECT` after + /// `t0` uses `t − u` with `t0 < u < t`. + ExtraProcessObservedMeanIsNotAfterExtraProcessObservedMean, + /// Driver Eq. 5 of the evolved mean was treated as the Eq. 5 + /// after-t0 extra-process observed mean. `τ + λ μ_t` is not + /// `τ + λ(μ_t + a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a))`. + EvolvedObservedMeanIsNotAfterExtraProcessObservedMean, + /// Driver Eq. 5 of the impulse carry was treated as the Eq. 5 + /// after-t0 extra-process observed mean. `e^{a(t−u)} m x` is a + /// Dirac on the original process. Extra-process `TDPREDEFFECT` + /// after `t0` drives the original process through `DRIFT`. + ImpulseCarryObservedMeanIsNotAfterExtraProcessObservedMean, + /// Driver §7.2 after-t0 extra-process contribution was treated + /// as `E(y_t)`. The contribution is not `τ + λ` of the + /// evolved-plus-after-contribution latent mean. + AfterExtraProcessContributionIsNotObservedMean, + /// Driver §7.2 evolved-plus-after-contribution latent mean was + /// treated as `E(y_t)`. Equation 5 maps `E(y_t) = τ + λ` of + /// that mean. + AfterExtraProcessLatentMeanIsNotObservedMean, + /// Driver §7.2 `asymTIPREDEFFECT` was requested for a non-stable + /// drift. The expected total change in process means is `-B z / a` + /// and requires `a < 0`. + AsymptoticTimeIndependentEffectRequiresStableDrift, + /// Driver §7.2 `asymTIPREDEFFECT` was treated as `TIPREDEFFECT`. + /// `-B z / a` is not the coefficient `B`. + AsymptoticTimeIndependentEffectIsNotCoefficient, + /// Driver §7.2 `asymTIPREDEFFECT` was treated as the finite-interval + /// discrete increment. `-B z / a` is not + /// `A^{-1}[e^{A Δt} − I] B z`. + AsymptoticTimeIndependentEffectIsNotDiscreteEffect, + /// Driver §7.2 `asymTIPREDEFFECT` was treated as `CINT`. + /// `-B z / a` is not `κ`. + AsymptoticTimeIndependentEffectIsNotContinuousIntercept, + /// Driver §7.2 `asymTIPREDEFFECT` was treated as the contemporaneous + /// Dirac. `-B z / a` is not `M x`. + AsymptoticTimeIndependentEffectIsNotTimeDependentImpulse, + /// Driver §7.2 `addedTIPREDVAR` was treated as `TRAITVAR`. + /// `(B / a)² v` is between-subject variance accounted for by a + /// time-independent predictor, not a zero-drift trait process. + AsymptoticTimeIndependentVarianceIsNotTraitVariance, + /// Driver §7.2 `addedTIPREDVAR` was treated as `asymDIFFUSION`. + /// `(B / a)² v` is not the stationary within-subject variance. + AsymptoticTimeIndependentVarianceIsNotStationaryWithinSubject, + /// Driver §7.2 `addedTIPREDVAR` was treated as `asymTIPREDEFFECT`. + /// `(B / a)² v` is a variance, not the expected total change in + /// process means. + AsymptoticTimeIndependentVarianceIsNotAsymptoticEffect, + /// Driver Table 2 `asymCINT` was requested for a non-stable drift. + /// The expected change in process means for a change in intercept + /// is `-κ / a` and requires `a < 0`. + AsymptoticContinuousInterceptRequiresStableDrift, + /// Driver Table 2 `asymCINT` was treated as `CINT`. + /// `-κ / a` is not `κ`. + AsymptoticContinuousInterceptIsNotContinuousIntercept, + /// Driver Table 2 `asymCINT` was treated as the finite-interval + /// discrete intercept increment. `-κ / a` is not + /// `A^{-1}[e^{A Δt} − I] κ`. + AsymptoticContinuousInterceptIsNotDiscreteIncrement, + /// Driver Table 2 `asymCINT` was treated as `T0MEANS`. + /// `-κ / a` is not the first-occasion latent mean. + AsymptoticContinuousInterceptIsNotInitialLatentMean, + /// Driver Table 2 `asymCINT` was treated as `asymTIPREDEFFECT`. + /// `-κ / a` is the intercept contribution. `-B z / a` is the + /// time-independent predictor contribution. Page 16 of the JSS + /// article notes that a `T0MEANS` stationarity constraint includes + /// time-independent predictors; that composition is not this map. + AsymptoticContinuousInterceptIsNotAsymptoticTimeIndependentEffect, + /// Driver p. 16 stationary `T0MEANS` was treated as free `T0MEANS`. + /// `-κ / a + −B z / a` is the constrained first-occasion mean, not + /// the free first-occasion latent mean. + StationaryInitialLatentMeanIsNotInitialLatentMean, + /// Driver p. 16 stationary `T0MEANS` was treated as `asymCINT`. + /// The constraint includes time-independent predictors. `-κ / a` + /// is not that composition when `B z ≠ 0`. + StationaryInitialLatentMeanIsNotAsymptoticContinuousIntercept, + /// Driver p. 16 stationary `T0MEANS` was treated as + /// `asymTIPREDEFFECT`. The constraint includes the intercept + /// contribution. `-B z / a` is not that composition when `κ ≠ 0`. + StationaryInitialLatentMeanIsNotAsymptoticTimeIndependentEffect, + /// Driver p. 16 stationary `T0MEANS` was treated as a finite- + /// interval discrete latent mean. The constrained first-occasion + /// mean is not `exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ`. + StationaryInitialLatentMeanIsNotDiscreteMean, + /// Driver Eq. 5 of §4.3 stationary `T0MEANS` was treated as + /// `MANIFESTMEANS`. `τ + λ(−κ / a + −B z / a)` is not `τ`. + StationaryInitialObservedMeanIsNotManifestMeans, + /// Driver §4.3 stationary `T0MEANS` was treated as `E(y_0)`. + /// `−κ / a + −B z / a` is the constrained latent mean, not the + /// observed-indicator mean. + StationaryInitialLatentMeanIsNotObservedMean, + /// Driver Eq. 5 of a finite-interval evolved mean was treated as + /// Eq. 5 of §4.3 stationary `T0MEANS`. `τ + λ μ_t` is not + /// `τ + λ(−κ / a + −B z / a)` when the first occasion is + /// constrained. + EvolvedObservedMeanIsNotStationaryInitialObservedMean, + /// Driver Eq. 5 of `asymCINT` was treated as Eq. 5 of §4.3 + /// stationary `T0MEANS`. `τ + λ(−κ / a)` is not + /// `τ + λ(−κ / a + −B z / a)` when `B z ≠ 0`. + AsymptoticContinuousInterceptObservedMeanIsNotStationaryInitialObservedMean, + /// Driver Eq. 5 of free `T0MEANS` was treated as Eq. 5 of §4.3 + /// stationary `T0MEANS`. `τ + λ μ_0` is not + /// `τ + λ(−κ / a + −B z / a)`. + InitialObservedMeanIsNotStationaryInitialObservedMean, + /// Driver §4.3 / p. 16 stationary `T0VAR` was treated as free + /// `T0VAR`. `trait + −q / (2 a) + (B / a)² v` is the constrained + /// first-occasion variance, not the free first-occasion latent + /// variance. + StationaryInitialLatentVarianceIsNotInitialLatentVariance, + /// Driver §4.3 / p. 16 stationary `T0VAR` was treated as + /// `asymDIFFUSION`. The constraint includes trait variance and + /// time-independent predictor variance. `-q / (2 a)` is not that + /// composition when `TRAITVAR` or `addedTIPREDVAR` is nonzero. + StationaryInitialLatentVarianceIsNotStationaryWithinSubject, + /// Driver §4.3 / p. 16 stationary `T0VAR` was treated as + /// `TRAITVAR`. The constraint includes the within-subject + /// process variance and time-independent predictor variance. + StationaryInitialLatentVarianceIsNotTraitVariance, + /// Driver §4.3 / p. 16 stationary `T0VAR` was treated as + /// `addedTIPREDVAR`. The constraint includes trait variance and + /// `asymDIFFUSION`. `(B / a)² v` is not that composition when + /// those contributions are nonzero. + StationaryInitialLatentVarianceIsNotAsymptoticTimeIndependentVariance, + /// Driver §4.3 / p. 16 stationary `T0VAR` was treated as a + /// finite-interval discrete latent variance. + /// `exp(2 a Δt) p + Q_Δt` is not the constrained first-occasion + /// variance. + StationaryInitialLatentVarianceIsNotDiscreteVariance, + /// Driver Eq. 5 of §4.3 stationary `T0VAR` was treated as + /// `MANIFESTVAR`. `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` + /// is not `θ`. + StationaryInitialObservedVarianceIsNotMeasurementError, + /// Driver §4.3 stationary `T0VAR` was treated as `Var(y_0)`. + /// `trait + −q / (2 a) + (B / a)² v` is the constrained latent + /// variance, not the observed-indicator variance. + StationaryInitialLatentVarianceIsNotObservedVariance, + /// Driver Eq. 5 of a finite-interval evolved variance was treated + /// as Eq. 5 of §4.3 stationary `T0VAR`. `λ² Var(η_t) + θ` is not + /// `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` when the first + /// occasion is constrained. + EvolvedObservedVarianceIsNotStationaryInitialObservedVariance, + /// Driver Eq. 5 of `asymDIFFUSION` was treated as Eq. 5 of §4.3 + /// stationary `T0VAR`. `λ²(−q / (2 a)) + θ` is not + /// `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` when `TRAITVAR` + /// or `addedTIPREDVAR` is nonzero. + StationaryWithinSubjectObservedVarianceIsNotStationaryInitialObservedVariance, + /// Driver Eq. 5 of free `T0VAR` was treated as Eq. 5 of §4.3 + /// stationary `T0VAR`. `λ² p_0 + θ` is not + /// `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ`. + InitialObservedVarianceIsNotStationaryInitialObservedVariance, + /// Driver §4.3 lagged stationary covariance was treated as + /// contemporaneous stationary `T0VAR`. + /// `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` is not + /// `trait + −q / (2 a) + (B / a)² v` at a strictly positive lag. + StationaryLaggedLatentCovarianceIsNotStationaryInitialLatentVariance, + /// Driver §4.3 lagged stationary covariance was treated as the + /// decayed total `e^{a Δt}(trait + −q / (2 a) + (B / a)² v)`. + /// Trait variance and `addedTIPREDVAR` do not decay. + StationaryLaggedLatentCovarianceIsNotDecayedStationaryVariance, + /// Driver §4.3 trait-plus-state lagged covariance was treated as + /// lagged stationary `T0VAR`. `trait + e^{a Δt} p` is not that + /// composition when `addedTIPREDVAR` is nonzero. + TraitPlusStateLaggedCovarianceIsNotStationaryLaggedLatentCovariance, + /// Driver §4.3 lagged stationary covariance was treated as + /// lagged observed covariance. Equation 5 maps + /// `cov(y_t, y_{t-1}) = λ²` of that covariance plus `ψ`. + StationaryLaggedLatentCovarianceIsNotObservedCovariance, + /// Driver Eq. 5 measurement error was treated as lagged + /// stationary observed covariance. Independent `ε_t` does not + /// enter `cov(y_t, y_{t-1})`. + MeasurementErrorIsNotStationaryLaggedObservedCovariance, + /// Driver Eq. 5 of contemporaneous stationary `T0VAR` was treated + /// as lagged stationary observed covariance. + /// `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` includes `θ` + /// and is not `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ`. + StationaryInitialObservedVarianceIsNotStationaryLaggedObservedCovariance, + /// Driver §4.3 later-occasion stationary variance was treated as + /// lagged stationary covariance. `e^{2 a Δt} p + Q_Δt` of the + /// within-subject state is not `e^{a Δt} p`. + StationaryLaterLatentVarianceIsNotLaggedCovariance, + /// Driver §4.3 later-occasion stationary variance was treated as + /// the free discrete evolution of the constrained total. + /// Trait variance and `addedTIPREDVAR` do not enter `Q_Δt`. + StationaryLaterLatentVarianceIsNotDiscreteVariance, + /// Driver §4.3 later-occasion stationary variance was treated as + /// finite-interval process noise. `Q_Δt` is the state residual, + /// not `trait + e^{2 a Δt} p + Q_Δt + (B / a)² v`. + StationaryLaterLatentVarianceIsNotProcessNoise, + /// Driver §4.3 later-occasion stationary variance was treated as + /// later-occasion observed variance. Equation 5 maps + /// `Var(y_t) = λ²` of that variance plus `θ + ψ`. + StationaryLaterLatentVarianceIsNotObservedVariance, + /// Driver Eq. 5 measurement error was treated as later-occasion + /// stationary observed variance. `θ` is not + /// `λ²(trait + e^{2 a Δt} p + Q_Δt + (B / a)² v) + θ + ψ`. + MeasurementErrorIsNotStationaryLaterObservedVariance, + /// Driver Eq. 5 of lagged §4.3 stationary `T0VAR` was treated as + /// later-occasion stationary observed variance. Lagged covariance + /// omits `Q_Δt` and `θ`. + StationaryLaggedObservedCovarianceIsNotStationaryLaterObservedVariance, +} + +impl fmt::Display for PsychometricError { + #[allow(clippy::too_many_lines)] + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::RawProportionForbidden => { + "raw topic proportions are forbidden psychometric indicators" + } + Self::InvalidNumericInput => "invalid psychometric numeric input", + Self::SingularDesign => "singular psychometric design matrix", + Self::FormativeReinterpretationForbidden => { + "formative or network constructs cannot be reinterpreted as reflective" + } + Self::CausalUnderidentified => "temporal precedence is not causal identification", + Self::UnresolvedConstruct => "construct class is unresolved", + Self::EventTimeRequired => { + "discrete lag and local log-rate require event time, not another clock" + } + Self::DifferenceQuotientForbidden => { + "the difference quotient is not the local continuous-time rate" + } + Self::UnequalIntervalPoolingForbidden => { + "discrete lags from unequal event intervals are not one coefficient" + } + Self::UnmatchedTimeVaryingInterval => { + "time-varying predictor discrete effect requires matching sampling and constancy intervals" + } + Self::InsufficientClusters => "within/between recovery requires at least two clusters", + Self::InvalidWeight => "invalid non-negative finite psychometric weight", + Self::NonPositiveInterval => "event-time interval must be strictly positive", + Self::InsufficientDraws => { + "Rubin total variance requires at least two complete-data draws" + } + Self::StrongInvarianceRequired => { + "latent-mean comparison requires strong or strict invariance; metric/weak is not enough" + } + Self::MalformedInvarianceEvidence => { + "invariance evidence requires a non-empty comparison scope and model version" + } + Self::ProcessNoiseIsConditionalVariance => { + "discrete process noise is the conditional residual variance, not the unconditional latent variance" + } + Self::StationaryVarianceRequiresStableDrift => { + "stationary within-subject variance requires a stable negative drift" + } + Self::FiniteIntervalProcessNoiseIsNotStationary => { + "finite-interval process noise is not the asymptotic within-subject variance" + } + Self::TraitVarianceIsNotProcessNoise => { + "trait variance is not process noise and is not a diffusion" + } + Self::TraitVarianceIsNotStationaryWithinSubject => { + "trait variance is not the stationary within-subject variance" + } + Self::MeasurementErrorIsNotObservedVariance => { + "measurement-error variance is not the observed-indicator variance" + } + Self::LatentVarianceIsNotObservedVariance => { + "latent variance is not the observed-indicator variance" + } + Self::ManifestTraitVarianceIsNotMeasurementError => { + "manifest-trait variance is not measurement-error variance" + } + Self::LatentLaggedCovarianceIsNotObservedCovariance => { + "lagged latent covariance is not the lagged observed-indicator covariance" + } + Self::MeasurementErrorIsNotLaggedObservedCovariance => { + "measurement-error variance is not the lagged observed-indicator covariance" + } + Self::ManifestMeansIsNotObservedMean => { + "manifest means are not the observed-indicator mean" + } + Self::LatentMeanIsNotObservedMean => "latent mean is not the observed-indicator mean", + Self::ContinuousInterceptIsNotManifestMeans => { + "continuous intercept is not the manifest mean" + } + Self::InitialLatentMeanIsNotEvolvedMean => { + "initial latent mean is not the evolved latent mean" + } + Self::ContinuousInterceptIsNotDiscreteMeanIncrement => { + "continuous intercept is not the discrete mean increment" + } + Self::ContinuousInterceptIsNotInitialLatentMean => { + "continuous intercept is not the initial latent mean" + } + Self::InitialObservedMeanIsNotEvolvedObservedMean => { + "first-occasion observed mean is not the evolved observed mean" + } + Self::TimeDependentImpulseIsNotContinuousIntercept => { + "time-dependent predictor impulse is not the continuous intercept" + } + Self::TimeDependentImpulseIsNotTimeIndependentEffect => { + "time-dependent predictor impulse is not the time-independent discrete effect" + } + Self::TimeDependentImpulseIsNotTimeVaryingDiscreteEffect => { + "time-dependent predictor impulse is not the time-varying discrete effect" + } + Self::TimeIndependentEffectIsNotContinuousIntercept => { + "time-independent predictor effect is not the continuous intercept" + } + Self::TimeIndependentEffectIsNotTimeDependentImpulse => { + "time-independent predictor effect is not the time-dependent impulse" + } + Self::TimeIndependentEffectIsNotTimeVaryingDiscreteEffect => { + "time-independent predictor effect is not the time-varying discrete effect" + } + Self::TimeIndependentCoefficientIsNotDiscreteEffect => { + "time-independent predictor coefficient is not the discrete effect" + } + Self::TimeDependentImpulseCarryIsNotContemporaneousImpulse => { + "time-dependent predictor impulse carry is not the contemporaneous impulse" + } + Self::TimeDependentImpulseCarryIsNotContinuousIntercept => { + "time-dependent predictor impulse carry is not the continuous intercept" + } + Self::TimeDependentImpulseCarryIsNotTimeIndependentEffect => { + "time-dependent predictor impulse carry is not the time-independent discrete effect" + } + Self::TimeDependentImpulseCarryIsNotTimeVaryingDiscreteEffect => { + "time-dependent predictor impulse carry is not the time-varying discrete effect" + } + Self::EvolvedObservedMeanIsNotImpulseCarryObservedMean => { + "evolved observed mean is not the impulse-carry observed mean" + } + Self::EvolvedObservedMeanIsNotImpulseObservedMean => { + "evolved observed mean is not the contemporaneous-impulse observed mean" + } + Self::ImpulseObservedMeanIsNotImpulseCarryObservedMean => { + "contemporaneous-impulse observed mean is not the impulse-carry observed mean" + } + Self::EvolvedObservedMeanIsNotTimeIndependentObservedMean => { + "evolved observed mean is not the time-independent-predictor observed mean" + } + Self::ImpulseObservedMeanIsNotTimeIndependentObservedMean => { + "contemporaneous-impulse observed mean is not the time-independent-predictor observed mean" + } + Self::ImpulseCarryObservedMeanIsNotTimeIndependentObservedMean => { + "impulse-carry observed mean is not the time-independent-predictor observed mean" + } + Self::InitialTimeIndependentEffectIsNotProcessIncrement => { + "first-occasion time-independent predictor shift is not the process increment" + } + Self::InitialTimeIndependentCarryIsNotInitialEffect => { + "carried first-occasion time-independent predictor is not the first-occasion shift" + } + Self::InitialTimeIndependentEffectIsNotContinuousIntercept => { + "first-occasion time-independent predictor shift is not the continuous intercept" + } + Self::InitialTimeIndependentEffectIsNotTimeDependentImpulse => { + "first-occasion time-independent predictor shift is not the time-dependent impulse" + } + Self::InitialTimeIndependentCoefficientIsNotInitialEffect => { + "first-occasion time-independent predictor coefficient is not the first-occasion shift" + } + Self::EvolvedObservedMeanIsNotInitialTimeIndependentObservedMean => { + "evolved observed mean is not the first-occasion time-independent-predictor observed mean" + } + Self::TimeIndependentObservedMeanIsNotInitialTimeIndependentObservedMean => { + "time-independent-predictor observed mean is not the first-occasion time-independent-predictor observed mean" + } + Self::ImpulseObservedMeanIsNotInitialTimeIndependentObservedMean => { + "contemporaneous-impulse observed mean is not the first-occasion time-independent-predictor observed mean" + } + Self::ImpulseCarryObservedMeanIsNotInitialTimeIndependentObservedMean => { + "impulse-carry observed mean is not the first-occasion time-independent-predictor observed mean" + } + Self::InitialTimeDependentEffectIsNotContemporaneousImpulse => { + "first-occasion time-dependent predictor shift is not the contemporaneous impulse" + } + Self::InitialTimeDependentCarryIsNotInitialEffect => { + "carried first-occasion time-dependent predictor is not the first-occasion shift" + } + Self::InitialTimeDependentEffectIsNotContinuousIntercept => { + "first-occasion time-dependent predictor shift is not the continuous intercept" + } + Self::InitialTimeDependentEffectIsNotProcessIncrement => { + "first-occasion time-dependent predictor shift is not the process increment" + } + Self::InitialTimeDependentEffectIsNotInitialTimeIndependentEffect => { + "first-occasion time-dependent predictor shift is not the first-occasion time-independent predictor shift" + } + Self::InitialTimeDependentCoefficientIsNotInitialEffect => { + "first-occasion time-dependent predictor coefficient is not the first-occasion shift" + } + Self::InitialTimeDependentCarryIsNotImpulseCarry => { + "carried first-occasion time-dependent predictor is not the impulse carry" + } + Self::EvolvedObservedMeanIsNotInitialTimeDependentObservedMean => { + "evolved observed mean is not the first-occasion time-dependent-predictor observed mean" + } + Self::TimeIndependentObservedMeanIsNotInitialTimeDependentObservedMean => { + "time-independent-predictor observed mean is not the first-occasion time-dependent-predictor observed mean" + } + Self::ImpulseObservedMeanIsNotInitialTimeDependentObservedMean => { + "contemporaneous-impulse observed mean is not the first-occasion time-dependent-predictor observed mean" + } + Self::ImpulseCarryObservedMeanIsNotInitialTimeDependentObservedMean => { + "impulse-carry observed mean is not the first-occasion time-dependent-predictor observed mean" + } + Self::InitialTimeIndependentObservedMeanIsNotInitialTimeDependentObservedMean => { + "first-occasion time-independent-predictor observed mean is not the first-occasion time-dependent-predictor observed mean" + } + Self::LevelChangeRequiresStableDrift => { + "lasting level-change CINT requires stable negative drift" + } + Self::LevelChangeInterceptIsNotImpulse => { + "level-change CINT is not the contemporaneous impulse" + } + Self::LevelChangeInterceptIsNotFreeContinuousIntercept => { + "level-change CINT is not a free continuous intercept" + } + Self::LevelChangeInterceptIsNotProcessIncrement => { + "level-change CINT is not the time-independent process increment" + } + Self::LevelChangeIncrementIsNotImpulse => { + "level-change CINT increment is not the contemporaneous impulse" + } + Self::LevelChangeIncrementIsNotIntercept => { + "level-change CINT increment is not the level-change intercept" + } + Self::LevelChangeIncrementIsNotProcessIncrement => { + "level-change CINT increment is not the time-independent process increment" + } + Self::LevelChangeExtraProcessRequiresNegativeDrift => { + "lasting level-change extra process requires strictly negative extra drift" + } + Self::LevelChangeExtraProcessIsNotImpulse => { + "level-change extra-process contribution is not the contemporaneous impulse" + } + Self::LevelChangeExtraProcessIsNotIntercept => { + "level-change extra-process contribution is not the level-change intercept" + } + Self::LevelChangeExtraProcessIsNotIncrement => { + "level-change extra-process contribution is not the level-change increment" + } + Self::EvolvedObservedMeanIsNotExtraProcessObservedMean => { + "evolved observed mean is not the extra-process observed mean" + } + Self::ImpulseObservedMeanIsNotExtraProcessObservedMean => { + "contemporaneous-impulse observed mean is not the extra-process observed mean" + } + Self::ExtraProcessContributionIsNotObservedMean => { + "extra-process contribution is not the extra-process observed mean" + } + Self::ExtraProcessLatentMeanIsNotObservedMean => { + "evolved-plus-contribution latent mean is not the extra-process observed mean" + } + Self::ExtraProcessObservedMeanIsNotAfterExtraProcessObservedMean => { + "first-occasion extra-process observed mean is not the after-t0 extra-process observed mean" + } + Self::EvolvedObservedMeanIsNotAfterExtraProcessObservedMean => { + "evolved observed mean is not the after-t0 extra-process observed mean" + } + Self::ImpulseCarryObservedMeanIsNotAfterExtraProcessObservedMean => { + "impulse-carry observed mean is not the after-t0 extra-process observed mean" + } + Self::AfterExtraProcessContributionIsNotObservedMean => { + "after-t0 extra-process contribution is not the after-t0 extra-process observed mean" + } + Self::AfterExtraProcessLatentMeanIsNotObservedMean => { + "evolved-plus-after-contribution latent mean is not the after-t0 extra-process observed mean" + } + Self::AsymptoticTimeIndependentEffectRequiresStableDrift => { + "asymptotic time-independent predictor effect requires a stable negative drift" + } + Self::AsymptoticTimeIndependentEffectIsNotCoefficient => { + "asymptotic time-independent predictor effect is not the TIPREDEFFECT coefficient" + } + Self::AsymptoticTimeIndependentEffectIsNotDiscreteEffect => { + "asymptotic time-independent predictor effect is not the finite-interval discrete increment" + } + Self::AsymptoticTimeIndependentEffectIsNotContinuousIntercept => { + "asymptotic time-independent predictor effect is not the continuous intercept" + } + Self::AsymptoticTimeIndependentEffectIsNotTimeDependentImpulse => { + "asymptotic time-independent predictor effect is not the contemporaneous impulse" + } + Self::AsymptoticTimeIndependentVarianceIsNotTraitVariance => { + "asymptotic time-independent predictor variance is not trait variance" + } + Self::AsymptoticTimeIndependentVarianceIsNotStationaryWithinSubject => { + "asymptotic time-independent predictor variance is not the stationary within-subject variance" + } + Self::AsymptoticTimeIndependentVarianceIsNotAsymptoticEffect => { + "asymptotic time-independent predictor variance is not the expected total change in process means" + } + Self::AsymptoticContinuousInterceptRequiresStableDrift => { + "asymptotic continuous intercept requires a stable negative drift" + } + Self::AsymptoticContinuousInterceptIsNotContinuousIntercept => { + "asymptotic continuous intercept is not the continuous intercept" + } + Self::AsymptoticContinuousInterceptIsNotDiscreteIncrement => { + "asymptotic continuous intercept is not the finite-interval discrete increment" + } + Self::AsymptoticContinuousInterceptIsNotInitialLatentMean => { + "asymptotic continuous intercept is not the first-occasion latent mean" + } + Self::AsymptoticContinuousInterceptIsNotAsymptoticTimeIndependentEffect => { + "asymptotic continuous intercept is not the asymptotic time-independent predictor effect" + } + Self::StationaryInitialLatentMeanIsNotInitialLatentMean => { + "stationary first-occasion latent mean is not the free first-occasion latent mean" + } + Self::StationaryInitialLatentMeanIsNotAsymptoticContinuousIntercept => { + "stationary first-occasion latent mean is not the asymptotic continuous intercept" + } + Self::StationaryInitialLatentMeanIsNotAsymptoticTimeIndependentEffect => { + "stationary first-occasion latent mean is not the asymptotic time-independent predictor effect" + } + Self::StationaryInitialLatentMeanIsNotDiscreteMean => { + "stationary first-occasion latent mean is not the finite-interval discrete latent mean" + } + Self::StationaryInitialObservedMeanIsNotManifestMeans => { + "stationary first-occasion observed mean is not the manifest mean" + } + Self::StationaryInitialLatentMeanIsNotObservedMean => { + "stationary first-occasion latent mean is not the first-occasion observed mean" + } + Self::EvolvedObservedMeanIsNotStationaryInitialObservedMean => { + "evolved observed mean is not the stationary first-occasion observed mean" + } + Self::AsymptoticContinuousInterceptObservedMeanIsNotStationaryInitialObservedMean => { + "asymptotic-intercept observed mean is not the stationary first-occasion observed mean" + } + Self::InitialObservedMeanIsNotStationaryInitialObservedMean => { + "free first-occasion observed mean is not the stationary first-occasion observed mean" + } + Self::StationaryInitialLatentVarianceIsNotInitialLatentVariance => { + "stationary first-occasion latent variance is not the free first-occasion latent variance" + } + Self::StationaryInitialLatentVarianceIsNotStationaryWithinSubject => { + "stationary first-occasion latent variance is not the asymptotic within-subject variance" + } + Self::StationaryInitialLatentVarianceIsNotTraitVariance => { + "stationary first-occasion latent variance is not the trait variance" + } + Self::StationaryInitialLatentVarianceIsNotAsymptoticTimeIndependentVariance => { + "stationary first-occasion latent variance is not the asymptotic time-independent predictor variance" + } + Self::StationaryInitialLatentVarianceIsNotDiscreteVariance => { + "stationary first-occasion latent variance is not the finite-interval discrete latent variance" + } + Self::StationaryInitialObservedVarianceIsNotMeasurementError => { + "stationary first-occasion observed variance is not the measurement-error variance" + } + Self::StationaryInitialLatentVarianceIsNotObservedVariance => { + "stationary first-occasion latent variance is not the first-occasion observed variance" + } + Self::EvolvedObservedVarianceIsNotStationaryInitialObservedVariance => { + "evolved observed variance is not the stationary first-occasion observed variance" + } + Self::StationaryWithinSubjectObservedVarianceIsNotStationaryInitialObservedVariance => { + "asymptotic-within-subject observed variance is not the stationary first-occasion observed variance" + } + Self::InitialObservedVarianceIsNotStationaryInitialObservedVariance => { + "free first-occasion observed variance is not the stationary first-occasion observed variance" + } + Self::StationaryLaggedLatentCovarianceIsNotStationaryInitialLatentVariance => { + "stationary lagged latent covariance is not the stationary first-occasion latent variance" + } + Self::StationaryLaggedLatentCovarianceIsNotDecayedStationaryVariance => { + "stationary lagged latent covariance is not the decayed stationary variance" + } + Self::TraitPlusStateLaggedCovarianceIsNotStationaryLaggedLatentCovariance => { + "trait-plus-state lagged covariance is not the stationary lagged latent covariance" + } + Self::StationaryLaggedLatentCovarianceIsNotObservedCovariance => { + "stationary lagged latent covariance is not the lagged observed covariance" + } + Self::MeasurementErrorIsNotStationaryLaggedObservedCovariance => { + "measurement-error variance is not the stationary lagged observed covariance" + } + Self::StationaryInitialObservedVarianceIsNotStationaryLaggedObservedCovariance => { + "stationary first-occasion observed variance is not the stationary lagged observed covariance" + } + Self::StationaryLaterLatentVarianceIsNotLaggedCovariance => { + "stationary later-occasion latent variance is not the stationary lagged latent covariance" + } + Self::StationaryLaterLatentVarianceIsNotDiscreteVariance => { + "stationary later-occasion latent variance is not the free discrete latent variance" + } + Self::StationaryLaterLatentVarianceIsNotProcessNoise => { + "stationary later-occasion latent variance is not the finite-interval process noise" + } + Self::StationaryLaterLatentVarianceIsNotObservedVariance => { + "stationary later-occasion latent variance is not the later-occasion observed variance" + } + Self::MeasurementErrorIsNotStationaryLaterObservedVariance => { + "measurement-error variance is not the stationary later-occasion observed variance" + } + Self::StationaryLaggedObservedCovarianceIsNotStationaryLaterObservedVariance => { + "stationary lagged observed covariance is not the stationary later-occasion observed variance" + } + }; + formatter.write_str(message) + } +} + +impl std::error::Error for PsychometricError {} + +#[cfg(test)] +mod tests { + use super::PsychometricError; + + #[test] + fn messages_are_stable() { + assert_eq!( + PsychometricError::RawProportionForbidden.to_string(), + "raw topic proportions are forbidden psychometric indicators" + ); + assert_eq!( + PsychometricError::InvalidNumericInput.to_string(), + "invalid psychometric numeric input" + ); + assert_eq!( + PsychometricError::SingularDesign.to_string(), + "singular psychometric design matrix" + ); + assert_eq!( + PsychometricError::FormativeReinterpretationForbidden.to_string(), + "formative or network constructs cannot be reinterpreted as reflective" + ); + assert_eq!( + PsychometricError::CausalUnderidentified.to_string(), + "temporal precedence is not causal identification" + ); + assert_eq!( + PsychometricError::UnresolvedConstruct.to_string(), + "construct class is unresolved" + ); + assert_eq!( + PsychometricError::EventTimeRequired.to_string(), + "discrete lag and local log-rate require event time, not another clock" + ); + assert_eq!( + PsychometricError::DifferenceQuotientForbidden.to_string(), + "the difference quotient is not the local continuous-time rate" + ); + assert_eq!( + PsychometricError::UnequalIntervalPoolingForbidden.to_string(), + "discrete lags from unequal event intervals are not one coefficient" + ); + assert_eq!( + PsychometricError::UnmatchedTimeVaryingInterval.to_string(), + "time-varying predictor discrete effect requires matching sampling and constancy intervals" + ); + assert_eq!( + PsychometricError::InsufficientClusters.to_string(), + "within/between recovery requires at least two clusters" + ); + assert_eq!( + PsychometricError::InvalidWeight.to_string(), + "invalid non-negative finite psychometric weight" + ); + assert_eq!( + PsychometricError::NonPositiveInterval.to_string(), + "event-time interval must be strictly positive" + ); + assert_eq!( + PsychometricError::InsufficientDraws.to_string(), + "Rubin total variance requires at least two complete-data draws" + ); + assert_eq!( + PsychometricError::StrongInvarianceRequired.to_string(), + "latent-mean comparison requires strong or strict invariance; metric/weak is not enough" + ); + assert_eq!( + PsychometricError::MalformedInvarianceEvidence.to_string(), + "invariance evidence requires a non-empty comparison scope and model version" + ); + assert_eq!( + PsychometricError::ProcessNoiseIsConditionalVariance.to_string(), + "discrete process noise is the conditional residual variance, not the unconditional latent variance" + ); + assert_eq!( + PsychometricError::StationaryVarianceRequiresStableDrift.to_string(), + "stationary within-subject variance requires a stable negative drift" + ); + assert_eq!( + PsychometricError::FiniteIntervalProcessNoiseIsNotStationary.to_string(), + "finite-interval process noise is not the asymptotic within-subject variance" + ); + assert_eq!( + PsychometricError::TraitVarianceIsNotProcessNoise.to_string(), + "trait variance is not process noise and is not a diffusion" + ); + assert_eq!( + PsychometricError::TraitVarianceIsNotStationaryWithinSubject.to_string(), + "trait variance is not the stationary within-subject variance" + ); + } + + #[test] + fn observed_indicator_boundary_messages_are_stable() { + assert_eq!( + PsychometricError::MeasurementErrorIsNotObservedVariance.to_string(), + "measurement-error variance is not the observed-indicator variance" + ); + assert_eq!( + PsychometricError::LatentVarianceIsNotObservedVariance.to_string(), + "latent variance is not the observed-indicator variance" + ); + assert_eq!( + PsychometricError::ManifestTraitVarianceIsNotMeasurementError.to_string(), + "manifest-trait variance is not measurement-error variance" + ); + assert_eq!( + PsychometricError::LatentLaggedCovarianceIsNotObservedCovariance.to_string(), + "lagged latent covariance is not the lagged observed-indicator covariance" + ); + assert_eq!( + PsychometricError::MeasurementErrorIsNotLaggedObservedCovariance.to_string(), + "measurement-error variance is not the lagged observed-indicator covariance" + ); + assert_eq!( + PsychometricError::ManifestMeansIsNotObservedMean.to_string(), + "manifest means are not the observed-indicator mean" + ); + assert_eq!( + PsychometricError::LatentMeanIsNotObservedMean.to_string(), + "latent mean is not the observed-indicator mean" + ); + assert_eq!( + PsychometricError::ContinuousInterceptIsNotManifestMeans.to_string(), + "continuous intercept is not the manifest mean" + ); + assert_eq!( + PsychometricError::InitialLatentMeanIsNotEvolvedMean.to_string(), + "initial latent mean is not the evolved latent mean" + ); + assert_eq!( + PsychometricError::ContinuousInterceptIsNotDiscreteMeanIncrement.to_string(), + "continuous intercept is not the discrete mean increment" + ); + assert_eq!( + PsychometricError::ContinuousInterceptIsNotInitialLatentMean.to_string(), + "continuous intercept is not the initial latent mean" + ); + assert_eq!( + PsychometricError::InitialObservedMeanIsNotEvolvedObservedMean.to_string(), + "first-occasion observed mean is not the evolved observed mean" + ); + } + + #[test] + fn time_dependent_impulse_boundary_messages_are_stable() { + assert_eq!( + PsychometricError::TimeDependentImpulseIsNotContinuousIntercept.to_string(), + "time-dependent predictor impulse is not the continuous intercept" + ); + assert_eq!( + PsychometricError::TimeDependentImpulseIsNotTimeIndependentEffect.to_string(), + "time-dependent predictor impulse is not the time-independent discrete effect" + ); + assert_eq!( + PsychometricError::TimeDependentImpulseIsNotTimeVaryingDiscreteEffect.to_string(), + "time-dependent predictor impulse is not the time-varying discrete effect" + ); + assert_eq!( + PsychometricError::TimeIndependentEffectIsNotContinuousIntercept.to_string(), + "time-independent predictor effect is not the continuous intercept" + ); + assert_eq!( + PsychometricError::TimeIndependentEffectIsNotTimeDependentImpulse.to_string(), + "time-independent predictor effect is not the time-dependent impulse" + ); + assert_eq!( + PsychometricError::TimeIndependentEffectIsNotTimeVaryingDiscreteEffect.to_string(), + "time-independent predictor effect is not the time-varying discrete effect" + ); + assert_eq!( + PsychometricError::TimeIndependentCoefficientIsNotDiscreteEffect.to_string(), + "time-independent predictor coefficient is not the discrete effect" + ); + assert_eq!( + PsychometricError::TimeDependentImpulseCarryIsNotContemporaneousImpulse.to_string(), + "time-dependent predictor impulse carry is not the contemporaneous impulse" + ); + assert_eq!( + PsychometricError::TimeDependentImpulseCarryIsNotContinuousIntercept.to_string(), + "time-dependent predictor impulse carry is not the continuous intercept" + ); + assert_eq!( + PsychometricError::TimeDependentImpulseCarryIsNotTimeIndependentEffect.to_string(), + "time-dependent predictor impulse carry is not the time-independent discrete effect" + ); + assert_eq!( + PsychometricError::TimeDependentImpulseCarryIsNotTimeVaryingDiscreteEffect.to_string(), + "time-dependent predictor impulse carry is not the time-varying discrete effect" + ); + assert_eq!( + PsychometricError::EvolvedObservedMeanIsNotImpulseCarryObservedMean.to_string(), + "evolved observed mean is not the impulse-carry observed mean" + ); + assert_eq!( + PsychometricError::EvolvedObservedMeanIsNotImpulseObservedMean.to_string(), + "evolved observed mean is not the contemporaneous-impulse observed mean" + ); + assert_eq!( + PsychometricError::ImpulseObservedMeanIsNotImpulseCarryObservedMean.to_string(), + "contemporaneous-impulse observed mean is not the impulse-carry observed mean" + ); + assert_eq!( + PsychometricError::EvolvedObservedMeanIsNotTimeIndependentObservedMean.to_string(), + "evolved observed mean is not the time-independent-predictor observed mean" + ); + assert_eq!( + PsychometricError::ImpulseObservedMeanIsNotTimeIndependentObservedMean.to_string(), + "contemporaneous-impulse observed mean is not the time-independent-predictor observed mean" + ); + assert_eq!( + PsychometricError::ImpulseCarryObservedMeanIsNotTimeIndependentObservedMean.to_string(), + "impulse-carry observed mean is not the time-independent-predictor observed mean" + ); + assert_eq!( + PsychometricError::InitialTimeIndependentEffectIsNotProcessIncrement.to_string(), + "first-occasion time-independent predictor shift is not the process increment" + ); + assert_eq!( + PsychometricError::InitialTimeIndependentCarryIsNotInitialEffect.to_string(), + "carried first-occasion time-independent predictor is not the first-occasion shift" + ); + assert_eq!( + PsychometricError::InitialTimeIndependentEffectIsNotContinuousIntercept.to_string(), + "first-occasion time-independent predictor shift is not the continuous intercept" + ); + assert_eq!( + PsychometricError::InitialTimeIndependentEffectIsNotTimeDependentImpulse.to_string(), + "first-occasion time-independent predictor shift is not the time-dependent impulse" + ); + assert_eq!( + PsychometricError::InitialTimeIndependentCoefficientIsNotInitialEffect.to_string(), + "first-occasion time-independent predictor coefficient is not the first-occasion shift" + ); + } + + #[test] + fn initial_time_independent_observed_mean_boundary_messages_are_stable() { + assert_eq!( + PsychometricError::EvolvedObservedMeanIsNotInitialTimeIndependentObservedMean + .to_string(), + "evolved observed mean is not the first-occasion time-independent-predictor observed mean" + ); + assert_eq!( + PsychometricError::TimeIndependentObservedMeanIsNotInitialTimeIndependentObservedMean + .to_string(), + "time-independent-predictor observed mean is not the first-occasion time-independent-predictor observed mean" + ); + assert_eq!( + PsychometricError::ImpulseObservedMeanIsNotInitialTimeIndependentObservedMean + .to_string(), + "contemporaneous-impulse observed mean is not the first-occasion time-independent-predictor observed mean" + ); + assert_eq!( + PsychometricError::ImpulseCarryObservedMeanIsNotInitialTimeIndependentObservedMean + .to_string(), + "impulse-carry observed mean is not the first-occasion time-independent-predictor observed mean" + ); + assert_eq!( + PsychometricError::InitialTimeDependentEffectIsNotContemporaneousImpulse.to_string(), + "first-occasion time-dependent predictor shift is not the contemporaneous impulse" + ); + assert_eq!( + PsychometricError::InitialTimeDependentCarryIsNotInitialEffect.to_string(), + "carried first-occasion time-dependent predictor is not the first-occasion shift" + ); + assert_eq!( + PsychometricError::InitialTimeDependentEffectIsNotContinuousIntercept.to_string(), + "first-occasion time-dependent predictor shift is not the continuous intercept" + ); + assert_eq!( + PsychometricError::InitialTimeDependentEffectIsNotProcessIncrement.to_string(), + "first-occasion time-dependent predictor shift is not the process increment" + ); + assert_eq!( + PsychometricError::InitialTimeDependentEffectIsNotInitialTimeIndependentEffect + .to_string(), + "first-occasion time-dependent predictor shift is not the first-occasion time-independent predictor shift" + ); + assert_eq!( + PsychometricError::InitialTimeDependentCoefficientIsNotInitialEffect.to_string(), + "first-occasion time-dependent predictor coefficient is not the first-occasion shift" + ); + assert_eq!( + PsychometricError::InitialTimeDependentCarryIsNotImpulseCarry.to_string(), + "carried first-occasion time-dependent predictor is not the impulse carry" + ); + assert_eq!( + PsychometricError::EvolvedObservedMeanIsNotInitialTimeDependentObservedMean.to_string(), + "evolved observed mean is not the first-occasion time-dependent-predictor observed mean" + ); + assert_eq!( + PsychometricError::TimeIndependentObservedMeanIsNotInitialTimeDependentObservedMean + .to_string(), + "time-independent-predictor observed mean is not the first-occasion time-dependent-predictor observed mean" + ); + assert_eq!( + PsychometricError::ImpulseObservedMeanIsNotInitialTimeDependentObservedMean.to_string(), + "contemporaneous-impulse observed mean is not the first-occasion time-dependent-predictor observed mean" + ); + assert_eq!( + PsychometricError::ImpulseCarryObservedMeanIsNotInitialTimeDependentObservedMean + .to_string(), + "impulse-carry observed mean is not the first-occasion time-dependent-predictor observed mean" + ); + assert_eq!( + PsychometricError::InitialTimeIndependentObservedMeanIsNotInitialTimeDependentObservedMean + .to_string(), + "first-occasion time-independent-predictor observed mean is not the first-occasion time-dependent-predictor observed mean" + ); + } + + #[test] + fn level_change_boundary_messages_are_stable() { + assert_eq!( + PsychometricError::LevelChangeRequiresStableDrift.to_string(), + "lasting level-change CINT requires stable negative drift" + ); + assert_eq!( + PsychometricError::LevelChangeInterceptIsNotImpulse.to_string(), + "level-change CINT is not the contemporaneous impulse" + ); + assert_eq!( + PsychometricError::LevelChangeInterceptIsNotFreeContinuousIntercept.to_string(), + "level-change CINT is not a free continuous intercept" + ); + assert_eq!( + PsychometricError::LevelChangeInterceptIsNotProcessIncrement.to_string(), + "level-change CINT is not the time-independent process increment" + ); + assert_eq!( + PsychometricError::LevelChangeIncrementIsNotImpulse.to_string(), + "level-change CINT increment is not the contemporaneous impulse" + ); + assert_eq!( + PsychometricError::LevelChangeIncrementIsNotIntercept.to_string(), + "level-change CINT increment is not the level-change intercept" + ); + assert_eq!( + PsychometricError::LevelChangeIncrementIsNotProcessIncrement.to_string(), + "level-change CINT increment is not the time-independent process increment" + ); + assert_eq!( + PsychometricError::LevelChangeExtraProcessRequiresNegativeDrift.to_string(), + "lasting level-change extra process requires strictly negative extra drift" + ); + assert_eq!( + PsychometricError::LevelChangeExtraProcessIsNotImpulse.to_string(), + "level-change extra-process contribution is not the contemporaneous impulse" + ); + assert_eq!( + PsychometricError::LevelChangeExtraProcessIsNotIntercept.to_string(), + "level-change extra-process contribution is not the level-change intercept" + ); + assert_eq!( + PsychometricError::LevelChangeExtraProcessIsNotIncrement.to_string(), + "level-change extra-process contribution is not the level-change increment" + ); + assert_eq!( + PsychometricError::EvolvedObservedMeanIsNotExtraProcessObservedMean.to_string(), + "evolved observed mean is not the extra-process observed mean" + ); + assert_eq!( + PsychometricError::ImpulseObservedMeanIsNotExtraProcessObservedMean.to_string(), + "contemporaneous-impulse observed mean is not the extra-process observed mean" + ); + assert_eq!( + PsychometricError::ExtraProcessContributionIsNotObservedMean.to_string(), + "extra-process contribution is not the extra-process observed mean" + ); + assert_eq!( + PsychometricError::ExtraProcessLatentMeanIsNotObservedMean.to_string(), + "evolved-plus-contribution latent mean is not the extra-process observed mean" + ); + assert_eq!( + PsychometricError::ExtraProcessObservedMeanIsNotAfterExtraProcessObservedMean + .to_string(), + "first-occasion extra-process observed mean is not the after-t0 extra-process observed mean" + ); + assert_eq!( + PsychometricError::EvolvedObservedMeanIsNotAfterExtraProcessObservedMean.to_string(), + "evolved observed mean is not the after-t0 extra-process observed mean" + ); + assert_eq!( + PsychometricError::ImpulseCarryObservedMeanIsNotAfterExtraProcessObservedMean + .to_string(), + "impulse-carry observed mean is not the after-t0 extra-process observed mean" + ); + assert_eq!( + PsychometricError::AfterExtraProcessContributionIsNotObservedMean.to_string(), + "after-t0 extra-process contribution is not the after-t0 extra-process observed mean" + ); + assert_eq!( + PsychometricError::AfterExtraProcessLatentMeanIsNotObservedMean.to_string(), + "evolved-plus-after-contribution latent mean is not the after-t0 extra-process observed mean" + ); + } + + #[test] + fn asymptotic_time_independent_boundary_messages_are_stable() { + assert_eq!( + PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift.to_string(), + "asymptotic time-independent predictor effect requires a stable negative drift" + ); + assert_eq!( + PsychometricError::AsymptoticTimeIndependentEffectIsNotCoefficient.to_string(), + "asymptotic time-independent predictor effect is not the TIPREDEFFECT coefficient" + ); + assert_eq!( + PsychometricError::AsymptoticTimeIndependentEffectIsNotDiscreteEffect.to_string(), + "asymptotic time-independent predictor effect is not the finite-interval discrete increment" + ); + assert_eq!( + PsychometricError::AsymptoticTimeIndependentEffectIsNotContinuousIntercept.to_string(), + "asymptotic time-independent predictor effect is not the continuous intercept" + ); + assert_eq!( + PsychometricError::AsymptoticTimeIndependentEffectIsNotTimeDependentImpulse.to_string(), + "asymptotic time-independent predictor effect is not the contemporaneous impulse" + ); + assert_eq!( + PsychometricError::AsymptoticTimeIndependentVarianceIsNotTraitVariance.to_string(), + "asymptotic time-independent predictor variance is not trait variance" + ); + assert_eq!( + PsychometricError::AsymptoticTimeIndependentVarianceIsNotStationaryWithinSubject + .to_string(), + "asymptotic time-independent predictor variance is not the stationary within-subject variance" + ); + assert_eq!( + PsychometricError::AsymptoticTimeIndependentVarianceIsNotAsymptoticEffect.to_string(), + "asymptotic time-independent predictor variance is not the expected total change in process means" + ); + assert_eq!( + PsychometricError::AsymptoticContinuousInterceptRequiresStableDrift.to_string(), + "asymptotic continuous intercept requires a stable negative drift" + ); + assert_eq!( + PsychometricError::AsymptoticContinuousInterceptIsNotContinuousIntercept.to_string(), + "asymptotic continuous intercept is not the continuous intercept" + ); + assert_eq!( + PsychometricError::AsymptoticContinuousInterceptIsNotDiscreteIncrement.to_string(), + "asymptotic continuous intercept is not the finite-interval discrete increment" + ); + assert_eq!( + PsychometricError::AsymptoticContinuousInterceptIsNotInitialLatentMean.to_string(), + "asymptotic continuous intercept is not the first-occasion latent mean" + ); + assert_eq!( + PsychometricError::AsymptoticContinuousInterceptIsNotAsymptoticTimeIndependentEffect + .to_string(), + "asymptotic continuous intercept is not the asymptotic time-independent predictor effect" + ); + assert_eq!( + PsychometricError::StationaryInitialLatentMeanIsNotInitialLatentMean.to_string(), + "stationary first-occasion latent mean is not the free first-occasion latent mean" + ); + assert_eq!( + PsychometricError::StationaryInitialLatentMeanIsNotAsymptoticContinuousIntercept + .to_string(), + "stationary first-occasion latent mean is not the asymptotic continuous intercept" + ); + assert_eq!( + PsychometricError::StationaryInitialLatentMeanIsNotAsymptoticTimeIndependentEffect + .to_string(), + "stationary first-occasion latent mean is not the asymptotic time-independent predictor effect" + ); + assert_eq!( + PsychometricError::StationaryInitialLatentMeanIsNotDiscreteMean.to_string(), + "stationary first-occasion latent mean is not the finite-interval discrete latent mean" + ); + assert_eq!( + PsychometricError::StationaryInitialObservedMeanIsNotManifestMeans.to_string(), + "stationary first-occasion observed mean is not the manifest mean" + ); + assert_eq!( + PsychometricError::StationaryInitialLatentMeanIsNotObservedMean.to_string(), + "stationary first-occasion latent mean is not the first-occasion observed mean" + ); + assert_eq!( + PsychometricError::EvolvedObservedMeanIsNotStationaryInitialObservedMean.to_string(), + "evolved observed mean is not the stationary first-occasion observed mean" + ); + assert_eq!( + PsychometricError::AsymptoticContinuousInterceptObservedMeanIsNotStationaryInitialObservedMean + .to_string(), + "asymptotic-intercept observed mean is not the stationary first-occasion observed mean" + ); + assert_eq!( + PsychometricError::InitialObservedMeanIsNotStationaryInitialObservedMean.to_string(), + "free first-occasion observed mean is not the stationary first-occasion observed mean" + ); + } + + #[test] + fn stationary_initial_latent_variance_boundary_messages_are_stable() { + assert_eq!( + PsychometricError::StationaryInitialLatentVarianceIsNotInitialLatentVariance + .to_string(), + "stationary first-occasion latent variance is not the free first-occasion latent variance" + ); + assert_eq!( + PsychometricError::StationaryInitialLatentVarianceIsNotStationaryWithinSubject + .to_string(), + "stationary first-occasion latent variance is not the asymptotic within-subject variance" + ); + assert_eq!( + PsychometricError::StationaryInitialLatentVarianceIsNotTraitVariance.to_string(), + "stationary first-occasion latent variance is not the trait variance" + ); + assert_eq!( + PsychometricError::StationaryInitialLatentVarianceIsNotAsymptoticTimeIndependentVariance + .to_string(), + "stationary first-occasion latent variance is not the asymptotic time-independent predictor variance" + ); + assert_eq!( + PsychometricError::StationaryInitialLatentVarianceIsNotDiscreteVariance.to_string(), + "stationary first-occasion latent variance is not the finite-interval discrete latent variance" + ); + } + + #[test] + fn stationary_initial_observed_variance_boundary_messages_are_stable() { + assert_eq!( + PsychometricError::StationaryInitialObservedVarianceIsNotMeasurementError.to_string(), + "stationary first-occasion observed variance is not the measurement-error variance" + ); + assert_eq!( + PsychometricError::StationaryInitialLatentVarianceIsNotObservedVariance.to_string(), + "stationary first-occasion latent variance is not the first-occasion observed variance" + ); + assert_eq!( + PsychometricError::EvolvedObservedVarianceIsNotStationaryInitialObservedVariance + .to_string(), + "evolved observed variance is not the stationary first-occasion observed variance" + ); + assert_eq!( + PsychometricError::StationaryWithinSubjectObservedVarianceIsNotStationaryInitialObservedVariance + .to_string(), + "asymptotic-within-subject observed variance is not the stationary first-occasion observed variance" + ); + assert_eq!( + PsychometricError::InitialObservedVarianceIsNotStationaryInitialObservedVariance + .to_string(), + "free first-occasion observed variance is not the stationary first-occasion observed variance" + ); + } + + #[test] + fn stationary_lagged_covariance_boundary_messages_are_stable() { + assert_eq!( + PsychometricError::StationaryLaggedLatentCovarianceIsNotStationaryInitialLatentVariance + .to_string(), + "stationary lagged latent covariance is not the stationary first-occasion latent variance" + ); + assert_eq!( + PsychometricError::StationaryLaggedLatentCovarianceIsNotDecayedStationaryVariance + .to_string(), + "stationary lagged latent covariance is not the decayed stationary variance" + ); + assert_eq!( + PsychometricError::TraitPlusStateLaggedCovarianceIsNotStationaryLaggedLatentCovariance + .to_string(), + "trait-plus-state lagged covariance is not the stationary lagged latent covariance" + ); + assert_eq!( + PsychometricError::StationaryLaggedLatentCovarianceIsNotObservedCovariance.to_string(), + "stationary lagged latent covariance is not the lagged observed covariance" + ); + assert_eq!( + PsychometricError::MeasurementErrorIsNotStationaryLaggedObservedCovariance.to_string(), + "measurement-error variance is not the stationary lagged observed covariance" + ); + assert_eq!( + PsychometricError::StationaryInitialObservedVarianceIsNotStationaryLaggedObservedCovariance + .to_string(), + "stationary first-occasion observed variance is not the stationary lagged observed covariance" + ); + } + + #[test] + fn stationary_later_variance_boundary_messages_are_stable() { + assert_eq!( + PsychometricError::StationaryLaterLatentVarianceIsNotLaggedCovariance.to_string(), + "stationary later-occasion latent variance is not the stationary lagged latent covariance" + ); + assert_eq!( + PsychometricError::StationaryLaterLatentVarianceIsNotDiscreteVariance.to_string(), + "stationary later-occasion latent variance is not the free discrete latent variance" + ); + assert_eq!( + PsychometricError::StationaryLaterLatentVarianceIsNotProcessNoise.to_string(), + "stationary later-occasion latent variance is not the finite-interval process noise" + ); + assert_eq!( + PsychometricError::StationaryLaterLatentVarianceIsNotObservedVariance.to_string(), + "stationary later-occasion latent variance is not the later-occasion observed variance" + ); + assert_eq!( + PsychometricError::MeasurementErrorIsNotStationaryLaterObservedVariance.to_string(), + "measurement-error variance is not the stationary later-occasion observed variance" + ); + assert_eq!( + PsychometricError::StationaryLaggedObservedCovarianceIsNotStationaryLaterObservedVariance + .to_string(), + "stationary lagged observed covariance is not the stationary later-occasion observed variance" + ); + } +} diff --git a/crates/psychometric_core/src/event_time.rs b/crates/psychometric_core/src/event_time.rs new file mode 100644 index 00000000..e5a889bc --- /dev/null +++ b/crates/psychometric_core/src/event_time.rs @@ -0,0 +1,14035 @@ +//! Event-time discrete lag-1 and exact scalar local log-rate. +//! +//! Voelkle, Oud, Davidov, and Schmidt (2012, Eq. 7; ZORA accepted +//! manuscript, Continuous Time Modeling p. 16 and Appendix B) and Driver, +//! Oud, and Voelkle (2017, Eq. 3) map the continuous-time drift by +//! `A*(Δt) = exp(A Δt)`. The noiseless scalar inverse is +//! `a = ln(φ) / Δt` with `φ = A*(Δt)`. The forward map is +//! `φ(Δt) = exp(a Δt)`. Discrete lags from unequal event intervals are +//! not one coefficient; they map through `a` first. The exact scalar +//! discrete effect of a constant predictor is Voelkle et al. (2012, +//! Eq. 12). The discrete effect of a time-varying predictor whose +//! sampling interval equals its constancy interval is their Eq. 14. +//! The exact scalar discrete process noise is the closed form of +//! Driver, Oud, and Voelkle (2017, Eq. 3–4, pp. 4–5) `Q_Δt`. +//! Equation 3 writes `η(t) = exp(A Δt) η(t0) + … +` the stochastic +//! integral. Equation 4 writes that the integral exhibits covariance +//! `Q_Δt`. The homogeneous-process consequence (`ξ`, `z` given) is +//! `Q_Δt = cov(η_ti | η_{t-1,i})` and +//! `cov(η_ti, η_{t-1,i}) = A_Δt cov(η_{t-1,i})`. The law of total +//! variance on that pair is +//! `Var(η_ti) = A_Δt Var(η_{t-1,i}) A_Δt⊤ + Q_Δt`. As `Δt → ∞` with +//! stable `a < 0`, Eq. 4 and the JSS `asymDIFFUSION` summary (p. 16; +//! §4.3 T0VAR stationarity) give the scalar Lyapunov solution +//! `-q / (2 a)`. Section 4.3 (p. 9) then adds a stable trait process +//! with `DRIFT` and `DIFFUSION` fixed to zero. That `TRAITVAR` is +//! time-invariant between-subject variance; it is not process noise +//! and not `asymDIFFUSION`. Equation 1 (p. 4) is the latent SDE. +//! Equation 5 (p. 5) writes `y_i(t) = τ_i + Λ η_i(t) + ε_i(t)` with +//! `ε ~ N(0, Θ)` and `τ_i ~ N(μ_τ, Ψ_τ)`. Table 2 (p. 12) names +//! `Θ` `MANIFESTVAR` and `Ψ_τ` `MANIFESTTRAITVAR`. The JSS summary +//! (p. 16) restates those names; it is not the measurement equation. +//! The scalar observed variance is `λ² Var(η) + θ` when `Ψ_τ = 0` +//! and `λ² Var(η) + θ + ψ` otherwise. `MANIFESTVAR` is not `Var(y)`, +//! `MANIFESTTRAITVAR` is not `MANIFESTVAR`, `TRAITVAR` is latent +//! (scaled by `λ²`), and `Var(η)` is not `Var(y)`. The lagged +//! observed covariance is `λ² cov(η_t, η_{t-1}) + ψ`; `Θ` does not +//! enter. The scalar observed mean is `τ + λ μ` (Table 2, p. 12: +//! `MANIFESTMEANS` is `τ`, not `E(y)`; `CINT` is `κ`, not `τ`; +//! `T0MEANS` is the initial latent mean, not `E(y)`). Equation 3's +//! expected-value map is `μ_t = exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ` +//! (`T0MEANS` is not `μ_t`; `CINT` is not that discrete increment). Equation 3's +//! fourth summand is the contemporaneous Dirac impulse `M x` (Table 2 +//! `TDPREDEFFECT` is `M`, not `CINT`, not `TIPREDEFFECT`, and not +//! Voelkle et al., 2012, Eq. 14). Equations 1–2 plus the Eq. 3 +//! exponential map a time-dependent impulse that occurred strictly +//! inside `(t0, t)` as `e^{A(t−u)} M x` (`t0 < u < t`). That carry +//! is not the contemporaneous Dirac, not `CINT`, not `TIPREDEFFECT`, +//! and not Voelkle Eq. 14. Equation 5 of that carried latent mean +//! is `τ + λ(μ_t + e^{a(t−u)} m x)` (`τ + λ μ_t` is not that +//! observed mean). Section 7.2 calls this dissipation back to +//! the process mean. Equation 3's second summand also +//! maps the time-independent predictor as `A^{-1}[e^{A Δt} − I] B z` +//! (Table 2 `TIPREDEFFECT` is `B`, not `κ`, not `M`, and not Voelkle +//! Eq. 14). Section 7.2 (pp. 20–21; JSS PDF opened 2026-08-21T13:08Z) +//! names `asymTIPREDEFFECT` the expected total change in process +//! means given a unit increase on a time-independent predictor. The +//! scalar map is `-B z / a` for stable `a < 0`. That total change is +//! not the coefficient `B`, not the finite-interval increment +//! `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, and not `M x`. Section 7.2 +//! then names `addedTIPREDVAR` the stable between-subject variance +//! accounted for by those predictors. The scalar map is `(B / a)² v` +//! for predictor variance `v ≥ 0`. That variance is not `TRAITVAR`, +//! not `asymDIFFUSION`, and not the expected total change `-B z / a`. +//! Table 2 (p. 12) names `asymCINT` the asymptotic (`Δt = ∞`) +//! expected change in processes for a 1 unit change in intercept +//! (`CINT`). Equation 3 maps a finite event interval as +//! `A^{-1}[e^{A Δt} − I] κ`. For stable `a < 0` that `Δt → ∞` limit +//! is `-κ / a`. A unit intercept is `-1 / a`. That intercept +//! contribution is not `κ`, not the finite-interval increment, not +//! `T0MEANS`, and not `asymTIPREDEFFECT` `-B z / a`. Page 16 notes +//! that a `T0MEANS` stationarity constraint includes time-independent +//! predictors; that composition is not this intercept-only map. +//! Page 16 constrains `T0MEANS` to the model-implied values using +//! `T0MEANSbase` / `T0MEANSfree`. Those constraints include extra +//! effects due to time-independent predictors (`asymTIPREDEFFECT`). +//! The scalar composition is `-κ / a + −B z / a` for stable `a < 0`. +//! Form the intercept contribution first, then include the TI extra +//! effect, then add. That constrained first-occasion mean is not free +//! `T0MEANS`, not `asymCINT` alone, not `asymTIPREDEFFECT` alone, and +//! not the finite-interval discrete latent mean. +//! Equation 5 of that constrained first-occasion mean is +//! `τ + λ(−κ / a + −B z / a)` (§4.3, pp. 9–10; Eq. 5, p. 5; JSS PDF +//! re-opened 2026-08-21T20:07Z). Form the stationary latent mean +//! first, then `τ + λ` of that mean. `τ + λ μ_0` for free `T0MEANS` +//! is not that composition. `τ + λ(−κ / a)` is not that composition +//! when `B z ≠ 0`. `τ + λ μ_t` is not that composition. +//! Section 4.3 (pp. 9–10; JSS PDF re-opened 2026-08-22T03:07Z) +//! constrains `T0VAR` to the model-predicted variance when +//! `stationary` includes `"T0VAR"`. Page 16 names `asymDIFFUSION` +//! the total within-subject variance `-q / (2 a)`. Section 4.3 +//! (p. 9) adds `TRAITVAR`. Section 7.2 (pp. 20–21) names +//! `addedTIPREDVAR` the stable between-subject variance accounted +//! for by time-independent predictors, `(B / a)² v`. The scalar +//! composition is `trait + −q / (2 a) + (B / a)² v` for stable +//! `a < 0` when the process or TI contribution is nonzero. Form the +//! within-subject contribution first, then include the trait, then +//! include the TI extra variance, then add. That constrained +//! first-occasion variance is not free `T0VAR`, not +//! `asymDIFFUSION` alone, not `TRAITVAR` alone, not +//! `addedTIPREDVAR` alone, and not the finite-interval discrete +//! latent variance `exp(2 a Δt) p + Q_Δt`. The printed 2-latent +//! `addedTIPREDVAR` 2.838 is not this scalar map. +//! Equation 5 of that constrained first-occasion variance is +//! `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` (§4.3, pp. 9–10; +//! Eq. 5, p. 5; Table 2, p. 12; JSS PDF re-opened 2026-08-22T03:20Z). +//! Form the stationary latent variance first, then `λ² p + θ + ψ`. +//! `λ² p` for free `T0VAR` is not that composition. +//! `λ²(−q / (2 a)) + θ` is not that composition when `TRAITVAR` or +//! `addedTIPREDVAR` is nonzero. `MANIFESTVAR` is not `Var(y_0)`. +//! The constrained latent variance is not `Var(y_0)`. +//! The lagged covariance of that stationary process is +//! `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` (Eq. 3–4 of §4.3 +//! `T0VAR`; JSS PDF re-opened 2026-08-22T19:13Z). Form the lagged +//! within-subject covariance first, then include the trait, then +//! include the TI extra variance, then add. Trait variance and +//! `addedTIPREDVAR` are time-invariant between-subject and do not +//! decay with `e^{a Δt}`. Evolving the constrained total as if it +//! were all state is not that lagged map. Contemporaneous +//! `T0VAR` is not that lagged map. The interval must be a strictly +//! positive event interval. Equation 5 of that lagged covariance is +//! `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ`. Independent +//! `ε_t` does not enter. `θ` is not that lagged observed +//! covariance. Contemporaneous `Var(y_0)` is not that lagged +//! observed covariance. The lagged latent covariance is not the +//! lagged observed covariance. +//! The later-occasion variance of that stationary process is +//! `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v` (Eq. 3–4 +//! of §4.3 `T0VAR`; JSS PDF re-opened 2026-08-22T23:12Z). Form +//! the evolved within-subject variance first, then include the +//! trait, then include the TI extra variance, then add. Trait +//! variance and `addedTIPREDVAR` do not enter `Q_Δt`. Under +//! stationarity that composition equals contemporaneous `T0VAR`. +//! Evolving the constrained total as if it were all state is not +//! that later-occasion map. The lagged covariance omits `Q_Δt` +//! and is not that later-occasion map. `Q_Δt` is not that map. +//! Equation 5 of that later-occasion variance is +//! `λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ`. +//! The lagged observed covariance omits `Q_Δt` and `θ`. `θ` is +//! not that later-occasion observed variance. The later-occasion +//! latent variance is not that observed variance. +//! Table 3 (p. 13) names a different matrix +//! `T0TIPREDEFFECT` for time-independent predictors on latents at +//! `T0`. The scalar first-occasion shift is `t0_b z`. Equation 3's +//! first summand carries that shift as `e^{A Δt} t0_b z`. That carry +//! is not `t0_b z`, not `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, and +//! not `M x`. Equation 5 of that carried first-occasion shift is +//! `τ + λ(μ_t + e^{a Δt} t0_b z)` (`τ + λ μ_t` is not that observed +//! mean; `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not that +//! observed mean). Table 3 also names a different matrix +//! `T0TDPREDEFFECT` for time-dependent predictors on latents at +//! `T0`. The scalar first-occasion shift is `t0_m x0`. Equation 3's +//! first summand carries that shift as `e^{A Δt} t0_m x0`. That +//! carry is not `t0_m x0`, not `M x`, not `e^{A(t−u)} M x` for +//! `t0 < u < t`, not `t0_b z`, not `A^{-1}[e^{A Δt} − I] B z`, and +//! not `CINT`. An impulse at `u ≤ t0` that used `M` is already in +//! `η(t0)` as `TDPREDEFFECT`, not as `T0TDPREDEFFECT`. Equation 5 of +//! that carried first-occasion TD shift is +//! `τ + λ(μ_t + e^{a Δt} t0_m x0)` (`τ + λ μ_t` is not that +//! observed mean; `τ + λ(μ_t + e^{a Δt} t0_b z)` is not that +//! observed mean). The JSS §7.2 lasting level change sets +//! `CINT` to `TDPREDEFFECT * −DRIFT` (`κ = −a m x`; `a < 0` so +//! `−κ / a = m x`). That `CINT` setting is not the dissipating +//! Dirac, not a free `CINT`, and not the extra near-zero-drift +//! latent process also named in §7.2. Equation 3 maps that +//! intercept as `(1 − e^{a Δt}) m x` (`(1 − e^{a Δt}) m x` is not +//! `m x`, not `κ`, and not `A^{-1}[e^{A Δt} − I] B z`). Section 7.2 +//! (pp. 22–23) then specifies a lasting level change by an extra +//! latent process: `T0MEANS`, `CINT`, `T0VAR`, `DIFFUSION`, and +//! `TRAITVAR` of that process are fixed to 0; `TDPREDEFFECT` on it +//! is fixed to 1; its `DRIFT` diagonal is very close to 0 (printed +//! example `−0.000001`; precisely 0 causes computational problems); +//! and its effect on the original process is the `DRIFT` coupling +//! `a_{ηξ}`. After a unit identification impulse the scalar +//! contribution is `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` +//! (`ε = a` is `a_{ηξ} x Δt e^{a Δt}`). That contribution is not +//! `κ = −a m x`, not `(1 − e^{a Δt}) m x`, and not the dissipating +//! Dirac `m x`. `ε ≥ 0` fails closed. Equation 5 of that extra-process +//! contribution is +//! `τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a))` (the extra +//! process has `LAMBDA` 0 and is not an observed indicator; `τ + λ μ_t` +//! is not that observed mean; `τ + λ(μ_t + m x)` is not that observed +//! mean; the contribution is not `E(y_t)`; the evolved-plus-contribution +//! latent mean is not `E(y_t)`). `T0TDPREDEFFECT` on the extra process +//! begins at `t = 0` and uses `Δt = t − t0` for both the original-process +//! evolution and the extra drive. `TDPREDEFFECT` after `t0` uses +//! `t − u` with `t0 < u < t` for the extra drive while `μ_t` still +//! uses `Δt`. Equation 5 of that after-t0 contribution is +//! `τ + λ(μ_t + a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a))` (the +//! first-occasion extra-process observed mean is not that observed +//! mean when `u ≠ t0`; `e^{a(t−u)} m x` is a Dirac on the original +//! process, not this `DRIFT` drive). The JSS article +//! has no numbered §2.2 (2.1 is Continuous time and SEM; §3 follows). +//! The difference quotient `(x(t+Δt) − x(t)) / Δt` (their +//! Eqs. 3–4) is refused. This is not DSEM and not a matrix `expm`. + +use std::collections::BTreeMap; + +use crate::error::PsychometricError; +use crate::indicator::require_finite; + +/// Clock on which a structural lag may be computed. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum LagClock { + /// Event / valid time. The only clock that licenses a structural lag. + EventTime, + /// System / transaction time. + SystemTime, + /// Assertion time. + AssertionTime, + /// Document time. + DocumentTime, + /// Availability time. + AvailabilityTime, + /// Knowledge cutoff. + KnowledgeCutoff, +} + +impl LagClock { + /// Stable wire name for the lag clock. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::EventTime => "event_time", + Self::SystemTime => "system_time", + Self::AssertionTime => "assertion_time", + Self::DocumentTime => "document_time", + Self::AvailabilityTime => "availability_time", + Self::KnowledgeCutoff => "knowledge_cutoff", + } + } + + /// Return whether this clock may carry a discrete lag or local log-rate. + #[must_use] + pub const fn admits_structural_lag(self) -> bool { + matches!(self, Self::EventTime) + } +} + +/// One occasion on an event-time series. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct EventOccasion { + /// Event / valid time of the observation. + pub event_time: f64, + /// Already-mapped score. + pub score: f64, +} + +/// One clustered event-time score used for CWC-then-lag recovery. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ClusteredEventScore { + /// Cluster identity. + pub cluster_key: u64, + /// Event / valid time. + pub event_time: f64, + /// Already-mapped score. + pub score: f64, +} + +/// One already-centered lagged residual pair on event time. +/// +/// `earlier_residual` and `later_residual` are within residuals the caller +/// already formed. This type is not a raw score and is not re-centered. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LaggedWithinResidual { + /// Earlier within residual. + pub earlier_residual: f64, + /// Later within residual. + pub later_residual: f64, + /// Strictly positive event-time interval. Intervals may be irregular. + pub event_delta: f64, +} + +/// Discrete lag-1 coefficient and its exact local log-rate. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DiscreteLagAndLogRate { + /// Discrete-time lag `φ = exp(a Δt)`. + pub discrete_lag: f64, + /// Local log-rate `a = ln(φ) / Δt`. + pub log_rate: f64, + /// Positive event-time interval. + pub event_delta: f64, +} + +/// Recover the noiseless scalar discrete lag `later / earlier`. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidNumericInput`] when either score is +/// non-finite or the earlier score is zero (the ratio is undefined). +pub fn recover_discrete_lag_one(earlier: f64, later: f64) -> Result { + if !earlier.is_finite() || !later.is_finite() || earlier == 0.0 { + return Err(PsychometricError::InvalidNumericInput); + } + require_finite(later / earlier) +} + +/// Map a discrete lag through the exact scalar exponential inverse. +/// +/// `a = ln(φ) / Δt`. The clock must be event time. `φ` must be strictly +/// positive so the real logarithm exists. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for any non-event clock, +/// [`PsychometricError::NonPositiveInterval`] when `event_delta` is not +/// strictly positive, and [`PsychometricError::InvalidNumericInput`] when the +/// discrete lag is non-finite or not strictly positive. +pub fn recover_local_log_rate( + discrete_lag: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + if !event_delta.is_finite() || event_delta <= 0.0 { + return Err(PsychometricError::NonPositiveInterval); + } + if !discrete_lag.is_finite() || discrete_lag <= 0.0 { + return Err(PsychometricError::InvalidNumericInput); + } + require_finite(discrete_lag.ln() / event_delta) +} + +/// Exact scalar forward map `φ(Δt) = exp(a Δt)` (Voelkle et al., 2012, Eq. 7). +/// +/// This is the inverse of [`recover_local_log_rate`]. It is the scalar case of +/// `A*(Δt) = exp(A Δt)`, not a matrix `expm`. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for any non-event clock, +/// [`PsychometricError::NonPositiveInterval`] when `event_delta` is not +/// strictly positive, and [`PsychometricError::InvalidNumericInput`] when the +/// log-rate is non-finite or the exponential overflows or underflows to zero. +/// Binary64 `exp` of a large negative argument is `+0`, which is not a +/// discrete lag: the inverse `a = ln(φ) / Δt` requires `φ > 0`. +pub fn recover_discrete_lag_from_log_rate( + log_rate: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + if !event_delta.is_finite() || event_delta <= 0.0 { + return Err(PsychometricError::NonPositiveInterval); + } + if !log_rate.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + let discrete_lag = (log_rate * event_delta).exp(); + if !discrete_lag.is_finite() || discrete_lag <= 0.0 { + return Err(PsychometricError::InvalidNumericInput); + } + Ok(discrete_lag) +} + +/// Recover the exact scalar pair `(φ, a)` on event time. +/// +/// # Errors +/// +/// Propagates [`recover_discrete_lag_one`] and [`recover_local_log_rate`]. +pub fn recover_event_time_discrete_lag_and_log_rate( + earlier: f64, + later: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + let discrete_lag = recover_discrete_lag_one(earlier, later)?; + let log_rate = recover_local_log_rate(discrete_lag, event_delta, clock)?; + Ok(DiscreteLagAndLogRate { + discrete_lag, + log_rate, + event_delta, + }) +} + +/// Map a discrete lag from one event interval onto another through `a`. +/// +/// Voelkle et al. (2012, ZORA accepted manuscript pp. 2, 16, 33) show that +/// discrete-time autoregressive coefficients from different intervals are +/// not comparable. The licensed path is `a = ln(φ_src) / Δt_src` then +/// `φ_ref = exp(a Δt_ref)`. Equal source and reference intervals still go +/// through that map. This is not DSEM. +/// +/// # Errors +/// +/// Propagates [`recover_local_log_rate`] and +/// [`recover_discrete_lag_from_log_rate`]. +pub fn map_discrete_lag_across_event_intervals( + discrete_lag: f64, + source_delta: f64, + reference_delta: f64, + clock: LagClock, +) -> Result { + let log_rate = recover_local_log_rate(discrete_lag, source_delta, clock)?; + recover_discrete_lag_from_log_rate(log_rate, reference_delta, clock) +} + +/// Exact scalar discrete effect of a constant event-time predictor. +/// +/// Voelkle et al. (2012, Eq. 12; ZORA accepted manuscript, Introducing +/// Intercepts, manuscript p. 20): adding a continuous-time intercept +/// `b` yields the discrete increment `A^{-1}(exp(A Δt) − I) b`. Driver, +/// Oud, and Voelkle (2017, Eq. 3) write the same term as +/// `A^{-1}[e^{A Δt} − I] ξ`. The scalar case is +/// `b*_y.x(Δt) = (a_yx / a_xx) (exp(a_xx Δt) − 1)` for `a_xx ≠ 0`. +/// The algebraically identical finite-`expm1` evaluation is +/// `a_yx (expm1(z) / a_xx)` with `z = a_xx Δt`. Dividing the increment +/// by the finite auto-effect keeps a finite Eq. 12 result when `z` +/// overflows to `-∞` (`exp(z) → 0`, so Eq. 12 → `-a_yx / a_xx`) and +/// when `a_yx Δt` overflows. When binary64 `z` underflows to `+0`, the +/// mathematical limit of Eq. 12 is `a_yx Δt`. When `a_yx = 0`, Eq. 12 +/// is exactly `0` even if `expm1(z)` overflows (`0 * +∞` is `NaN`). +/// When `expm1(z)` overflows to `+∞` at a finite `z`, rewrite as +/// `sign(a_yx / a_xx) exp(ln|a_yx| + z − ln|a_xx|) − a_yx / a_xx` so a +/// finite Eq. 12 result is not lost. `z → +∞` is an unstable process +/// and fails closed unless `a_yx = 0`. The first-order product is not +/// the general discrete effect. This is not DSEM and not a matrix +/// `expm`. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for any non-event clock, +/// [`PsychometricError::NonPositiveInterval`] when `event_delta` is not +/// strictly positive, and [`PsychometricError::InvalidNumericInput`] when +/// either rate is non-finite, the predictor auto-effect is zero, or the +/// mapped effect is non-finite. +pub fn recover_discrete_constant_predictor_effect( + outcome_on_predictor: f64, + predictor_log_rate: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + if !event_delta.is_finite() || event_delta <= 0.0 { + return Err(PsychometricError::NonPositiveInterval); + } + if !outcome_on_predictor.is_finite() + || !predictor_log_rate.is_finite() + || predictor_log_rate == 0.0 + { + return Err(PsychometricError::InvalidNumericInput); + } + // Voelkle Eq. 12 / Driver Eq. 3: (0 / a)(exp(a Δt) − 1) = 0. + // Direct 0 * (expm1(z) / a) is NaN when expm1 overflows. + if outcome_on_predictor == 0.0 { + return Ok(0.0); + } + let increment_argument = predictor_log_rate * event_delta; + if increment_argument == 0.0 { + // Binary64 underflow of a_xx Δt. lim z→0 of Eq. 12 is a_yx Δt. + return require_finite(outcome_on_predictor * event_delta); + } + let increment = increment_argument.exp_m1(); + if increment.is_finite() { + // Divide expm1(z) by the finite a_xx, not by z. expm1(-∞)/-∞ + // is +0 and loses the equilibrium increment -a_yx/a_xx (Voelkle + // 2012, Introducing Intercepts: the exponential vanishes as Δt + // grows). + return require_finite(outcome_on_predictor * (increment / predictor_log_rate)); + } + // expm1 overflowed. Finite z uses the log-space rewrite; a + // non-finite argument also fails closed through `require_finite`. + // Finite z, overflowed expm1. (a_yx/a_xx)(exp(z) − 1) = + // sign(a_yx/a_xx) exp(ln|a_yx| + z − ln|a_xx|) − a_yx/a_xx. + // The subtracted scale must itself be finite: if a_yx/a_xx overflows, + // the rewrite term is not a binary64 number. That path is dead if + // dominant is required first (dominant is then also infinite), so + // refuse the scale before forming the exponential. + let scale = outcome_on_predictor / predictor_log_rate; + if !scale.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + let log_abs_dominant = + outcome_on_predictor.abs().ln() + increment_argument - predictor_log_rate.abs().ln(); + let dominant = require_finite( + outcome_on_predictor.signum() * predictor_log_rate.signum() * log_abs_dominant.exp(), + )?; + require_finite(dominant - scale) +} + +/// Refuse treating discrete lags from unequal event intervals as one coefficient. +/// +/// Always fails closed. Map each lag through +/// [`map_discrete_lag_across_event_intervals`] instead. +/// +/// # Errors +/// +/// Always returns [`PsychometricError::UnequalIntervalPoolingForbidden`]. +pub fn refuse_pooled_discrete_lag_across_unequal_intervals( + first_delta: f64, + second_delta: f64, +) -> Result { + let _ = (first_delta, second_delta); + Err(PsychometricError::UnequalIntervalPoolingForbidden) +} + +/// Exact scalar discrete effect of a time-varying event-time predictor. +/// +/// Voelkle et al. (2012, Eq. 14; ZORA accepted manuscript, Introducing +/// Intercepts, manuscript p. 21): when the predictor can take a new value +/// at each occasion **and** the sampling interval equals the interval +/// during which that predictor is assumed constant, the discrete effect +/// is `b*_y.x(Δt) = a_yx Δt`. It does not depend on the predictor +/// auto-effect. The manuscript calls this a first-order approximation +/// that deteriorates as `Δt` grows. It is not Eq. 12. The general case +/// (sampling interval ≠ constancy interval) cites Oud and Jansen (2000), +/// which is unread, and fails closed. This is not DSEM. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for any non-event clock, +/// [`PsychometricError::NonPositiveInterval`] when any interval is not +/// strictly positive, [`PsychometricError::UnmatchedTimeVaryingInterval`] +/// when the event, sampling, and constancy intervals are not the same +/// finite value, and [`PsychometricError::InvalidNumericInput`] when the +/// continuous effect is non-finite or the product overflows. +pub fn recover_discrete_time_varying_predictor_effect( + outcome_on_predictor: f64, + event_delta: f64, + sampling_interval: f64, + constancy_interval: f64, + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + if !event_delta.is_finite() + || event_delta <= 0.0 + || !sampling_interval.is_finite() + || sampling_interval <= 0.0 + || !constancy_interval.is_finite() + || constancy_interval <= 0.0 + { + return Err(PsychometricError::NonPositiveInterval); + } + if event_delta.to_bits() != sampling_interval.to_bits() + || sampling_interval.to_bits() != constancy_interval.to_bits() + { + return Err(PsychometricError::UnmatchedTimeVaryingInterval); + } + if !outcome_on_predictor.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + require_finite(outcome_on_predictor * event_delta) +} + +/// Refuse mapping a time-varying predictor when sampling ≠ constancy. +/// +/// Always fails closed. Oud and Jansen (2000) is unread. Use +/// [`recover_discrete_time_varying_predictor_effect`] only when the +/// intervals already match, or [`recover_discrete_constant_predictor_effect`] +/// for a constant predictor (Eq. 12). +/// +/// # Errors +/// +/// Always returns [`PsychometricError::UnmatchedTimeVaryingInterval`]. +pub fn refuse_unmatched_time_varying_predictor_interval( + sampling_interval: f64, + constancy_interval: f64, +) -> Result { + let _ = (sampling_interval, constancy_interval); + Err(PsychometricError::UnmatchedTimeVaryingInterval) +} + +/// Exact scalar discrete process noise on event time. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 3; JSS PDF re-opened 2026-08-17T21:03Z, +/// p. 4) write the discrete process-noise covariance +/// `Q_Δt = ∫_0^{Δt} expm(A(Δt−τ)) L G G⊤ L⊤ expm(A(Δt−τ))⊤ dτ`. +/// This slice takes scalar `L = 1` (every latent subject to system noise). +/// The noiseless scalar closed form with continuous diffusion +/// `q = G G⊤ ≥ 0` is `q (exp(2 a Δt) − 1) / (2 a)` for `a ≠ 0` and +/// `q Δt` for `a = 0`. The algebraically identical finite-`expm1` +/// evaluation is `0.5 q (expm1(z) / a)` with `z = 2 (a Δt)`. Form +/// `z` as twice the already-finite product `a Δt`. Forming `2 a` +/// first overflows when `|a|` is at the binary64 extreme even if +/// `a Δt` and `Q_Δt` are finite (`a = ±1e308`, `Δt = 1e-308`). +/// When binary64 `z` underflows to `+0`, the mathematical limit is +/// `q Δt`. When `z → −∞` the exponential vanishes and the result is +/// the equilibrium variance `−q / (2 a) = −0.5 q / a` for stable +/// `a < 0`. When `expm1(z)` overflows to `+∞` at a finite `z`, +/// rewrite as `sign(q / a) exp(ln|q| + z − ln|a| − ln 2) − 0.5 q / a`. +/// An overflowing rewrite scale `0.5 q / a` is not a finite `Q_Δt` +/// (`q = 1e308`, `a = 0.1`, `Δt = 4000` → `z = 800`, `0.5 q / a = +∞`). +/// `z → +∞` is an unstable process and fails closed unless `q = 0`. +/// A zero diffusion is exactly zero even if `expm1` overflows. This +/// is not a Kalman filter, not DSEM, and not a matrix `expm`. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for any non-event clock, +/// [`PsychometricError::NonPositiveInterval`] when `event_delta` is not +/// strictly positive, and [`PsychometricError::InvalidNumericInput`] when +/// the diffusion is negative or non-finite, the log-rate is non-finite, or +/// the mapped variance is non-finite. +pub fn recover_discrete_process_noise( + continuous_diffusion: f64, + log_rate: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + if !event_delta.is_finite() || event_delta <= 0.0 { + return Err(PsychometricError::NonPositiveInterval); + } + if !continuous_diffusion.is_finite() || continuous_diffusion < 0.0 || !log_rate.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + // Driver Eq. 3: the integral of a zero diffusion is zero. + // Direct 0 * (expm1(z) / (2 a)) is NaN when expm1 overflows. + if continuous_diffusion == 0.0 { + return Ok(0.0); + } + if log_rate == 0.0 { + return require_finite(continuous_diffusion * event_delta); + } + // z = 2 (a Δt), not (2 a) Δt. 2 a overflows at |a| = 1e308 even + // when a Δt is finite (Driver Eq. 3 scalar closed form). + let drift_interval = log_rate * event_delta; + let increment_argument = 2.0 * drift_interval; + if increment_argument == 0.0 { + // Binary64 underflow of 2 a Δt. lim z→0 of Eq. 3 Q_Δt is q Δt. + return require_finite(continuous_diffusion * event_delta); + } + let increment = increment_argument.exp_m1(); + if increment.is_finite() { + // Q = q expm1(z) / (2 a) = 0.5 q (expm1(z) / a). Divide by + // the finite a, not by 2 a: 2 a overflows when |a| = 1e308. + // expm1(−∞) is −1, so this path also keeps −0.5 q / a. + return require_finite(0.5 * continuous_diffusion * (increment / log_rate)); + } + // expm1 overflowed. Finite z uses the log-space rewrite; a + // non-finite argument also fails closed through `require_finite`. + // Finite z, overflowed expm1. (q / (2 a))(exp(z) − 1) = + // sign(q / a) exp(ln|q| + z − ln|a| − ln 2) − 0.5 q / a. + // Driver Eq. 3 (JSS PDF re-opened 2026-08-18T03:07Z, p. 4): + // Q_Δt is that integral. If 0.5 q / a overflows, the rewrite + // scale is not finite and Q_Δt is not finite. + let half_scale = 0.5 * continuous_diffusion / log_rate; + if !half_scale.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + let log_abs_dominant = continuous_diffusion.abs().ln() + increment_argument + - log_rate.abs().ln() + - std::f64::consts::LN_2; + let dominant = + require_finite(continuous_diffusion.signum() * log_rate.signum() * log_abs_dominant.exp())?; + require_finite(dominant - half_scale) +} + +/// Exact scalar lagged latent covariance on event time. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 3–4, pp. 4–5; JSS PDF +/// re-opened 2026-08-18T11:20Z) write `η(t) = exp(A Δt) η(t0) + … +` +/// the stochastic integral (Eq. 3) and that the integral exhibits +/// covariance `Q_Δt` (Eq. 4). The homogeneous-process consequence is +/// `cov(η_ti, η_{t-1,i}) = A_Δt cov(η_{t-1,i})`. The scalar map is +/// `exp(a Δt) p` with prior variance `p ≥ 0`. This is not `Q_Δt`. +/// Binary64 underflow of `exp(a Δt)` to `+0` is a vanishing +/// covariance and is kept. A zero prior variance is exactly zero even +/// if the exponential overflows. When `exp(a Δt)` overflows at a +/// finite `a Δt`, rewrite as `exp(ln p + a Δt)`. An overflowing +/// rewrite fails closed. A finite `exp(a Δt)` whose product with `p` +/// overflows also fails closed. The JSS article has no numbered §2.2. +/// This is not a Kalman filter, not DSEM, and not a matrix `expm`. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for any non-event clock, +/// [`PsychometricError::NonPositiveInterval`] when `event_delta` is not +/// strictly positive, and [`PsychometricError::InvalidNumericInput`] when +/// the prior variance is negative or non-finite, the log-rate is +/// non-finite, or the mapped covariance is non-finite. +pub fn recover_discrete_lagged_latent_covariance( + prior_variance: f64, + log_rate: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + if !event_delta.is_finite() || event_delta <= 0.0 { + return Err(PsychometricError::NonPositiveInterval); + } + if !prior_variance.is_finite() || prior_variance < 0.0 || !log_rate.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + // 0 * +∞ is NaN. Driver Eq. 3: A_Δt * 0 = 0. + if prior_variance == 0.0 { + return Ok(0.0); + } + let drift_interval = log_rate * event_delta; + let auto_effect = drift_interval.exp(); + if auto_effect.is_finite() { + // +0 underflow is a vanishing lagged covariance. + return require_finite(auto_effect * prior_variance); + } + // Overflow of a finite `a Δt` is the log-space rewrite. + // A non-finite argument also fails closed through `require_finite`. + require_finite((prior_variance.ln() + drift_interval).exp()) +} + +/// Exact scalar discrete latent variance on event time. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 3–4, pp. 4–5; JSS PDF +/// re-opened 2026-08-18T11:20Z) write `Q_Δt` as the covariance of the +/// stochastic integral (Eq. 4) after the homogeneous map +/// `η(t) = exp(A Δt) η(t0) + …` (Eq. 3). That pair is +/// `Q_Δt = cov(η_ti | η_{t-1,i})` and +/// `cov(η_ti, η_{t-1,i}) = A_Δt cov(η_{t-1,i})` when `ξ` and `z` are +/// given. The law of total variance on that pair is +/// `Var(η_ti) = A_Δt Var(η_{t-1,i}) A_Δt⊤ + Q_Δt`. The scalar map is +/// `exp(2 a Δt) p + Q_Δt`. This is not `Q_Δt` alone and not a Kalman +/// measurement update. A zero prior variance is exactly `Q_Δt`. +/// Binary64 underflow of `exp(2 a Δt)` keeps `Q_Δt`. When `exp(z)` +/// overflows at a finite `z = 2 (a Δt)`, rewrite as +/// `exp(ln p + z) + Q_Δt`. An overflowing rewrite fails closed. +/// A finite `exp(z) p` whose sum with `Q_Δt` overflows fails closed. +/// A zero diffusion skips the process-noise `z → +∞` refusal; the +/// carried term `exp(2 a Δt) p` is then still non-finite when +/// `2 (a Δt)` overflows to `+∞` (`p = 2`, `q = 0`, `a = 1e308`, +/// `Δt = 2`) and fails closed. The JSS article has no numbered §2.2. +/// +/// # Errors +/// +/// Propagates [`recover_discrete_process_noise`]. Returns +/// [`PsychometricError::InvalidNumericInput`] when the prior variance is +/// negative or non-finite or the mapped variance is non-finite. +pub fn recover_discrete_latent_variance( + prior_variance: f64, + continuous_diffusion: f64, + log_rate: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + let process_noise = + recover_discrete_process_noise(continuous_diffusion, log_rate, event_delta, clock)?; + if !prior_variance.is_finite() || prior_variance < 0.0 { + return Err(PsychometricError::InvalidNumericInput); + } + if prior_variance == 0.0 { + return Ok(process_noise); + } + let increment_argument = 2.0 * (log_rate * event_delta); + if increment_argument == 0.0 { + return require_finite(prior_variance + process_noise); + } + let auto_effect_square = increment_argument.exp(); + if auto_effect_square.is_finite() { + return require_finite(auto_effect_square * prior_variance + process_noise); + } + // `e^{2 a Δt}` overflow of a finite `2 a Δt` is the log-space rewrite. + // A non-finite argument also fails closed through `require_finite`. + let carried = require_finite((prior_variance.ln() + increment_argument).exp())?; + require_finite(carried + process_noise) +} + +/// Exact scalar stationary within-subject variance on event time. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 4, p. 5; JSS PDF re-opened +/// 2026-08-19T04:10Z) write `Q_Δt` as +/// `irow(A#^{-1}[e^{A# Δt} − I] row(Q))` with `A# = A ⊗ I + I ⊗ A`. +/// The scalar Kronecker sum is `2 a`. As `Δt → ∞` with stable +/// `a < 0`, `e^{2 a Δt} → 0` and Eq. 4 becomes `-q / (2 a)`. The +/// JSS summary names that limit `asymDIFFUSION` and takes it as the +/// total within-subject variance (p. 16). Section 4.3 (pp. 9–10) +/// constrains a stationary `T0VAR` to that same model-predicted +/// variance. When `2 a` is finite, form `q / -(2 a)` so `q / a` +/// overflow does not lose a finite Lyapunov solution (`q = MAX`, +/// `a = -0.75` → `MAX / 1.5`; `CodeRabbit` on `75ecdd3`). When `2 a` +/// overflows, form `(q / a) * -0.5`. Do not form `2 a` as the only +/// path: at `a = -1e308`, `q = 1e308`, `2 a` overflows and +/// `-q / (2 a)` collapses to `+0`, but `(q / a) * -0.5 = 0.5`. Do +/// not form `0.5 q` first: at `q = from_bits(1)`, `a = -from_bits(1)`, +/// `-0.5 * q` underflows to `-0` and the quotient is `+0`, but +/// the representable Lyapunov solution is `0.5`. A zero diffusion is +/// exactly zero. `a ≥ 0` has no finite stationary variance +/// (including Brownian `a = 0`, whose variance grows as `q Δt`). +/// An overflowing Lyapunov solution fails closed. This is not a Kalman +/// filter, not DSEM, not a matrix `expm`, and not ctsem estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for any non-event +/// clock, [`PsychometricError::StationaryVarianceRequiresStableDrift`] +/// when the log-rate is not strictly negative, and +/// [`PsychometricError::InvalidNumericInput`] when the diffusion is +/// negative or non-finite, the log-rate is non-finite, or the mapped +/// variance is non-finite. +pub fn recover_stationary_latent_variance( + continuous_diffusion: f64, + log_rate: f64, + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + if !continuous_diffusion.is_finite() || continuous_diffusion < 0.0 || !log_rate.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + if log_rate >= 0.0 { + return Err(PsychometricError::StationaryVarianceRequiresStableDrift); + } + // Driver Eq. 4 as Δt → ∞: (0 − 1) q / (2 a) = −q / (2 a). + // Direct 0 * (1 / (2 a)) is not needed; a zero diffusion is zero. + if continuous_diffusion == 0.0 { + return Ok(0.0); + } + // −q / (2 a). When 2 a is finite, divide by that Kronecker sum + // so q/a overflow does not lose a finite Lyapunov solution + // (q = MAX, a = −0.75 → MAX/1.5; CodeRabbit on 75ecdd3). + // When 2 a overflows (|a| = 1e308), form (q/a)*−0.5 instead. + // Do not form 0.5 q first (min-subnormal underflow). + let twice_rate = log_rate * 2.0; + let stationary = if twice_rate.is_finite() { + continuous_diffusion / -twice_rate + } else { + (continuous_diffusion / log_rate) * -0.5 + }; + require_finite(stationary) +} + +/// Refuse treating finite-interval process noise as `asymDIFFUSION`. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 4 and p. 16): `Q_Δt` at a +/// finite event interval is the covariance of the stochastic integral +/// over that interval. The asymptotic within-subject variance is the +/// `Δt → ∞` limit. Section 4.3 distinguishes that stationary +/// constraint from a predetermined `T0VAR`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::FiniteIntervalProcessNoiseIsNotStationary`]. +pub fn refuse_finite_interval_process_noise_as_stationary_variance( + process_noise: f64, + event_delta: f64, +) -> Result { + let _ = (process_noise, event_delta); + Err(PsychometricError::FiniteIntervalProcessNoiseIsNotStationary) +} + +/// Exact scalar trait-plus-state latent variance. +/// +/// Driver, Oud, and Voelkle (2017, §4.3, p. 9; JSS PDF re-opened +/// 2026-08-18T21:07Z) add a stable trait process with `DRIFT` and +/// `DIFFUSION` fixed to zero. The scalar sum is `trait + state`. The +/// ctsem `TRAITVAR` parameterization that adds the trait to the +/// `DIFFUSION` matrix is a software rewrite; it does not license +/// treating trait variance as process noise. This is not RI-CLPM, not +/// a Kalman filter, and not ctsem estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidNumericInput`] when either +/// variance is negative or non-finite, or the sum overflows. +pub fn recover_trait_plus_state_latent_variance( + trait_variance: f64, + state_variance: f64, +) -> Result { + if !trait_variance.is_finite() || trait_variance < 0.0 { + return Err(PsychometricError::InvalidNumericInput); + } + if !state_variance.is_finite() || state_variance < 0.0 { + return Err(PsychometricError::InvalidNumericInput); + } + if trait_variance == 0.0 { + return Ok(state_variance); + } + if state_variance == 0.0 { + return Ok(trait_variance); + } + require_finite(trait_variance + state_variance) +} + +/// Exact scalar trait-plus-state lagged latent covariance. +/// +/// Driver, Oud, and Voelkle (2017, §4.3, p. 9): a stable trait has no +/// temporal dynamics, so `cov(trait_t, trait_{t-1}) = trait`. The +/// state lagged covariance remains `exp(a Δt) p` (Eq. 3–4). The +/// scalar sum is `trait + exp(a Δt) p`. Evolving the summed variance +/// as if it were all state is not this map. This is not RI-CLPM. +/// +/// # Errors +/// +/// Propagates [`recover_discrete_lagged_latent_covariance`]. Returns +/// [`PsychometricError::InvalidNumericInput`] when the trait variance +/// is negative or non-finite or the sum overflows. +pub fn recover_trait_plus_state_lagged_covariance( + trait_variance: f64, + state_prior_variance: f64, + log_rate: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + if !trait_variance.is_finite() || trait_variance < 0.0 { + return Err(PsychometricError::InvalidNumericInput); + } + let state_lagged = recover_discrete_lagged_latent_covariance( + state_prior_variance, + log_rate, + event_delta, + clock, + )?; + if trait_variance == 0.0 { + return Ok(state_lagged); + } + require_finite(trait_variance + state_lagged) +} + +/// Refuse treating Driver §4.3 trait variance as process noise. +/// +/// A stable trait has `DIFFUSION` fixed to zero. The ctsem +/// `TRAITVAR` rewrite that adds the trait to `DIFFUSION` is not a +/// license to treat trait variance as `Q_Δt`. +/// +/// # Errors +/// +/// Always returns [`PsychometricError::TraitVarianceIsNotProcessNoise`]. +pub fn refuse_trait_variance_as_process_noise( + trait_variance: f64, + process_noise: f64, +) -> Result { + let _ = (trait_variance, process_noise); + Err(PsychometricError::TraitVarianceIsNotProcessNoise) +} + +/// Refuse treating Driver §4.3 trait variance as `asymDIFFUSION`. +/// +/// Trait variance is time-invariant between-subject variance. The +/// stationary within-subject variance is the `Δt → ∞` limit of Eq. 4. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::TraitVarianceIsNotStationaryWithinSubject`]. +pub fn refuse_trait_variance_as_stationary_within_subject( + trait_variance: f64, + stationary_state_variance: f64, +) -> Result { + let _ = (trait_variance, stationary_state_variance); + Err(PsychometricError::TraitVarianceIsNotStationaryWithinSubject) +} + +/// Exact scalar observed-indicator variance from Driver Equation 5 +/// with `MANIFESTTRAITVAR = 0`. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; Table 2, p. 12; JSS +/// PDF re-opened 2026-08-19T04:18Z) write `y_i(t) = τ_i + Λ η_i(t) + +/// ε_i(t)` with `ε ~ N(0, Θ)` and `τ_i ~ N(μ_τ, Ψ_τ)`. Equation 1 +/// (p. 4) is the latent SDE, not the measurement model. Table 2 names +/// `Θ` `MANIFESTVAR` and `Ψ_τ` `MANIFESTTRAITVAR`. The p. 16 summary +/// restates those names; it is not the equation. With `Ψ_τ = 0` the +/// scalar map is `Var(y) = λ² Var(η) + θ`. Form `(λ p) λ` then add +/// `θ`. Do not form `λ²` first: at `λ = 1e308`, `p = 1e-308`, `λ²` +/// overflows and `λ² p` is non-finite, but `(λ p) λ = 1e308`. A zero +/// loading or zero latent variance is exactly `θ`. A zero +/// measurement error is exactly `λ² p`. Negative latent or +/// measurement-error variance fails closed. An overflowing product +/// or sum fails closed. This is not a Kalman filter, not ESEM +/// estimation, and not ctsem estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidNumericInput`] when the loading +/// is non-finite, either variance is negative or non-finite, or the +/// mapped variance is non-finite. +pub fn recover_manifest_observed_variance( + loading: f64, + latent_variance: f64, + measurement_error_variance: f64, +) -> Result { + if !loading.is_finite() + || !latent_variance.is_finite() + || latent_variance < 0.0 + || !measurement_error_variance.is_finite() + || measurement_error_variance < 0.0 + { + return Err(PsychometricError::InvalidNumericInput); + } + if loading == 0.0 || latent_variance == 0.0 { + return Ok(measurement_error_variance); + } + let explained = require_finite((loading * latent_variance) * loading)?; + if measurement_error_variance == 0.0 { + return Ok(explained); + } + require_finite(explained + measurement_error_variance) +} + +/// Refuse treating Driver Eq. 5 measurement error as `Var(y)`. +/// +/// Table 2 (p. 12) names `MANIFESTVAR` as `Θ`, the variance of `ε`. +/// Equation 5 maps `Var(y) = λ² Var(η) + θ` when `Ψ_τ = 0`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::MeasurementErrorIsNotObservedVariance`]. +pub fn refuse_measurement_error_as_observed_variance( + measurement_error_variance: f64, + observed_variance: f64, +) -> Result { + let _ = (measurement_error_variance, observed_variance); + Err(PsychometricError::MeasurementErrorIsNotObservedVariance) +} + +/// Refuse treating Driver Eq. 5 latent variance as `Var(y)`. +/// +/// `Var(η)` is the latent process variance. Equation 5 maps +/// `Var(y) = λ² Var(η) + θ` when `Ψ_τ = 0`. +/// +/// # Errors +/// +/// Always returns [`PsychometricError::LatentVarianceIsNotObservedVariance`]. +pub fn refuse_latent_variance_as_observed_variance( + latent_variance: f64, + observed_variance: f64, +) -> Result { + let _ = (latent_variance, observed_variance); + Err(PsychometricError::LatentVarianceIsNotObservedVariance) +} + +/// Exact scalar observed-indicator variance from Driver Equation 5 +/// with nonzero `MANIFESTTRAITVAR`. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; Table 2, p. 12; JSS +/// PDF re-opened 2026-08-19T04:18Z) write `τ_i ~ N(μ_τ, Ψ_τ)` on the +/// indicator intercept. The scalar map is `Var(y) = λ² Var(η) + θ + +/// ψ`. Form the `Ψ_τ = 0` map first, then add `ψ`. Do not form +/// `λ²` first. A zero manifest trait is exactly `λ² p + θ`. A zero +/// loading or zero latent variance is exactly `θ + ψ`. `Ψ_τ` is not +/// `Θ`: Table 2 names `MANIFESTTRAITVAR` separately from +/// `MANIFESTVAR`. `TRAITVAR` is latent additional variance and is +/// scaled by `λ²`; `MANIFESTTRAITVAR` is not. Negative trait +/// variance fails closed. An overflowing sum fails closed. This is +/// not a Kalman filter, not ESEM estimation, and not ctsem +/// estimation. +/// +/// # Errors +/// +/// Propagates [`recover_manifest_observed_variance`]. Returns +/// [`PsychometricError::InvalidNumericInput`] when the manifest-trait +/// variance is negative or non-finite or the sum overflows. +pub fn recover_manifest_trait_plus_state_observed_variance( + loading: f64, + latent_variance: f64, + measurement_error_variance: f64, + manifest_trait_variance: f64, +) -> Result { + if !manifest_trait_variance.is_finite() || manifest_trait_variance < 0.0 { + return Err(PsychometricError::InvalidNumericInput); + } + let within = + recover_manifest_observed_variance(loading, latent_variance, measurement_error_variance)?; + if manifest_trait_variance == 0.0 { + return Ok(within); + } + require_finite(within + manifest_trait_variance) +} + +/// Refuse treating Driver Eq. 5 `MANIFESTTRAITVAR` as `MANIFESTVAR`. +/// +/// Table 2 (p. 12) names `MANIFESTTRAITVAR` as `Ψ_τ`, additional +/// intercept variance on the indicators, and `MANIFESTVAR` as `Θ`, +/// the variance of `ε`. Equation 5 maps `Var(y) = λ² Var(η) + θ + +/// ψ`. `Ψ_τ` is not `Θ`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::ManifestTraitVarianceIsNotMeasurementError`]. +pub fn refuse_manifest_trait_variance_as_measurement_error( + manifest_trait_variance: f64, + measurement_error_variance: f64, +) -> Result { + let _ = (manifest_trait_variance, measurement_error_variance); + Err(PsychometricError::ManifestTraitVarianceIsNotMeasurementError) +} + +/// Exact scalar lagged observed-indicator covariance from Driver +/// Equation 5. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; Eq. 3–4, pp. 4–5; +/// Table 2, p. 12; JSS PDF re-opened 2026-08-19T04:18Z) write +/// `y_i(t) = τ_i + Λ η_i(t) + ε_i(t)` with independent measurement +/// error and a person-level intercept `τ_i ~ N(μ_τ, Ψ_τ)`. The +/// scalar lagged covariance is `cov(y_t, y_{t-1}) = λ² cov(η_t, +/// η_{t-1}) + ψ`. `Θ` does not enter: `ε_t` and `ε_{t-1}` are +/// independent. Form `(λ c) λ` then add `ψ`. Do not form `λ²` +/// first. A zero loading or zero latent lagged covariance is +/// exactly `ψ`. A zero manifest trait is exactly `λ² c`. Negative +/// latent lagged covariance or trait variance fails closed. An +/// overflowing product or sum fails closed. This is not a Kalman +/// filter and not ctsem estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidNumericInput`] when the loading +/// is non-finite, the latent lagged covariance is negative or +/// non-finite, the manifest-trait variance is negative or +/// non-finite, or the mapped covariance is non-finite. +pub fn recover_manifest_lagged_observed_covariance( + loading: f64, + lagged_latent_covariance: f64, + manifest_trait_variance: f64, +) -> Result { + if !loading.is_finite() + || !lagged_latent_covariance.is_finite() + || lagged_latent_covariance < 0.0 + || !manifest_trait_variance.is_finite() + || manifest_trait_variance < 0.0 + { + return Err(PsychometricError::InvalidNumericInput); + } + if loading == 0.0 || lagged_latent_covariance == 0.0 { + return Ok(manifest_trait_variance); + } + let explained = require_finite((loading * lagged_latent_covariance) * loading)?; + if manifest_trait_variance == 0.0 { + return Ok(explained); + } + require_finite(explained + manifest_trait_variance) +} + +/// Refuse treating Driver Eq. 3–4 lagged latent covariance as +/// `cov(y_t, y_{t-1})`. +/// +/// Equation 5 maps `cov(y_t, y_{t-1}) = λ² cov(η_t, η_{t-1}) + ψ`. +/// The latent lagged covariance is not the observed lagged +/// covariance. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::LatentLaggedCovarianceIsNotObservedCovariance`]. +pub fn refuse_latent_lagged_covariance_as_observed_covariance( + lagged_latent_covariance: f64, + observed_lagged_covariance: f64, +) -> Result { + let _ = (lagged_latent_covariance, observed_lagged_covariance); + Err(PsychometricError::LatentLaggedCovarianceIsNotObservedCovariance) +} + +/// Refuse treating Driver Eq. 5 measurement error as lagged observed +/// covariance. +/// +/// `MANIFESTVAR` is `Θ`. Independent `ε_t` does not enter +/// `cov(y_t, y_{t-1})`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::MeasurementErrorIsNotLaggedObservedCovariance`]. +pub fn refuse_measurement_error_as_lagged_observed_covariance( + measurement_error_variance: f64, + observed_lagged_covariance: f64, +) -> Result { + let _ = (measurement_error_variance, observed_lagged_covariance); + Err(PsychometricError::MeasurementErrorIsNotLaggedObservedCovariance) +} + +/// Exact scalar observed-indicator mean from Driver Equation 5. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; Table 2, p. 12; JSS +/// PDF re-opened 2026-08-19T14:08Z) write `y_i(t) = Γ + Λ η_i(t) + +/// ζ_i(t)` with `ζ ~ N(0, Θ)` and `Γ ~ N(τ, Ψ)`. The expected +/// intercept is `τ`. Table 2 names `τ` `MANIFESTMEANS`. The scalar +/// map is `E(y) = τ + λ μ`. Form `λ μ` then add `τ`. A zero loading +/// or zero latent mean is exactly `τ`. A zero intercept is exactly +/// `λ μ`. `MANIFESTMEANS` is not `E(y)`. `E(η)` is not `E(y)`. +/// `CINT` `κ` is the latent continuous intercept from Equation 1, +/// not `τ`. `T0MEANS` is the initial latent mean, not `E(y)`. An +/// overflowing product or sum fails closed. This is not a Kalman +/// filter and not ctsem estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidNumericInput`] when the loading, +/// latent mean, or intercept is non-finite, or the mapped mean is +/// non-finite. +pub fn recover_manifest_observed_mean( + loading: f64, + latent_mean: f64, + manifest_mean: f64, +) -> Result { + if !loading.is_finite() || !latent_mean.is_finite() || !manifest_mean.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + if loading == 0.0 || latent_mean == 0.0 { + return Ok(manifest_mean); + } + let explained = require_finite(loading * latent_mean)?; + if manifest_mean == 0.0 { + return Ok(explained); + } + require_finite(explained + manifest_mean) +} + +/// Refuse treating Driver Eq. 5 `MANIFESTMEANS` as `E(y)`. +/// +/// Table 2 (p. 12) names `τ` the expected intercept `Γ`. Equation 5 +/// maps `E(y) = τ + λ μ`. +/// +/// # Errors +/// +/// Always returns [`PsychometricError::ManifestMeansIsNotObservedMean`]. +pub fn refuse_manifest_means_as_observed_mean( + manifest_mean: f64, + observed_mean: f64, +) -> Result { + let _ = (manifest_mean, observed_mean); + Err(PsychometricError::ManifestMeansIsNotObservedMean) +} + +/// Refuse treating Driver Eq. 5 latent mean as `E(y)`. +/// +/// `E(η)` is the latent process mean. Equation 5 maps `E(y) = τ + λ μ`. +/// `T0MEANS` is that latent mean at the first occasion, not `E(y)`. +/// +/// # Errors +/// +/// Always returns [`PsychometricError::LatentMeanIsNotObservedMean`]. +pub fn refuse_latent_mean_as_observed_mean( + latent_mean: f64, + observed_mean: f64, +) -> Result { + let _ = (latent_mean, observed_mean); + Err(PsychometricError::LatentMeanIsNotObservedMean) +} + +/// Refuse treating Driver Table 2 `CINT` as `MANIFESTMEANS`. +/// +/// Table 2 (p. 12) names `κ` `CINT`, the latent continuous intercept +/// from Equation 1, and `τ` `MANIFESTMEANS`, the expected `Γ` from +/// Equation 5. `κ` is not `τ`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::ContinuousInterceptIsNotManifestMeans`]. +pub fn refuse_continuous_intercept_as_manifest_means( + continuous_intercept: f64, + manifest_mean: f64, +) -> Result { + let _ = (continuous_intercept, manifest_mean); + Err(PsychometricError::ContinuousInterceptIsNotManifestMeans) +} + +/// Exact scalar discrete intercept increment from Driver Equation 3. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 3, p. 4; Table 2, p. 12; JSS +/// PDF re-opened 2026-08-19T18:10Z) write the expected-value term +/// `A^{-1}[e^{A Δt} − I] b` after the stochastic integral is taken +/// to have mean zero. Table 2 names `κ` `CINT`. The scalar map is +/// `κ (expm1(a Δt) / a)` for `a ≠ 0`. A zero drift is the Eq. 3 +/// integral with `A = 0`: `κ Δt`. That path has no matrix inverse. +/// A zero intercept is exactly zero. `CINT` is not this discrete +/// increment. The `a ≠ 0` evaluation is +/// [`recover_discrete_constant_predictor_effect`]. This is not a +/// Kalman filter and not ctsem estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for any non-event +/// clock, [`PsychometricError::NonPositiveInterval`] when +/// `event_delta` is not strictly positive, and +/// [`PsychometricError::InvalidNumericInput`] when the intercept or +/// drift is non-finite or the mapped increment is non-finite. +pub fn recover_discrete_continuous_intercept_effect( + continuous_intercept: f64, + log_rate: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + if !event_delta.is_finite() || event_delta <= 0.0 { + return Err(PsychometricError::NonPositiveInterval); + } + if !continuous_intercept.is_finite() || !log_rate.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + if log_rate == 0.0 { + if continuous_intercept == 0.0 { + return Ok(0.0); + } + return require_finite(continuous_intercept * event_delta); + } + recover_discrete_constant_predictor_effect(continuous_intercept, log_rate, event_delta, clock) +} + +/// Exact scalar discrete latent mean from Driver Equation 3. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 3, p. 4; Table 2, p. 12; JSS +/// PDF re-opened 2026-08-19T18:10Z) write +/// `η(t) = exp(A Δt) η(t0) + ∫ exp(A(t−s)) (b + …) ds` plus a +/// stochastic integral of mean zero. With no time-varying covariates +/// the scalar expected-value map is +/// `μ_t = exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ`. Table 2 names `μ_0` +/// at the first occasion `T0MEANS` and `κ` `CINT`. Form the CINT +/// increment first, then add the carried `T0MEANS` term. A zero +/// initial mean is exactly the increment. A zero intercept is exactly +/// `exp(a Δt) μ_0`. A zero drift carries `T0MEANS` unchanged and adds +/// `κ Δt`. As `Δt → ∞` with stable `a < 0`, `μ_t → −κ / a`. Binary64 +/// underflow of `exp(a Δt)` to `+0` drops the carried `T0MEANS` and +/// keeps the equilibrium increment. `T0MEANS` is not `μ_t`. `CINT` is +/// not the discrete increment. `CINT` is not `T0MEANS`. This is not a +/// Kalman filter and not ctsem estimation. +/// +/// # Errors +/// +/// Propagates [`recover_discrete_continuous_intercept_effect`] and +/// returns [`PsychometricError::InvalidNumericInput`] when the initial +/// mean is non-finite, the carried exponential overflows, or the +/// mapped mean is non-finite. +pub fn recover_discrete_latent_mean( + initial_latent_mean: f64, + log_rate: f64, + continuous_intercept: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + let intercept_effect = recover_discrete_continuous_intercept_effect( + continuous_intercept, + log_rate, + event_delta, + clock, + )?; + if !initial_latent_mean.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + if initial_latent_mean == 0.0 { + return Ok(intercept_effect); + } + let carried = if log_rate == 0.0 { + initial_latent_mean + } else { + let increment_argument = log_rate * event_delta; + if increment_argument == 0.0 { + initial_latent_mean + } else { + let discrete_lag = increment_argument.exp(); + if discrete_lag == 0.0 { + 0.0 + } else if !discrete_lag.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } else { + require_finite(discrete_lag * initial_latent_mean)? + } + } + }; + if intercept_effect == 0.0 { + return Ok(carried); + } + if carried == 0.0 { + return Ok(intercept_effect); + } + require_finite(carried + intercept_effect) +} + +/// Exact scalar discrete observed-indicator mean from Driver +/// Equations 3 and 5. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 3, p. 5; Eq. 5, p. 5; +/// Table 2, p. 12; JSS PDF re-opened 2026-08-19T22:10Z) write +/// `η_i(t) = exp(A Δt) η_i(t0) + A^{-1}[exp(A Δt) − I] ξ_i + …` +/// with `ξ_i ~ N(κ, φ_ξ)` (p. 4) and a stochastic integral of +/// mean zero, then `y_i(t) = Γ_i + Λ η_i(t) + ζ_i(t)` with +/// `Γ ~ N(τ, Ψ)` and `ζ ~ N(0, Θ)`. The scalar expected-value +/// composition is `E(y_t) = τ + λ μ_t` with +/// `μ_t = exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ`. Form `μ_t` +/// first, then `τ + λ μ_t`. Table 2 names `μ_0` `T0MEANS`, `κ` +/// `CINT`, and `τ` `MANIFESTMEANS`. A zero loading is exactly +/// `τ`. A zero evolved latent mean is exactly `τ`. A zero +/// intercept is exactly `λ μ_t`. The first-occasion map +/// `τ + λ μ_0` is not `E(y_t)`. `MANIFESTMEANS` is not +/// `E(y_t)`. `T0MEANS` is not `E(y_t)`. `μ_t` is not `E(y_t)`. +/// `CINT` is not `E(y_t)`. This is not a Kalman filter and not +/// ctsem estimation. +/// +/// # Errors +/// +/// Propagates [`recover_discrete_latent_mean`] and +/// [`recover_manifest_observed_mean`]. +pub fn recover_discrete_observed_mean( + loading: f64, + initial_latent_mean: f64, + log_rate: f64, + continuous_intercept: f64, + manifest_mean: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + let evolved_latent_mean = recover_discrete_latent_mean( + initial_latent_mean, + log_rate, + continuous_intercept, + event_delta, + clock, + )?; + recover_manifest_observed_mean(loading, evolved_latent_mean, manifest_mean) +} + +/// Refuse treating the first-occasion observed mean as `E(y_t)`. +/// +/// Equation 5 of `T0MEANS` is `τ + λ μ_0`. Equation 5 of the +/// Eq. 3 evolved mean is `τ + λ μ_t`. Those are not the same +/// map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::InitialObservedMeanIsNotEvolvedObservedMean`]. +pub fn refuse_initial_observed_mean_as_evolved_observed_mean( + initial_observed_mean: f64, + evolved_observed_mean: f64, +) -> Result { + let _ = (initial_observed_mean, evolved_observed_mean); + Err(PsychometricError::InitialObservedMeanIsNotEvolvedObservedMean) +} + +/// Exact scalar contemporaneous impulse from Driver Equation 3. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 1–3, pp. 4–5; Table 2, p. 12; +/// §7.2, pp. 20–21; JSS PDF re-opened 2026-08-20T07:10Z from +/// ) +/// write the time-dependent predictor as the Dirac impulse +/// `χ_i(t) = Σ_{u ∈ U_i} x_{i,u} δ(t − u)` (Eq. 2). Equation 3's +/// fourth summand is `M Σ x_{i,u} δ(t − u)`. Table 2 names `M` +/// `TDPREDEFFECT`. Section 7.2 calls this "a sudden impulse to the +/// system which then dissipates back to the process mean" and reports +/// `TDPREDEFFECT` as "the initial impact of the predictor on the +/// processes." The scalar contemporaneous jump is `m x`. It is not +/// the second-summand `CINT` map `A^{-1}[e^{A Δt} − I] κ`, not the +/// third-summand time-independent map `A^{-1}[e^{A Δt} − I] B z`, +/// and not Voelkle et al. (2012, Eq. 14) `a_{yx} Δt`. The §7.2 +/// lasting level change sets `CINT` to `TDPREDEFFECT * −DRIFT` +/// (`κ = −a m x`) and is not this jump. The extra near-zero-drift +/// latent process also named in §7.2 is a third specification. A +/// zero effect or zero predictor is exactly zero. This is not a +/// Kalman filter and not ctsem estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidNumericInput`] when the effect +/// or predictor is non-finite or the product overflows. +pub fn recover_time_dependent_predictor_impulse( + time_dependent_effect: f64, + time_dependent_predictor: f64, +) -> Result { + if !time_dependent_effect.is_finite() || !time_dependent_predictor.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + if time_dependent_effect == 0.0 || time_dependent_predictor == 0.0 { + return Ok(0.0); + } + require_finite(time_dependent_effect * time_dependent_predictor) +} + +/// Exact scalar level-change `CINT` from Driver Section 7.2. +/// +/// Driver, Oud, and Voelkle (2017, §7.2, pp. 20–21; Eq. 1–3, pp. 4–5; +/// Table 2, p. 12; JSS PDF re-opened 2026-08-20T19:45Z from +/// ) +/// contrast a sudden Dirac that dissipates back to the process mean +/// with a lasting level change. To generate that lasting change, +/// `CINT` is set to `TDPREDEFFECT * −DRIFT`. The scalar setting is +/// `κ = −a m x`. Form `m x` first, then multiply by `−a`. A zero +/// effect or zero predictor is exactly zero. Stable `a < 0` is +/// required so `−κ / a = m x` is an equilibrium offset. `a ≥ 0` +/// cannot hold a new process mean. `−a m x` is not the +/// contemporaneous jump `m x`. `−a m x` is not a free `CINT`. +/// `−a m x` is not `A^{-1}[e^{A Δt} − I] B z`. The extra +/// near-zero-drift latent process also named in §7.2 is a different +/// specification and is not this `CINT` setting. This is not a +/// Kalman filter and not ctsem estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidNumericInput`] when an input +/// is non-finite or a product overflows, and +/// [`PsychometricError::LevelChangeRequiresStableDrift`] when the +/// drift is not strictly negative and the impulse is nonzero. +pub fn recover_level_change_continuous_intercept( + time_dependent_effect: f64, + time_dependent_predictor: f64, + log_rate: f64, +) -> Result { + if !time_dependent_effect.is_finite() + || !time_dependent_predictor.is_finite() + || !log_rate.is_finite() + { + return Err(PsychometricError::InvalidNumericInput); + } + if time_dependent_effect == 0.0 || time_dependent_predictor == 0.0 { + return Ok(0.0); + } + if log_rate >= 0.0 { + return Err(PsychometricError::LevelChangeRequiresStableDrift); + } + let impulse = require_finite(time_dependent_effect * time_dependent_predictor)?; + require_finite(-log_rate * impulse) +} + +/// Refuse treating the §7.2 level-change `CINT` as the +/// contemporaneous Dirac. +/// +/// `κ = −a m x` is not the jump `m x`. +/// +/// # Errors +/// +/// Always returns [`PsychometricError::LevelChangeInterceptIsNotImpulse`]. +pub fn refuse_level_change_intercept_as_impulse( + level_change_intercept: f64, + time_dependent_impulse: f64, +) -> Result { + let _ = (level_change_intercept, time_dependent_impulse); + Err(PsychometricError::LevelChangeInterceptIsNotImpulse) +} + +/// Refuse treating the §7.2 level-change `CINT` as a free `CINT`. +/// +/// `κ = −a m x` is not an arbitrary continuous intercept. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::LevelChangeInterceptIsNotFreeContinuousIntercept`]. +pub fn refuse_level_change_intercept_as_free_continuous_intercept( + level_change_intercept: f64, + continuous_intercept: f64, +) -> Result { + let _ = (level_change_intercept, continuous_intercept); + Err(PsychometricError::LevelChangeInterceptIsNotFreeContinuousIntercept) +} + +/// Refuse treating the §7.2 level-change `CINT` as the Eq. 3 +/// process increment. +/// +/// `κ = −a m x` is not `A^{-1}[e^{A Δt} − I] B z`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::LevelChangeInterceptIsNotProcessIncrement`]. +pub fn refuse_level_change_intercept_as_process_increment( + level_change_intercept: f64, + time_independent_increment: f64, +) -> Result { + let _ = (level_change_intercept, time_independent_increment); + Err(PsychometricError::LevelChangeInterceptIsNotProcessIncrement) +} + +/// Exact scalar discrete increment of the §7.2 level-change `CINT`. +/// +/// Driver, Oud, and Voelkle (2017, §7.2, pp. 20–21; Eq. 3, pp. 4–5; +/// Table 2, p. 12; JSS PDF re-opened 2026-08-20T19:50Z from +/// ) +/// set `CINT` to `TDPREDEFFECT * −DRIFT` so a sudden impulse holds a +/// new process mean. Equation 3 maps that intercept through +/// `A^{-1}[e^{A Δt} − I] κ`. With `κ = −a m x` the scalar increment +/// is `(e^{a Δt} − 1)/a · (−a m x) = (1 − e^{a Δt}) m x`. Form the +/// level-change `CINT` first, then the discrete intercept map. +/// Underflow of `e^{a Δt}` to `+0` keeps the equilibrium offset +/// `m x`. A zero effect or zero predictor is exactly zero. Stable +/// `a < 0` is required. `(1 − e^{a Δt}) m x` is not the +/// contemporaneous jump `m x`. `(1 − e^{a Δt}) m x` is not `κ`. +/// `(1 − e^{a Δt}) m x` is not `A^{-1}[e^{A Δt} − I] B z`. The extra +/// near-zero-drift latent process also named in §7.2 is a different +/// specification. This is not a Kalman filter and not ctsem +/// estimation. +/// +/// # Errors +/// +/// Propagates [`recover_level_change_continuous_intercept`] and +/// [`recover_discrete_continuous_intercept_effect`]. +pub fn recover_level_change_discrete_increment( + time_dependent_effect: f64, + time_dependent_predictor: f64, + log_rate: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + let intercept = recover_level_change_continuous_intercept( + time_dependent_effect, + time_dependent_predictor, + log_rate, + )?; + recover_discrete_continuous_intercept_effect(intercept, log_rate, event_delta, clock) +} + +/// Refuse treating the §7.2 level-change CINT increment as the +/// contemporaneous Dirac. +/// +/// `(1 − e^{a Δt}) m x` is not the jump `m x`. +/// +/// # Errors +/// +/// Always returns [`PsychometricError::LevelChangeIncrementIsNotImpulse`]. +pub fn refuse_level_change_increment_as_impulse( + level_change_increment: f64, + time_dependent_impulse: f64, +) -> Result { + let _ = (level_change_increment, time_dependent_impulse); + Err(PsychometricError::LevelChangeIncrementIsNotImpulse) +} + +/// Refuse treating the §7.2 level-change CINT increment as `CINT`. +/// +/// `(1 − e^{a Δt}) m x` is not `κ = −a m x`. +/// +/// # Errors +/// +/// Always returns [`PsychometricError::LevelChangeIncrementIsNotIntercept`]. +pub fn refuse_level_change_increment_as_intercept( + level_change_increment: f64, + level_change_intercept: f64, +) -> Result { + let _ = (level_change_increment, level_change_intercept); + Err(PsychometricError::LevelChangeIncrementIsNotIntercept) +} + +/// Refuse treating the §7.2 level-change CINT increment as the Eq. 3 +/// process increment. +/// +/// `(1 − e^{a Δt}) m x` is not `A^{-1}[e^{A Δt} − I] B z`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::LevelChangeIncrementIsNotProcessIncrement`]. +pub fn refuse_level_change_increment_as_process_increment( + level_change_increment: f64, + time_independent_increment: f64, +) -> Result { + let _ = (level_change_increment, time_independent_increment); + Err(PsychometricError::LevelChangeIncrementIsNotProcessIncrement) +} + +/// Exact scalar contribution of the §7.2 extra near-zero-drift process. +/// +/// Driver, Oud, and Voelkle (2017, §7.2, pp. 22–23; Eq. 1–3, pp. 4–5; +/// Table 2, p. 12; JSS PDF re-opened 2026-08-20T23:10Z from +/// ) +/// specify a lasting level change by an extra latent process, not by +/// rewriting `CINT`. `T0MEANS`, `CINT`, `T0VAR`, `DIFFUSION`, and +/// `TRAITVAR` of that process are fixed to 0. `TDPREDEFFECT` on it is +/// fixed to 1 to identify the effect. Its `DRIFT` diagonal is very +/// close to 0 (printed example `−0.000001`; precisely 0 causes +/// computational problems). The original process is driven by the +/// `DRIFT` coupling `a_{ηξ}`. After a unit identification impulse the +/// extra state is `x e^{ε t}` and the scalar contribution to the +/// original process is `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)`. +/// Form `a_{ηξ} x` first. When `ε = a` the contribution is +/// `a_{ηξ} x Δt e^{a Δt}`. A zero coupling or zero predictor is +/// exactly zero. `ε ≥ 0` cannot hold a lasting extra state and fails +/// closed. That contribution is not `κ = −a m x`, not +/// `(1 − e^{a Δt}) m x`, and not the dissipating Dirac `m x`. This +/// is not a Kalman filter, not a matrix `expm`, and not ctsem +/// estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for any non-event +/// clock, [`PsychometricError::NonPositiveInterval`] when the interval +/// is not strictly positive, +/// [`PsychometricError::LevelChangeExtraProcessRequiresNegativeDrift`] +/// when the extra drift is not strictly negative and the contribution +/// is nonzero, and [`PsychometricError::InvalidNumericInput`] when an +/// input is non-finite or a product, exponential, or quotient +/// overflows. +pub fn recover_level_change_extra_process_contribution( + original_from_extra_drift: f64, + time_dependent_predictor: f64, + original_log_rate: f64, + extra_log_rate: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + if !event_delta.is_finite() || event_delta <= 0.0 { + return Err(PsychometricError::NonPositiveInterval); + } + if !original_from_extra_drift.is_finite() + || !time_dependent_predictor.is_finite() + || !original_log_rate.is_finite() + || !extra_log_rate.is_finite() + { + return Err(PsychometricError::InvalidNumericInput); + } + if original_from_extra_drift == 0.0 || time_dependent_predictor == 0.0 { + return Ok(0.0); + } + if extra_log_rate >= 0.0 { + return Err(PsychometricError::LevelChangeExtraProcessRequiresNegativeDrift); + } + let coupling = require_finite(original_from_extra_drift * time_dependent_predictor)?; + // `e^{ε Δt}` with `ε < 0`; `exp(0) = 1` after product underflow. + let extra_lag = (extra_log_rate * event_delta).exp(); + let original_argument = original_log_rate * event_delta; + let original_lag = if original_log_rate == 0.0 { + 1.0 + } else { + let lag = original_argument.exp(); + if !lag.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + lag + }; + let rate_gap = extra_log_rate - original_log_rate; + let gap_argument = rate_gap * event_delta; + if gap_argument == 0.0 { + return require_finite(coupling * event_delta * original_lag); + } + let increment = gap_argument.exp_m1(); + if !increment.is_finite() { + return require_finite(coupling * (extra_lag - original_lag) / rate_gap); + } + if original_lag == 0.0 { + return require_finite(coupling * extra_lag / rate_gap); + } + require_finite(coupling * original_lag * (increment / rate_gap)) +} + +/// Refuse treating the §7.2 extra-process contribution as the +/// contemporaneous Dirac. +/// +/// `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` is not the jump `m x`. +/// +/// # Errors +/// +/// Always returns [`PsychometricError::LevelChangeExtraProcessIsNotImpulse`]. +pub fn refuse_level_change_extra_process_as_impulse( + extra_process_contribution: f64, + time_dependent_impulse: f64, +) -> Result { + let _ = (extra_process_contribution, time_dependent_impulse); + Err(PsychometricError::LevelChangeExtraProcessIsNotImpulse) +} + +/// Refuse treating the §7.2 extra-process contribution as the +/// level-change `CINT`. +/// +/// `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` is not `κ = −a m x`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::LevelChangeExtraProcessIsNotIntercept`]. +pub fn refuse_level_change_extra_process_as_intercept( + extra_process_contribution: f64, + level_change_intercept: f64, +) -> Result { + let _ = (extra_process_contribution, level_change_intercept); + Err(PsychometricError::LevelChangeExtraProcessIsNotIntercept) +} + +/// Refuse treating the §7.2 extra-process contribution as the Eq. 3 +/// level-change increment. +/// +/// `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` is not +/// `(1 − e^{a Δt}) m x`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::LevelChangeExtraProcessIsNotIncrement`]. +pub fn refuse_level_change_extra_process_as_increment( + extra_process_contribution: f64, + level_change_increment: f64, +) -> Result { + let _ = (extra_process_contribution, level_change_increment); + Err(PsychometricError::LevelChangeExtraProcessIsNotIncrement) +} + +/// Exact scalar evolved latent mean plus a §7.2 extra-process contribution. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 3, p. 5; §7.2, pp. 22–23; JSS +/// PDF re-opened 2026-08-21T06:12Z from +/// ) +/// write the first two summands as the carried `T0MEANS` and `CINT` +/// increment. Section 7.2 then drives the original process by the +/// extra near-zero-drift latent process through the `DRIFT` coupling +/// `a_{ηξ}`. Form `μ_t` first, then add the extra-process +/// contribution. A zero contribution is exactly `μ_t`. A zero evolved +/// mean is exactly the contribution. The first-occasion map +/// `μ_0 + contribution` is not this composition when the process has +/// already evolved. The contemporaneous Dirac `μ_t + m x` is not this +/// composition. The printed specification puts `TDPREDEFFECT` on the +/// extra process, not on the original process. This is not a Kalman +/// filter and not ctsem estimation. +/// +/// # Errors +/// +/// Propagates [`recover_discrete_latent_mean`] and +/// [`recover_level_change_extra_process_contribution`], and returns +/// [`PsychometricError::InvalidNumericInput`] when the sum overflows. +#[allow(clippy::too_many_arguments)] +pub fn recover_discrete_latent_mean_with_extra_process( + initial_latent_mean: f64, + original_log_rate: f64, + continuous_intercept: f64, + original_from_extra_drift: f64, + time_dependent_predictor: f64, + extra_log_rate: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + let evolved_latent_mean = recover_discrete_latent_mean( + initial_latent_mean, + original_log_rate, + continuous_intercept, + event_delta, + clock, + )?; + let contribution = recover_level_change_extra_process_contribution( + original_from_extra_drift, + time_dependent_predictor, + original_log_rate, + extra_log_rate, + event_delta, + clock, + )?; + if contribution == 0.0 { + return Ok(evolved_latent_mean); + } + if evolved_latent_mean == 0.0 { + return Ok(contribution); + } + require_finite(evolved_latent_mean + contribution) +} + +/// Exact scalar observed mean of a §7.2 extra-process contribution. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; Eq. 1–3, pp. 4–5; +/// §7.2, pp. 22–23; JSS PDF re-opened 2026-08-21T06:12Z from +/// ) +/// write `y_i(t) = Γ + Λ η_i(t) + ζ_i(t)` with `ζ ~ N(0, Θ)` and +/// `Γ ~ N(τ, Ψ)`. The expected intercept is `τ`. Section 7.2's +/// printed extra process has `LAMBDA` 0: it is not an observed +/// indicator. Original indicators load on the original process after +/// the `DRIFT` coupling. The latent process at `t` after that +/// contribution is `μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)`. +/// The scalar composition is +/// `E(y_t) = τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a))`. +/// Form the evolved-plus-contribution latent mean first, then +/// `τ + λ` of that mean. Table 2 names `τ` `MANIFESTMEANS`. A zero +/// loading is exactly `τ`. A zero evolved-plus-contribution latent +/// mean is exactly `τ`. A zero intercept is exactly `λ` of that +/// latent mean. The evolved observed mean `τ + λ μ_t` is not this +/// composition when the contribution is nonzero. The contemporaneous +/// map `τ + λ(μ_t + m x)` is not this composition. `MANIFESTMEANS` is +/// not `E(y_t)`. The extra-process contribution is not `E(y_t)`. The +/// evolved-plus-contribution latent mean is not `E(y_t)`. The extra +/// process itself is not an observed indicator. This is not a Kalman +/// filter and not ctsem estimation. +/// +/// # Errors +/// +/// Propagates [`recover_discrete_latent_mean_with_extra_process`] and +/// [`recover_manifest_observed_mean`]. +#[allow(clippy::too_many_arguments)] +pub fn recover_discrete_observed_mean_with_extra_process( + loading: f64, + initial_latent_mean: f64, + original_log_rate: f64, + continuous_intercept: f64, + original_from_extra_drift: f64, + time_dependent_predictor: f64, + extra_log_rate: f64, + manifest_mean: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + let extra_latent_mean = recover_discrete_latent_mean_with_extra_process( + initial_latent_mean, + original_log_rate, + continuous_intercept, + original_from_extra_drift, + time_dependent_predictor, + extra_log_rate, + event_delta, + clock, + )?; + recover_manifest_observed_mean(loading, extra_latent_mean, manifest_mean) +} + +/// Refuse treating the evolved observed mean as the extra-process +/// observed mean. +/// +/// Equation 5 of the Eq. 3 evolved mean is `τ + λ μ_t`. Equation 5 +/// of the §7.2 extra-process contribution is +/// `τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a))`. Those +/// are not the same map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::EvolvedObservedMeanIsNotExtraProcessObservedMean`]. +pub fn refuse_evolved_observed_mean_as_extra_process_observed_mean( + evolved_observed_mean: f64, + extra_process_observed_mean: f64, +) -> Result { + let _ = (evolved_observed_mean, extra_process_observed_mean); + Err(PsychometricError::EvolvedObservedMeanIsNotExtraProcessObservedMean) +} + +/// Refuse treating the contemporaneous-impulse observed mean as the +/// extra-process observed mean. +/// +/// Equation 5 of the contemporaneous Dirac is `τ + λ(μ_t + m x)`. +/// Equation 5 of the §7.2 extra-process contribution is +/// `τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a))`. Those +/// are not the same map. The printed specification puts +/// `TDPREDEFFECT` on the extra process, not on the original process. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::ImpulseObservedMeanIsNotExtraProcessObservedMean`]. +pub fn refuse_impulse_observed_mean_as_extra_process_observed_mean( + impulse_observed_mean: f64, + extra_process_observed_mean: f64, +) -> Result { + let _ = (impulse_observed_mean, extra_process_observed_mean); + Err(PsychometricError::ImpulseObservedMeanIsNotExtraProcessObservedMean) +} + +/// Refuse treating the §7.2 extra-process contribution as `E(y_t)`. +/// +/// The contribution is not `τ + λ` of the evolved-plus-contribution +/// latent mean. The extra process has `LAMBDA` 0 in the printed +/// specification. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::ExtraProcessContributionIsNotObservedMean`]. +pub fn refuse_extra_process_contribution_as_observed_mean( + extra_process_contribution: f64, + extra_process_observed_mean: f64, +) -> Result { + let _ = (extra_process_contribution, extra_process_observed_mean); + Err(PsychometricError::ExtraProcessContributionIsNotObservedMean) +} + +/// Refuse treating the evolved-plus-contribution latent mean as +/// `E(y_t)`. +/// +/// Equation 5 maps `E(y_t) = τ + λ` of that mean. The latent mean is +/// not the observed mean. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::ExtraProcessLatentMeanIsNotObservedMean`]. +pub fn refuse_extra_process_latent_mean_as_observed_mean( + extra_process_latent_mean: f64, + extra_process_observed_mean: f64, +) -> Result { + let _ = (extra_process_latent_mean, extra_process_observed_mean); + Err(PsychometricError::ExtraProcessLatentMeanIsNotObservedMean) +} + +/// Exact scalar §7.2 extra-process contribution of a `TDPREDEFFECT` +/// impulse strictly after `t0`. +/// +/// Driver, Oud, and Voelkle (2017, §7.2, pp. 22–23; JSS PDF +/// re-opened 2026-08-21T06:32Z from +/// ) +/// name `T0TDPREDEFFECT` when the extra process begins at `t = 0` +/// and `TDPREDEFFECT` when it begins after `t = 0`. The printed +/// extra `TDPREDEFFECT` is 1. The original process is driven through +/// the `DRIFT` coupling, not through a Dirac on the original +/// process. After an identification impulse at `u` with +/// `t0 < u < t` the scalar contribution is +/// `a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a)`. Form the +/// interior interval `t − u` first, then the extra-process +/// contribution on that interval. An impulse at `u = t0` is the +/// first-occasion extra-process map. An impulse at `u = t` has not +/// yet driven the original process. This is not a Kalman filter, +/// not a matrix `expm`, and not ctsem estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::NonPositiveInterval`] when +/// `t − u` is not strictly interior to `(0, t − t0)`, and +/// otherwise propagates +/// [`recover_level_change_extra_process_contribution`]. +pub fn recover_level_change_extra_process_contribution_after( + original_from_extra_drift: f64, + time_dependent_predictor: f64, + original_log_rate: f64, + extra_log_rate: f64, + event_delta: f64, + elapsed_after_impulse: f64, + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + if !event_delta.is_finite() || event_delta <= 0.0 { + return Err(PsychometricError::NonPositiveInterval); + } + if !elapsed_after_impulse.is_finite() || elapsed_after_impulse <= 0.0 { + return Err(PsychometricError::NonPositiveInterval); + } + if elapsed_after_impulse >= event_delta { + return Err(PsychometricError::NonPositiveInterval); + } + recover_level_change_extra_process_contribution( + original_from_extra_drift, + time_dependent_predictor, + original_log_rate, + extra_log_rate, + elapsed_after_impulse, + clock, + ) +} + +/// Exact scalar evolved latent mean plus a §7.2 extra-process +/// contribution after `t0`. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 3, p. 5; §7.2, pp. 22–23; +/// JSS PDF re-opened 2026-08-21T06:32Z) evolve `T0MEANS` and `CINT` +/// over `Δt = t − t0`. `TDPREDEFFECT` on the extra process after +/// `t0` drives the original process only over `t − u` with +/// `t0 < u < t`. Form `μ_t` first, then add the after-t0 +/// extra-process contribution. A zero contribution is exactly +/// `μ_t`. The first-occasion extra-process map uses `Δt` for both +/// the evolution and the extra drive and is not this composition +/// when `u ≠ t0`. The impulse-carry `μ_t + e^{a(t−u)} m x` is a +/// Dirac on the original process and is not this composition. +/// +/// # Errors +/// +/// Propagates [`recover_discrete_latent_mean`] and +/// [`recover_level_change_extra_process_contribution_after`], and +/// returns [`PsychometricError::InvalidNumericInput`] when the sum +/// overflows. +#[allow(clippy::too_many_arguments)] +pub fn recover_discrete_latent_mean_with_extra_process_after( + initial_latent_mean: f64, + original_log_rate: f64, + continuous_intercept: f64, + original_from_extra_drift: f64, + time_dependent_predictor: f64, + extra_log_rate: f64, + event_delta: f64, + elapsed_after_impulse: f64, + clock: LagClock, +) -> Result { + let evolved_latent_mean = recover_discrete_latent_mean( + initial_latent_mean, + original_log_rate, + continuous_intercept, + event_delta, + clock, + )?; + let contribution = recover_level_change_extra_process_contribution_after( + original_from_extra_drift, + time_dependent_predictor, + original_log_rate, + extra_log_rate, + event_delta, + elapsed_after_impulse, + clock, + )?; + if contribution == 0.0 { + return Ok(evolved_latent_mean); + } + if evolved_latent_mean == 0.0 { + return Ok(contribution); + } + require_finite(evolved_latent_mean + contribution) +} + +/// Exact scalar observed mean of a §7.2 extra-process contribution +/// after `t0`. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; §7.2, pp. 22–23; +/// JSS PDF re-opened 2026-08-21T06:32Z) write +/// `y_i(t) = Γ + Λ η_i(t) + ζ_i(t)` with `ζ ~ N(0, Θ)` and +/// `Γ ~ N(τ, Ψ)`. The printed extra process has `LAMBDA` 0. +/// Original indicators load on the original process after the +/// `DRIFT` coupling over `t − u` with `t0 < u < t`. The scalar +/// composition is +/// `E(y_t) = τ + λ(μ_t + a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a))`. +/// Form the evolved-plus-after-contribution latent mean first, then +/// `τ + λ` of that mean. The first-occasion extra-process observed +/// mean uses `Δt` for both the evolution and the extra drive and is +/// not this composition when `u ≠ t0`. The evolved observed mean +/// `τ + λ μ_t` is not this composition. The impulse-carry map +/// `τ + λ(μ_t + e^{a(t−u)} m x)` is not this composition. The +/// extra process itself is not an observed indicator. This is not a +/// Kalman filter and not ctsem estimation. +/// +/// # Errors +/// +/// Propagates [`recover_discrete_latent_mean_with_extra_process_after`] +/// and [`recover_manifest_observed_mean`]. +#[allow(clippy::too_many_arguments)] +pub fn recover_discrete_observed_mean_with_extra_process_after( + loading: f64, + initial_latent_mean: f64, + original_log_rate: f64, + continuous_intercept: f64, + original_from_extra_drift: f64, + time_dependent_predictor: f64, + extra_log_rate: f64, + manifest_mean: f64, + event_delta: f64, + elapsed_after_impulse: f64, + clock: LagClock, +) -> Result { + let extra_latent_mean = recover_discrete_latent_mean_with_extra_process_after( + initial_latent_mean, + original_log_rate, + continuous_intercept, + original_from_extra_drift, + time_dependent_predictor, + extra_log_rate, + event_delta, + elapsed_after_impulse, + clock, + )?; + recover_manifest_observed_mean(loading, extra_latent_mean, manifest_mean) +} + +/// Refuse treating the first-occasion extra-process observed mean +/// as the after-t0 extra-process observed mean. +/// +/// `T0TDPREDEFFECT` on the extra process uses `Δt = t − t0`. +/// `TDPREDEFFECT` after `t0` uses `t − u` with `t0 < u < t`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::ExtraProcessObservedMeanIsNotAfterExtraProcessObservedMean`]. +pub fn refuse_extra_process_observed_mean_as_after_extra_process_observed_mean( + extra_process_observed_mean: f64, + after_extra_process_observed_mean: f64, +) -> Result { + let _ = ( + extra_process_observed_mean, + after_extra_process_observed_mean, + ); + Err(PsychometricError::ExtraProcessObservedMeanIsNotAfterExtraProcessObservedMean) +} + +/// Refuse treating the evolved observed mean as the after-t0 +/// extra-process observed mean. +/// +/// Equation 5 of the Eq. 3 evolved mean is `τ + λ μ_t`. Equation 5 +/// of the after-t0 extra-process contribution is +/// `τ + λ(μ_t + a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a))`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::EvolvedObservedMeanIsNotAfterExtraProcessObservedMean`]. +pub fn refuse_evolved_observed_mean_as_after_extra_process_observed_mean( + evolved_observed_mean: f64, + after_extra_process_observed_mean: f64, +) -> Result { + let _ = (evolved_observed_mean, after_extra_process_observed_mean); + Err(PsychometricError::EvolvedObservedMeanIsNotAfterExtraProcessObservedMean) +} + +/// Refuse treating the impulse-carry observed mean as the after-t0 +/// extra-process observed mean. +/// +/// `e^{a(t−u)} m x` is a Dirac on the original process. Extra-process +/// `TDPREDEFFECT` after `t0` drives the original process through +/// `DRIFT`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::ImpulseCarryObservedMeanIsNotAfterExtraProcessObservedMean`]. +pub fn refuse_impulse_carry_observed_mean_as_after_extra_process_observed_mean( + impulse_carry_observed_mean: f64, + after_extra_process_observed_mean: f64, +) -> Result { + let _ = ( + impulse_carry_observed_mean, + after_extra_process_observed_mean, + ); + Err(PsychometricError::ImpulseCarryObservedMeanIsNotAfterExtraProcessObservedMean) +} + +/// Refuse treating the after-t0 extra-process contribution as +/// `E(y_t)`. +/// +/// The contribution is not `τ + λ` of the +/// evolved-plus-after-contribution latent mean. The extra process +/// has `LAMBDA` 0 in the printed specification. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::AfterExtraProcessContributionIsNotObservedMean`]. +pub fn refuse_after_extra_process_contribution_as_observed_mean( + after_extra_process_contribution: f64, + after_extra_process_observed_mean: f64, +) -> Result { + let _ = ( + after_extra_process_contribution, + after_extra_process_observed_mean, + ); + Err(PsychometricError::AfterExtraProcessContributionIsNotObservedMean) +} + +/// Refuse treating the evolved-plus-after-contribution latent mean +/// as `E(y_t)`. +/// +/// Equation 5 maps `E(y_t) = τ + λ` of that mean. The latent mean +/// is not the observed mean. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::AfterExtraProcessLatentMeanIsNotObservedMean`]. +pub fn refuse_after_extra_process_latent_mean_as_observed_mean( + after_extra_process_latent_mean: f64, + after_extra_process_observed_mean: f64, +) -> Result { + let _ = ( + after_extra_process_latent_mean, + after_extra_process_observed_mean, + ); + Err(PsychometricError::AfterExtraProcessLatentMeanIsNotObservedMean) +} + +/// Exact scalar evolved latent mean plus a contemporaneous impulse. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 3, p. 5) write the first two +/// summands as the carried `T0MEANS` and `CINT` increment, then add +/// the fourth-summand impulse at the observation instant. Form `μ_t` +/// first, then add `m x`. A zero impulse is exactly `μ_t`. A zero +/// evolved mean is exactly the impulse. The first-occasion map +/// `μ_0 + m x` is not this composition when the process has already +/// evolved. The level-change form is not this map. +/// +/// # Errors +/// +/// Propagates [`recover_discrete_latent_mean`] and +/// [`recover_time_dependent_predictor_impulse`], and returns +/// [`PsychometricError::InvalidNumericInput`] when the sum overflows. +pub fn recover_discrete_latent_mean_with_impulse( + initial_latent_mean: f64, + log_rate: f64, + continuous_intercept: f64, + time_dependent_effect: f64, + time_dependent_predictor: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + let evolved_latent_mean = recover_discrete_latent_mean( + initial_latent_mean, + log_rate, + continuous_intercept, + event_delta, + clock, + )?; + let impulse = + recover_time_dependent_predictor_impulse(time_dependent_effect, time_dependent_predictor)?; + if impulse == 0.0 { + return Ok(evolved_latent_mean); + } + if evolved_latent_mean == 0.0 { + return Ok(impulse); + } + require_finite(evolved_latent_mean + impulse) +} + +/// Exact scalar observed mean of a contemporaneous impulse. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; Eq. 1–3, pp. 4–5; +/// Table 2, p. 12; §7.2, pp. 20–21; JSS PDF re-opened 2026-08-20T09:01Z +/// from +/// ) +/// write `y_i(t) = Γ + Λ η_i(t) + ζ_i(t)` with `ζ ~ N(0, Θ)` and +/// `Γ ~ N(τ, Ψ)`. The expected intercept is `τ`. The latent process +/// at `t` after a contemporaneous Dirac (`u = t`) is `μ_t + m x`. +/// The scalar composition is `E(y_t) = τ + λ(μ_t + m x)`. Form the +/// evolved-plus-impulse latent mean first, then `τ + λ` of that +/// mean. Table 2 names `τ` `MANIFESTMEANS`. A zero loading is +/// exactly `τ`. A zero evolved-plus-impulse latent mean is exactly +/// `τ`. A zero intercept is exactly `λ(μ_t + m x)`. The evolved +/// observed mean `τ + λ μ_t` is not this composition when the +/// impulse is nonzero. The carry map +/// `τ + λ(μ_t + e^{a(t−u)} m x)` is not this composition when +/// `u ≠ t`. `MANIFESTMEANS` is not `E(y_t)`. The +/// evolved-plus-impulse latent mean is not `E(y_t)`. The §7.2 +/// level-change form is a different specification and is not this +/// map. This is not a Kalman filter and not ctsem estimation. +/// +/// # Errors +/// +/// Propagates [`recover_discrete_latent_mean_with_impulse`] and +/// [`recover_manifest_observed_mean`]. +#[allow(clippy::too_many_arguments)] +pub fn recover_discrete_observed_mean_with_impulse( + loading: f64, + initial_latent_mean: f64, + log_rate: f64, + continuous_intercept: f64, + time_dependent_effect: f64, + time_dependent_predictor: f64, + manifest_mean: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + let impulse_latent_mean = recover_discrete_latent_mean_with_impulse( + initial_latent_mean, + log_rate, + continuous_intercept, + time_dependent_effect, + time_dependent_predictor, + event_delta, + clock, + )?; + recover_manifest_observed_mean(loading, impulse_latent_mean, manifest_mean) +} + +/// Refuse treating the evolved observed mean as the contemporaneous- +/// impulse observed mean. +/// +/// Equation 5 of the Eq. 3 evolved mean is `τ + λ μ_t`. Equation 5 +/// of the Eq. 3 contemporaneous impulse is `τ + λ(μ_t + m x)`. +/// Those are not the same map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::EvolvedObservedMeanIsNotImpulseObservedMean`]. +pub fn refuse_evolved_observed_mean_as_impulse_observed_mean( + evolved_observed_mean: f64, + impulse_observed_mean: f64, +) -> Result { + let _ = (evolved_observed_mean, impulse_observed_mean); + Err(PsychometricError::EvolvedObservedMeanIsNotImpulseObservedMean) +} + +/// Refuse treating the contemporaneous-impulse observed mean as the +/// impulse-carry observed mean. +/// +/// Equation 5 of the contemporaneous Dirac is `τ + λ(μ_t + m x)`. +/// Equation 5 of the Eq. 1–2 carried latent mean is +/// `τ + λ(μ_t + e^{a(t−u)} m x)`. Those are not the same map when +/// `u ≠ t`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::ImpulseObservedMeanIsNotImpulseCarryObservedMean`]. +pub fn refuse_impulse_observed_mean_as_impulse_carry_observed_mean( + impulse_observed_mean: f64, + impulse_carry_observed_mean: f64, +) -> Result { + let _ = (impulse_observed_mean, impulse_carry_observed_mean); + Err(PsychometricError::ImpulseObservedMeanIsNotImpulseCarryObservedMean) +} + +/// Refuse treating the Eq. 3 impulse as `CINT`. +/// +/// Table 2 names `M` `TDPREDEFFECT` and `κ` `CINT`. The impulse is +/// `m x`. The continuous intercept is not that jump. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::TimeDependentImpulseIsNotContinuousIntercept`]. +pub fn refuse_time_dependent_impulse_as_continuous_intercept( + time_dependent_impulse: f64, + continuous_intercept: f64, +) -> Result { + let _ = (time_dependent_impulse, continuous_intercept); + Err(PsychometricError::TimeDependentImpulseIsNotContinuousIntercept) +} + +/// Refuse treating the Eq. 3 impulse as the time-independent effect. +/// +/// The third summand is `A^{-1}[e^{A Δt} − I] B z`. Table 2 names +/// `B` `TIPREDEFFECT`. The fourth-summand impulse is `M x`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::TimeDependentImpulseIsNotTimeIndependentEffect`]. +pub fn refuse_time_dependent_impulse_as_time_independent_effect( + time_dependent_impulse: f64, + time_independent_effect: f64, +) -> Result { + let _ = (time_dependent_impulse, time_independent_effect); + Err(PsychometricError::TimeDependentImpulseIsNotTimeIndependentEffect) +} + +/// Refuse treating the Eq. 3 impulse as Voelkle et al. (2012, Eq. 14). +/// +/// Equation 14 is `a_{yx} Δt` for a piecewise-constant time-varying +/// predictor whose sampling interval equals its constancy interval. +/// The Dirac impulse is `m x`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::TimeDependentImpulseIsNotTimeVaryingDiscreteEffect`]. +pub fn refuse_time_dependent_impulse_as_time_varying_discrete_effect( + time_dependent_impulse: f64, + time_varying_discrete_effect: f64, +) -> Result { + let _ = (time_dependent_impulse, time_varying_discrete_effect); + Err(PsychometricError::TimeDependentImpulseIsNotTimeVaryingDiscreteEffect) +} + +/// Exact scalar discrete time-independent predictor effect from +/// Driver Equation 3. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 1–3, pp. 4–5; Table 2, p. 12; +/// JSS PDF re-opened 2026-08-20T10:13Z from +/// ) +/// write the latent SDE +/// `dη = (A η + b + A_{ηξ} ξ + B z) dt + G dW + M dχ`. Equation 3's +/// second summand is `A^{-1}[e^{A Δt} − I](b + A_{ηξ} ξ + B z)`. +/// Table 2 names `B` `TIPREDEFFECT`, `κ`/`b` `CINT`, and `M` +/// `TDPREDEFFECT`. The scalar map of the time-independent predictor +/// is `(e^{a Δt} − 1)/a · B z` for `a ≠ 0`. Form `B z` first, then +/// the discrete intercept map. A zero drift is the Eq. 3 integral +/// `B z Δt`. A zero effect or zero predictor is exactly zero. +/// `TIPREDEFFECT` is `B`, not that discrete increment. `B z` is not +/// `CINT`. `A^{-1}[e^{A Δt} − I] B z` is not the contemporaneous +/// impulse `M x` and is not Voelkle et al. (2012, Eq. 14) `a_{yx} Δt`. +/// This is not a Kalman filter and not ctsem estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for any non-event +/// clock, [`PsychometricError::NonPositiveInterval`] when +/// `event_delta` is not strictly positive, and +/// [`PsychometricError::InvalidNumericInput`] when an input is +/// non-finite or `B z` or the mapped increment overflows. +pub fn recover_discrete_time_independent_predictor_effect( + time_independent_effect: f64, + time_independent_predictor: f64, + log_rate: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + if !event_delta.is_finite() || event_delta <= 0.0 { + return Err(PsychometricError::NonPositiveInterval); + } + if !time_independent_effect.is_finite() + || !time_independent_predictor.is_finite() + || !log_rate.is_finite() + { + return Err(PsychometricError::InvalidNumericInput); + } + if time_independent_effect == 0.0 || time_independent_predictor == 0.0 { + return Ok(0.0); + } + let continuous = require_finite(time_independent_effect * time_independent_predictor)?; + if log_rate == 0.0 { + return require_finite(continuous * event_delta); + } + recover_discrete_constant_predictor_effect(continuous, log_rate, event_delta, clock) +} + +/// Exact scalar evolved latent mean plus a time-independent predictor. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 3, p. 5) write the first two +/// summands as the carried `T0MEANS`, the `CINT` increment, and the +/// `TIPREDEFFECT` increment `A^{-1}[e^{A Δt} − I] B z`. Form `μ_t` +/// first, then add that increment. A zero time-independent increment +/// is exactly `μ_t`. A zero evolved mean is exactly the increment. +/// Adding `B z` to `μ_t` is not this map. Adding `M x` is not this +/// map. +/// +/// # Errors +/// +/// Propagates [`recover_discrete_latent_mean`] and +/// [`recover_discrete_time_independent_predictor_effect`], and +/// returns [`PsychometricError::InvalidNumericInput`] when the sum +/// overflows. +pub fn recover_discrete_latent_mean_with_time_independent_predictor( + initial_latent_mean: f64, + log_rate: f64, + continuous_intercept: f64, + time_independent_effect: f64, + time_independent_predictor: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + let evolved_latent_mean = recover_discrete_latent_mean( + initial_latent_mean, + log_rate, + continuous_intercept, + event_delta, + clock, + )?; + let time_independent_increment = recover_discrete_time_independent_predictor_effect( + time_independent_effect, + time_independent_predictor, + log_rate, + event_delta, + clock, + )?; + if time_independent_increment == 0.0 { + return Ok(evolved_latent_mean); + } + if evolved_latent_mean == 0.0 { + return Ok(time_independent_increment); + } + require_finite(evolved_latent_mean + time_independent_increment) +} + +/// Refuse treating the Eq. 3 time-independent increment as `CINT`. +/// +/// Table 2 names `B` `TIPREDEFFECT` and `κ` `CINT`. The discrete +/// increment is `A^{-1}[e^{A Δt} − I] B z`. The continuous intercept +/// is not that increment. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::TimeIndependentEffectIsNotContinuousIntercept`]. +pub fn refuse_time_independent_effect_as_continuous_intercept( + time_independent_increment: f64, + continuous_intercept: f64, +) -> Result { + let _ = (time_independent_increment, continuous_intercept); + Err(PsychometricError::TimeIndependentEffectIsNotContinuousIntercept) +} + +/// Refuse treating the Eq. 3 time-independent increment as `M x`. +/// +/// The fourth-summand impulse is contemporaneous. The second-summand +/// `TIPREDEFFECT` map integrates `B z` over the event interval. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::TimeIndependentEffectIsNotTimeDependentImpulse`]. +pub fn refuse_time_independent_effect_as_time_dependent_impulse( + time_independent_increment: f64, + time_dependent_impulse: f64, +) -> Result { + let _ = (time_independent_increment, time_dependent_impulse); + Err(PsychometricError::TimeIndependentEffectIsNotTimeDependentImpulse) +} + +/// Refuse treating the Eq. 3 time-independent increment as Voelkle +/// et al. (2012, Eq. 14). +/// +/// Equation 14 is `a_{yx} Δt` for a piecewise-constant time-varying +/// predictor whose sampling interval equals its constancy interval. +/// `TIPREDEFFECT` integrates a constant `z` through the drift. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::TimeIndependentEffectIsNotTimeVaryingDiscreteEffect`]. +pub fn refuse_time_independent_effect_as_time_varying_discrete_effect( + time_independent_increment: f64, + time_varying_discrete_effect: f64, +) -> Result { + let _ = (time_independent_increment, time_varying_discrete_effect); + Err(PsychometricError::TimeIndependentEffectIsNotTimeVaryingDiscreteEffect) +} + +/// Refuse treating Driver Table 2 `TIPREDEFFECT` as the discrete +/// increment. +/// +/// `B` is the continuous-time coefficient. Equation 3 maps +/// `A^{-1}[e^{A Δt} − I] B z`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::TimeIndependentCoefficientIsNotDiscreteEffect`]. +pub fn refuse_time_independent_coefficient_as_discrete_effect( + time_independent_coefficient: f64, + time_independent_increment: f64, +) -> Result { + let _ = (time_independent_coefficient, time_independent_increment); + Err(PsychometricError::TimeIndependentCoefficientIsNotDiscreteEffect) +} + +/// Exact scalar §7.2 `asymTIPREDEFFECT`. +/// +/// Driver, Oud, and Voelkle (2017, §7.2, pp. 20–21; Eq. 3, p. 5; +/// Table 2, p. 12; JSS PDF opened 2026-08-21T13:08Z from +/// ) +/// name `TIPREDEFFECT` the continuous-time coefficient `B`. Equation 3 +/// maps a finite event interval as `A^{-1}[e^{A Δt} − I] B z`. Section +/// 7.2 then names `asymTIPREDEFFECT` the expected total change in +/// process means given an increase of 1 on the time-independent +/// predictor. For stable `a < 0` that total change is `-A^{-1} B`. +/// The scalar map is `-B z / a`. Form `B z` first, then divide by +/// `-a`. A zero coefficient or zero predictor is exactly zero. +/// `a ≥ 0` cannot hold a finite process-mean change and fails closed. +/// `-B z / a` is not the coefficient `B`, not the finite-interval +/// increment `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, and not `M x`. +/// This is not a Kalman filter, not a matrix `expm`, and not ctsem +/// estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for any non-event +/// clock, [`PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift`] +/// when the drift is not strictly negative and the effect is nonzero, +/// and [`PsychometricError::InvalidNumericInput`] when an input is +/// non-finite or `B z` or the quotient overflows. +pub fn recover_asymptotic_time_independent_predictor_effect( + time_independent_effect: f64, + time_independent_predictor: f64, + log_rate: f64, + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + if !time_independent_effect.is_finite() + || !time_independent_predictor.is_finite() + || !log_rate.is_finite() + { + return Err(PsychometricError::InvalidNumericInput); + } + if time_independent_effect == 0.0 || time_independent_predictor == 0.0 { + return Ok(0.0); + } + if log_rate >= 0.0 { + return Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift); + } + let continuous = require_finite(time_independent_effect * time_independent_predictor)?; + require_finite(continuous / -log_rate) +} + +/// Refuse treating §7.2 `asymTIPREDEFFECT` as `TIPREDEFFECT`. +/// +/// `-B z / a` is the expected total change in process means. Table 2 +/// names `B` `TIPREDEFFECT`. The coefficient is not that total change. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::AsymptoticTimeIndependentEffectIsNotCoefficient`]. +pub fn refuse_asymptotic_time_independent_effect_as_coefficient( + asymptotic_effect: f64, + time_independent_coefficient: f64, +) -> Result { + let _ = (asymptotic_effect, time_independent_coefficient); + Err(PsychometricError::AsymptoticTimeIndependentEffectIsNotCoefficient) +} + +/// Refuse treating §7.2 `asymTIPREDEFFECT` as the finite-interval +/// discrete increment. +/// +/// `-B z / a` is the `Δt → ∞` limit of `A^{-1}[e^{A Δt} − I] B z` +/// under stable `a < 0`. A finite event interval is not that limit. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::AsymptoticTimeIndependentEffectIsNotDiscreteEffect`]. +pub fn refuse_asymptotic_time_independent_effect_as_discrete_effect( + asymptotic_effect: f64, + time_independent_increment: f64, +) -> Result { + let _ = (asymptotic_effect, time_independent_increment); + Err(PsychometricError::AsymptoticTimeIndependentEffectIsNotDiscreteEffect) +} + +/// Refuse treating §7.2 `asymTIPREDEFFECT` as `CINT`. +/// +/// `-B z / a` is the expected total change from a time-independent +/// predictor. Table 2 names `κ` `CINT`. Those are not the same map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::AsymptoticTimeIndependentEffectIsNotContinuousIntercept`]. +pub fn refuse_asymptotic_time_independent_effect_as_continuous_intercept( + asymptotic_effect: f64, + continuous_intercept: f64, +) -> Result { + let _ = (asymptotic_effect, continuous_intercept); + Err(PsychometricError::AsymptoticTimeIndependentEffectIsNotContinuousIntercept) +} + +/// Refuse treating §7.2 `asymTIPREDEFFECT` as `M x`. +/// +/// The fourth-summand impulse is contemporaneous. The asymptotic +/// time-independent effect is a new process mean, not a Dirac. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::AsymptoticTimeIndependentEffectIsNotTimeDependentImpulse`]. +pub fn refuse_asymptotic_time_independent_effect_as_time_dependent_impulse( + asymptotic_effect: f64, + time_dependent_impulse: f64, +) -> Result { + let _ = (asymptotic_effect, time_dependent_impulse); + Err(PsychometricError::AsymptoticTimeIndependentEffectIsNotTimeDependentImpulse) +} + +/// Exact scalar §7.2 `addedTIPREDVAR`. +/// +/// Driver, Oud, and Voelkle (2017, §7.2, pp. 20–21; Eq. 3, p. 5; +/// Table 2, p. 12; JSS PDF opened 2026-08-21T13:08Z from +/// ) +/// name `asymTIPREDEFFECT` the expected total change in process means +/// given a unit increase on a time-independent predictor. The scalar +/// map is `-B / a` for stable `a < 0`. Section 7.2 then names +/// `addedTIPREDVAR` the stable between-subject variance accounted for +/// by those predictors. For predictor variance `v ≥ 0` that variance +/// is `(-B / a)² v`. Form the unit asymptotic effect first, then +/// square, then multiply by `v`. A zero coefficient or zero predictor +/// variance is exactly zero. `v < 0` fails closed. `a ≥ 0` cannot hold +/// a finite process-mean change and fails closed. `(B / a)² v` is not +/// `TRAITVAR`, not `asymDIFFUSION`, and not the expected total change +/// `-B z / a`. This is not a Kalman filter, not a matrix `expm`, and +/// not ctsem estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for any non-event +/// clock, [`PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift`] +/// when the drift is not strictly negative and the variance is nonzero, +/// and [`PsychometricError::InvalidNumericInput`] when an input is +/// non-finite, the predictor variance is negative, or the product +/// overflows. +pub fn recover_asymptotic_time_independent_predictor_variance( + time_independent_effect: f64, + predictor_variance: f64, + log_rate: f64, + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + if !time_independent_effect.is_finite() + || !predictor_variance.is_finite() + || !log_rate.is_finite() + || predictor_variance < 0.0 + { + return Err(PsychometricError::InvalidNumericInput); + } + if time_independent_effect == 0.0 || predictor_variance == 0.0 { + return Ok(0.0); + } + let unit_effect = recover_asymptotic_time_independent_predictor_effect( + time_independent_effect, + 1.0, + log_rate, + clock, + )?; + let squared = require_finite(unit_effect * unit_effect)?; + require_finite(squared * predictor_variance) +} + +/// Refuse treating §7.2 `addedTIPREDVAR` as `TRAITVAR`. +/// +/// `(B / a)² v` is between-subject variance accounted for by a +/// time-independent predictor. Section 4.3 `TRAITVAR` is a zero-drift +/// latent process. Those are not the same map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::AsymptoticTimeIndependentVarianceIsNotTraitVariance`]. +pub fn refuse_asymptotic_time_independent_variance_as_trait_variance( + added_predictor_variance: f64, + trait_variance: f64, +) -> Result { + let _ = (added_predictor_variance, trait_variance); + Err(PsychometricError::AsymptoticTimeIndependentVarianceIsNotTraitVariance) +} + +/// Refuse treating §7.2 `addedTIPREDVAR` as `asymDIFFUSION`. +/// +/// `(B / a)² v` is between-subject variance from a time-independent +/// predictor. `asymDIFFUSION` is the stationary within-subject +/// variance `-q / (2 a)`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::AsymptoticTimeIndependentVarianceIsNotStationaryWithinSubject`]. +pub fn refuse_asymptotic_time_independent_variance_as_stationary_within_subject( + added_predictor_variance: f64, + stationary_variance: f64, +) -> Result { + let _ = (added_predictor_variance, stationary_variance); + Err(PsychometricError::AsymptoticTimeIndependentVarianceIsNotStationaryWithinSubject) +} + +/// Refuse treating §7.2 `addedTIPREDVAR` as `asymTIPREDEFFECT`. +/// +/// `(B / a)² v` is a variance. `-B z / a` is the expected total +/// change in process means. Those are not the same map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::AsymptoticTimeIndependentVarianceIsNotAsymptoticEffect`]. +pub fn refuse_asymptotic_time_independent_variance_as_asymptotic_effect( + added_predictor_variance: f64, + asymptotic_effect: f64, +) -> Result { + let _ = (added_predictor_variance, asymptotic_effect); + Err(PsychometricError::AsymptoticTimeIndependentVarianceIsNotAsymptoticEffect) +} + +/// Exact scalar Table 2 `asymCINT`. +/// +/// Driver, Oud, and Voelkle (2017, Table 2, p. 12; Eq. 3, p. 5; +/// §4.3 / p. 16; JSS PDF opened 2026-08-21T16:13Z from +/// ) +/// name `asymCINT` the asymptotic (`Δt = ∞`) expected change in +/// processes for a 1 unit change in intercept (`CINT`). Table 2 names +/// `κ` `CINT`. Equation 3 maps a finite event interval as +/// `A^{-1}[e^{A Δt} − I] κ`. For stable `a < 0` that `Δt → ∞` limit +/// is `-A^{-1} κ`. The scalar map is `-κ / a`. A unit intercept is +/// `-1 / a`. Form `κ` first, then divide by `-a`. A zero intercept is +/// exactly zero. `a ≥ 0` cannot hold a finite process-mean change and +/// fails closed. `-κ / a` is not `κ`, not the finite-interval +/// increment `A^{-1}[e^{A Δt} − I] κ`, not `T0MEANS`, and not +/// `asymTIPREDEFFECT` `-B z / a`. Page 16 notes that a `T0MEANS` +/// stationarity constraint includes time-independent predictors; that +/// composition is not this intercept-only map. The printed 2-latent +/// `CINT` values are not this scalar map. This is not a Kalman filter, +/// not a matrix `expm`, and not ctsem estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for any non-event +/// clock, [`PsychometricError::AsymptoticContinuousInterceptRequiresStableDrift`] +/// when the drift is not strictly negative and the intercept is +/// nonzero, and [`PsychometricError::InvalidNumericInput`] when an +/// input is non-finite or the quotient overflows. +pub fn recover_asymptotic_continuous_intercept( + continuous_intercept: f64, + log_rate: f64, + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + if !continuous_intercept.is_finite() || !log_rate.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + if continuous_intercept == 0.0 { + return Ok(0.0); + } + if log_rate >= 0.0 { + return Err(PsychometricError::AsymptoticContinuousInterceptRequiresStableDrift); + } + require_finite(continuous_intercept / -log_rate) +} + +/// Refuse treating Table 2 `asymCINT` as `CINT`. +/// +/// `-κ / a` is the expected change in process means. Table 2 names +/// `κ` `CINT`. The intercept is not that total change. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::AsymptoticContinuousInterceptIsNotContinuousIntercept`]. +pub fn refuse_asymptotic_continuous_intercept_as_continuous_intercept( + asymptotic_intercept: f64, + continuous_intercept: f64, +) -> Result { + let _ = (asymptotic_intercept, continuous_intercept); + Err(PsychometricError::AsymptoticContinuousInterceptIsNotContinuousIntercept) +} + +/// Refuse treating Table 2 `asymCINT` as the finite-interval discrete +/// intercept increment. +/// +/// `-κ / a` is the `Δt → ∞` limit of `A^{-1}[e^{A Δt} − I] κ` under +/// stable `a < 0`. A finite event interval is not that limit. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::AsymptoticContinuousInterceptIsNotDiscreteIncrement`]. +pub fn refuse_asymptotic_continuous_intercept_as_discrete_increment( + asymptotic_intercept: f64, + discrete_increment: f64, +) -> Result { + let _ = (asymptotic_intercept, discrete_increment); + Err(PsychometricError::AsymptoticContinuousInterceptIsNotDiscreteIncrement) +} + +/// Refuse treating Table 2 `asymCINT` as `T0MEANS`. +/// +/// `-κ / a` is the intercept contribution to the stationary process +/// mean. Table 2 names `μ_0` `T0MEANS`. Those are not the same map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::AsymptoticContinuousInterceptIsNotInitialLatentMean`]. +pub fn refuse_asymptotic_continuous_intercept_as_initial_latent_mean( + asymptotic_intercept: f64, + initial_latent_mean: f64, +) -> Result { + let _ = (asymptotic_intercept, initial_latent_mean); + Err(PsychometricError::AsymptoticContinuousInterceptIsNotInitialLatentMean) +} + +/// Refuse treating Table 2 `asymCINT` as `asymTIPREDEFFECT`. +/// +/// `-κ / a` is the intercept contribution. `-B z / a` is the +/// time-independent predictor contribution. Page 16 notes that a +/// `T0MEANS` stationarity constraint includes time-independent +/// predictors; that composition is not this intercept-only map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::AsymptoticContinuousInterceptIsNotAsymptoticTimeIndependentEffect`]. +pub fn refuse_asymptotic_continuous_intercept_as_asymptotic_time_independent_effect( + asymptotic_intercept: f64, + asymptotic_time_independent_effect: f64, +) -> Result { + let _ = (asymptotic_intercept, asymptotic_time_independent_effect); + Err(PsychometricError::AsymptoticContinuousInterceptIsNotAsymptoticTimeIndependentEffect) +} + +/// Exact scalar p. 16 stationary `T0MEANS`. +/// +/// Driver, Oud, and Voelkle (2017, p. 16; Table 2, p. 12; Eq. 3, p. 5; +/// JSS PDF opened 2026-08-21T16:13Z from +/// ) +/// constrain `T0MEANS` to the model-implied values using +/// `T0MEANSbase` / `T0MEANSfree` when the first observation is +/// determined by the process in the same way as later observations. +/// Those constraints include extra effects due to time-independent +/// predictors (`asymTIPREDEFFECT`). Table 2 names `κ` `CINT` and +/// names `asymCINT` the `Δt → ∞` intercept contribution `-κ / a`. +/// For stable `a < 0` the scalar composition is +/// `-κ / a + −B z / a`. Form the intercept contribution first, then +/// include the TI extra effect, then add. A zero intercept and a zero +/// TI contribution is exactly zero. `a ≥ 0` cannot hold a finite +/// process-mean change when either contribution is nonzero and fails +/// closed. That constrained first-occasion mean is not free +/// `T0MEANS`, not `asymCINT` alone, not `asymTIPREDEFFECT` alone, and +/// not the finite-interval discrete latent mean +/// `exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ`. The printed 2-latent +/// `T0MEANS` 2.823 is not this scalar map. This is not a Kalman +/// filter, not a matrix `expm`, and not ctsem estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for any non-event +/// clock, [`PsychometricError::AsymptoticContinuousInterceptRequiresStableDrift`] +/// or [`PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift`] +/// when the drift is not strictly negative and the corresponding +/// contribution is nonzero, and [`PsychometricError::InvalidNumericInput`] +/// when an input is non-finite or a quotient or sum overflows. +pub fn recover_stationary_initial_latent_mean( + continuous_intercept: f64, + time_independent_effect: f64, + time_independent_predictor: f64, + log_rate: f64, + clock: LagClock, +) -> Result { + let intercept = recover_asymptotic_continuous_intercept(continuous_intercept, log_rate, clock)?; + let tipred = recover_asymptotic_time_independent_predictor_effect( + time_independent_effect, + time_independent_predictor, + log_rate, + clock, + )?; + require_finite(intercept + tipred) +} + +/// Refuse treating p. 16 stationary `T0MEANS` as free `T0MEANS`. +/// +/// `-κ / a + −B z / a` is the constrained first-occasion mean. Table 2 +/// names the free first-occasion latent mean `T0MEANS`. Those are not +/// the same map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryInitialLatentMeanIsNotInitialLatentMean`]. +pub fn refuse_stationary_initial_latent_mean_as_initial_latent_mean( + stationary_mean: f64, + initial_latent_mean: f64, +) -> Result { + let _ = (stationary_mean, initial_latent_mean); + Err(PsychometricError::StationaryInitialLatentMeanIsNotInitialLatentMean) +} + +/// Refuse treating p. 16 stationary `T0MEANS` as `asymCINT`. +/// +/// The constraint includes time-independent predictors. `-κ / a` is +/// the intercept contribution and is not that composition when +/// `B z ≠ 0`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryInitialLatentMeanIsNotAsymptoticContinuousIntercept`]. +pub fn refuse_stationary_initial_latent_mean_as_asymptotic_continuous_intercept( + stationary_mean: f64, + asymptotic_intercept: f64, +) -> Result { + let _ = (stationary_mean, asymptotic_intercept); + Err(PsychometricError::StationaryInitialLatentMeanIsNotAsymptoticContinuousIntercept) +} + +/// Refuse treating p. 16 stationary `T0MEANS` as `asymTIPREDEFFECT`. +/// +/// The constraint includes the intercept contribution. `-B z / a` is +/// the TI extra effect and is not that composition when `κ ≠ 0`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryInitialLatentMeanIsNotAsymptoticTimeIndependentEffect`]. +pub fn refuse_stationary_initial_latent_mean_as_asymptotic_time_independent_effect( + stationary_mean: f64, + asymptotic_time_independent_effect: f64, +) -> Result { + let _ = (stationary_mean, asymptotic_time_independent_effect); + Err(PsychometricError::StationaryInitialLatentMeanIsNotAsymptoticTimeIndependentEffect) +} + +/// Refuse treating p. 16 stationary `T0MEANS` as a finite-interval +/// discrete latent mean. +/// +/// `-κ / a + −B z / a` is the `Δt → ∞` constrained first-occasion +/// mean. `exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ` is a finite event +/// interval and is not that limit. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryInitialLatentMeanIsNotDiscreteMean`]. +pub fn refuse_stationary_initial_latent_mean_as_discrete_mean( + stationary_mean: f64, + discrete_mean: f64, +) -> Result { + let _ = (stationary_mean, discrete_mean); + Err(PsychometricError::StationaryInitialLatentMeanIsNotDiscreteMean) +} + +/// Exact scalar observed mean of §4.3 stationary `T0MEANS`. +/// +/// Driver, Oud, and Voelkle (2017, §4.3, pp. 9–10; Eq. 5, p. 5; +/// Table 2, p. 12; Eq. 3, p. 5; JSS PDF re-opened 2026-08-21T20:07Z +/// from +/// ) +/// constrain the first-occasion mean to the model-predicted mean +/// when `stationary` includes `"T0MEANS"`. Equation 5 writes +/// `y_i(t) = Γ + Λ η_i(t) + ζ_i(t)` with `ζ ~ N(0, Θ)` and +/// `Γ ~ N(τ, Ψ)`. The constrained latent mean is +/// `-κ / a + −B z / a`. The scalar composition is +/// `E(y_0) = τ + λ(−κ / a + −B z / a)`. Form the stationary latent +/// mean first, then `τ + λ` of that mean. A zero loading is exactly +/// `τ`. A zero intercept and a zero TI contribution is exactly `τ`. +/// `τ + λ μ_0` for free `T0MEANS` is not this composition. +/// `τ + λ(−κ / a)` is not this composition when `B z ≠ 0`. +/// `τ + λ μ_t` is not this composition. `MANIFESTMEANS` is not +/// `E(y_0)`. The constrained latent mean is not `E(y_0)`. This is +/// not a Kalman filter, not a matrix `expm`, and not ctsem +/// estimation. +/// +/// # Errors +/// +/// Propagates [`recover_stationary_initial_latent_mean`] and +/// [`recover_manifest_observed_mean`]. +#[allow(clippy::too_many_arguments)] +pub fn recover_stationary_initial_observed_mean( + loading: f64, + continuous_intercept: f64, + time_independent_effect: f64, + time_independent_predictor: f64, + log_rate: f64, + manifest_mean: f64, + clock: LagClock, +) -> Result { + let stationary_latent_mean = recover_stationary_initial_latent_mean( + continuous_intercept, + time_independent_effect, + time_independent_predictor, + log_rate, + clock, + )?; + recover_manifest_observed_mean(loading, stationary_latent_mean, manifest_mean) +} + +/// Refuse treating §4.3 stationary `T0MEANS` as `E(y_0)`. +/// +/// `−κ / a + −B z / a` is the constrained latent mean. Equation 5 +/// maps `E(y_0) = τ + λ` of that mean. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryInitialLatentMeanIsNotObservedMean`]. +pub fn refuse_stationary_initial_latent_mean_as_observed_mean( + stationary_latent_mean: f64, + stationary_observed_mean: f64, +) -> Result { + let _ = (stationary_latent_mean, stationary_observed_mean); + Err(PsychometricError::StationaryInitialLatentMeanIsNotObservedMean) +} + +/// Refuse treating `MANIFESTMEANS` as Eq. 5 of §4.3 stationary +/// `T0MEANS`. +/// +/// Table 2 names `τ` `MANIFESTMEANS`. `τ + λ(−κ / a + −B z / a)` is +/// not `τ` when the loading and constrained mean are nonzero. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryInitialObservedMeanIsNotManifestMeans`]. +pub fn refuse_stationary_initial_observed_mean_as_manifest_means( + stationary_observed_mean: f64, + manifest_mean: f64, +) -> Result { + let _ = (stationary_observed_mean, manifest_mean); + Err(PsychometricError::StationaryInitialObservedMeanIsNotManifestMeans) +} + +/// Refuse treating evolved `τ + λ μ_t` as Eq. 5 of §4.3 stationary +/// `T0MEANS`. +/// +/// A finite-interval evolved observed mean is not the constrained +/// first-occasion observed mean. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::EvolvedObservedMeanIsNotStationaryInitialObservedMean`]. +pub fn refuse_evolved_observed_mean_as_stationary_initial_observed_mean( + evolved_observed_mean: f64, + stationary_observed_mean: f64, +) -> Result { + let _ = (evolved_observed_mean, stationary_observed_mean); + Err(PsychometricError::EvolvedObservedMeanIsNotStationaryInitialObservedMean) +} + +/// Refuse treating `τ + λ(−κ / a)` as Eq. 5 of §4.3 stationary +/// `T0MEANS`. +/// +/// The constraint includes time-independent predictors. +/// `τ + λ(−κ / a)` is not that composition when `B z ≠ 0`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::AsymptoticContinuousInterceptObservedMeanIsNotStationaryInitialObservedMean`]. +pub fn refuse_asymptotic_continuous_intercept_observed_mean_as_stationary_initial_observed_mean( + asymptotic_intercept_observed_mean: f64, + stationary_observed_mean: f64, +) -> Result { + let _ = (asymptotic_intercept_observed_mean, stationary_observed_mean); + Err( + PsychometricError::AsymptoticContinuousInterceptObservedMeanIsNotStationaryInitialObservedMean, + ) +} + +/// Refuse treating `τ + λ μ_0` as Eq. 5 of §4.3 stationary +/// `T0MEANS`. +/// +/// Free first-occasion `T0MEANS` is not the constrained +/// first-occasion mean. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::InitialObservedMeanIsNotStationaryInitialObservedMean`]. +pub fn refuse_initial_observed_mean_as_stationary_initial_observed_mean( + initial_observed_mean: f64, + stationary_observed_mean: f64, +) -> Result { + let _ = (initial_observed_mean, stationary_observed_mean); + Err(PsychometricError::InitialObservedMeanIsNotStationaryInitialObservedMean) +} + +/// Exact scalar §4.3 / p. 16 stationary `T0VAR`. +/// +/// Driver, Oud, and Voelkle (2017, §4.3, pp. 9–10; p. 16; Table 2, +/// p. 12; §7.2, pp. 20–21; Eq. 4, p. 5; JSS PDF re-opened +/// 2026-08-22T03:07Z from +/// ) +/// constrain `T0VAR` to the model-predicted variance when +/// `stationary` includes `"T0VAR"`. Section 4.3 writes that the +/// first-occasion variances are constrained according to the model +/// predicted variances across all time points. Page 16 names +/// `asymDIFFUSION` the total within-subject variance as `Δt → ∞`. +/// For stable `a < 0` that scalar is `-q / (2 a)`. Section 4.3 +/// (p. 9) adds a stable trait process with `DRIFT` and `DIFFUSION` +/// fixed to zero (`TRAITVAR`). Section 7.2 names `addedTIPREDVAR` +/// the stable between-subject variance accounted for by +/// time-independent predictors; the scalar map is `(B / a)² v`. +/// The constrained first-occasion variance is +/// `trait + −q / (2 a) + (B / a)² v`. Form the within-subject +/// contribution first, then include the trait, then include the TI +/// extra variance, then add. A zero trait, a zero diffusion, and a +/// zero TI contribution is exactly zero. A zero diffusion and a +/// zero TI contribution is exactly the trait. `a ≥ 0` cannot hold a +/// finite process variance when the diffusion or the TI +/// contribution is nonzero and fails closed. Trait-only variance +/// does not require a stable drift. That constrained +/// first-occasion variance is not free `T0VAR`, not +/// `asymDIFFUSION` alone, not `TRAITVAR` alone, not +/// `addedTIPREDVAR` alone, and not the finite-interval discrete +/// latent variance `exp(2 a Δt) p + Q_Δt`. The printed 2-latent +/// `addedTIPREDVAR` 2.838 is not this scalar map. This is not a +/// Kalman filter, not a matrix `expm`, and not ctsem estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for any +/// non-event clock, +/// [`PsychometricError::StationaryVarianceRequiresStableDrift`] +/// when the diffusion is nonzero and the drift is not strictly +/// negative, +/// [`PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift`] +/// when the TI contribution is nonzero and the drift is not +/// strictly negative, and +/// [`PsychometricError::InvalidNumericInput`] when an input is +/// non-finite, a variance is negative, or a product or sum +/// overflows. +pub fn recover_stationary_initial_latent_variance( + trait_variance: f64, + continuous_diffusion: f64, + time_independent_effect: f64, + predictor_variance: f64, + log_rate: f64, + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + let state = if continuous_diffusion == 0.0 { + 0.0 + } else { + recover_stationary_latent_variance(continuous_diffusion, log_rate, clock)? + }; + let trait_plus_state = recover_trait_plus_state_latent_variance(trait_variance, state)?; + let added = recover_asymptotic_time_independent_predictor_variance( + time_independent_effect, + predictor_variance, + log_rate, + clock, + )?; + require_finite(trait_plus_state + added) +} + +/// Refuse treating §4.3 / p. 16 stationary `T0VAR` as free `T0VAR`. +/// +/// `trait + −q / (2 a) + (B / a)² v` is the constrained +/// first-occasion variance. Table 2 names the free first-occasion +/// latent variance `T0VAR`. Those are not the same map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryInitialLatentVarianceIsNotInitialLatentVariance`]. +pub fn refuse_stationary_initial_latent_variance_as_initial_latent_variance( + stationary_variance: f64, + initial_latent_variance: f64, +) -> Result { + let _ = (stationary_variance, initial_latent_variance); + Err(PsychometricError::StationaryInitialLatentVarianceIsNotInitialLatentVariance) +} + +/// Refuse treating §4.3 / p. 16 stationary `T0VAR` as +/// `asymDIFFUSION`. +/// +/// The constraint includes trait variance and time-independent +/// predictor variance. `-q / (2 a)` is the within-subject +/// contribution and is not that composition when `TRAITVAR` or +/// `addedTIPREDVAR` is nonzero. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryInitialLatentVarianceIsNotStationaryWithinSubject`]. +pub fn refuse_stationary_initial_latent_variance_as_stationary_within_subject( + stationary_t0_variance: f64, + asymptotic_within_subject: f64, +) -> Result { + let _ = (stationary_t0_variance, asymptotic_within_subject); + Err(PsychometricError::StationaryInitialLatentVarianceIsNotStationaryWithinSubject) +} + +/// Refuse treating §4.3 / p. 16 stationary `T0VAR` as `TRAITVAR`. +/// +/// The constraint includes the within-subject process variance and +/// time-independent predictor variance. `TRAITVAR` is not that +/// composition when those contributions are nonzero. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryInitialLatentVarianceIsNotTraitVariance`]. +pub fn refuse_stationary_initial_latent_variance_as_trait_variance( + stationary_t0_variance: f64, + trait_variance: f64, +) -> Result { + let _ = (stationary_t0_variance, trait_variance); + Err(PsychometricError::StationaryInitialLatentVarianceIsNotTraitVariance) +} + +/// Refuse treating §4.3 / p. 16 stationary `T0VAR` as +/// `addedTIPREDVAR`. +/// +/// The constraint includes trait variance and `asymDIFFUSION`. +/// `(B / a)² v` is the TI extra variance and is not that +/// composition when those contributions are nonzero. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryInitialLatentVarianceIsNotAsymptoticTimeIndependentVariance`]. +pub fn refuse_stationary_initial_latent_variance_as_asymptotic_time_independent_variance( + stationary_t0_variance: f64, + added_predictor_variance: f64, +) -> Result { + let _ = (stationary_t0_variance, added_predictor_variance); + Err(PsychometricError::StationaryInitialLatentVarianceIsNotAsymptoticTimeIndependentVariance) +} + +/// Refuse treating §4.3 / p. 16 stationary `T0VAR` as a +/// finite-interval discrete latent variance. +/// +/// `trait + −q / (2 a) + (B / a)² v` is the `Δt → ∞` constrained +/// first-occasion variance. `exp(2 a Δt) p + Q_Δt` is a finite +/// event interval and is not that limit. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryInitialLatentVarianceIsNotDiscreteVariance`]. +pub fn refuse_stationary_initial_latent_variance_as_discrete_variance( + stationary_t0_variance: f64, + discrete_variance: f64, +) -> Result { + let _ = (stationary_t0_variance, discrete_variance); + Err(PsychometricError::StationaryInitialLatentVarianceIsNotDiscreteVariance) +} + +/// Exact scalar Eq. 5 of §4.3 / p. 16 stationary `T0VAR`. +/// +/// Driver, Oud, and Voelkle (2017, §4.3, pp. 9–10; Eq. 5, p. 5; +/// Table 2, p. 12; p. 16; §7.2, pp. 20–21; JSS PDF re-opened +/// 2026-08-22T03:20Z from +/// ) +/// constrain `T0VAR` to the model-predicted variance when +/// `stationary` includes `"T0VAR"`. Equation 5 writes +/// `y_i(t) = Γ + Λ η_i(t) + ζ_i(t)` with `ζ ~ N(0, Θ)` and +/// `Γ ~ N(τ, Ψ)`. The constrained latent variance is +/// `trait + −q / (2 a) + (B / a)² v`. The scalar composition is +/// `Var(y_0) = λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ`. Form +/// the stationary latent variance first, then `λ² p + θ + ψ`. A +/// zero loading is exactly `θ + ψ`. A zero trait, a zero diffusion, +/// and a zero TI contribution is exactly `θ + ψ`. `λ² p_0` for +/// free `T0VAR` is not this composition. `λ²(−q / (2 a)) + θ` is +/// not this composition when `TRAITVAR` or `addedTIPREDVAR` is +/// nonzero. Evolving the constrained variance as if it were all +/// state is not this composition when the trait or TI contribution +/// is nonzero. `MANIFESTVAR` is not `Var(y_0)`. The constrained +/// latent variance is not `Var(y_0)`. `TRAITVAR` is latent and is +/// scaled by `λ²`; `MANIFESTTRAITVAR` is not. This is not a Kalman +/// filter, not a matrix `expm`, and not ctsem estimation. +/// +/// # Errors +/// +/// Propagates [`recover_stationary_initial_latent_variance`] and +/// [`recover_manifest_trait_plus_state_observed_variance`]. +#[allow(clippy::too_many_arguments)] +pub fn recover_stationary_initial_observed_variance( + loading: f64, + trait_variance: f64, + continuous_diffusion: f64, + time_independent_effect: f64, + predictor_variance: f64, + log_rate: f64, + measurement_error_variance: f64, + manifest_trait_variance: f64, + clock: LagClock, +) -> Result { + let stationary_latent_variance = recover_stationary_initial_latent_variance( + trait_variance, + continuous_diffusion, + time_independent_effect, + predictor_variance, + log_rate, + clock, + )?; + recover_manifest_trait_plus_state_observed_variance( + loading, + stationary_latent_variance, + measurement_error_variance, + manifest_trait_variance, + ) +} + +/// Refuse treating §4.3 stationary `T0VAR` as `Var(y_0)`. +/// +/// `trait + −q / (2 a) + (B / a)² v` is the constrained latent +/// variance. Equation 5 maps `Var(y_0) = λ²` of that variance plus +/// `θ + ψ`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryInitialLatentVarianceIsNotObservedVariance`]. +pub fn refuse_stationary_initial_latent_variance_as_observed_variance( + stationary_latent_variance: f64, + stationary_observed_variance: f64, +) -> Result { + let _ = (stationary_latent_variance, stationary_observed_variance); + Err(PsychometricError::StationaryInitialLatentVarianceIsNotObservedVariance) +} + +/// Refuse treating `MANIFESTVAR` as Eq. 5 of §4.3 stationary +/// `T0VAR`. +/// +/// Table 2 names `θ` `MANIFESTVAR`. +/// `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` is not `θ` when +/// the loading and constrained variance are nonzero. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryInitialObservedVarianceIsNotMeasurementError`]. +pub fn refuse_stationary_initial_observed_variance_as_measurement_error( + stationary_observed_variance: f64, + measurement_error_variance: f64, +) -> Result { + let _ = (stationary_observed_variance, measurement_error_variance); + Err(PsychometricError::StationaryInitialObservedVarianceIsNotMeasurementError) +} + +/// Refuse treating evolved `λ² Var(η_t) + θ` as Eq. 5 of §4.3 +/// stationary `T0VAR`. +/// +/// Evolving the constrained first-occasion variance as if it were +/// all state is not `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` +/// when the trait or TI contribution is nonzero. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::EvolvedObservedVarianceIsNotStationaryInitialObservedVariance`]. +pub fn refuse_evolved_observed_variance_as_stationary_initial_observed_variance( + evolved_observed_variance: f64, + stationary_observed_variance: f64, +) -> Result { + let _ = (evolved_observed_variance, stationary_observed_variance); + Err(PsychometricError::EvolvedObservedVarianceIsNotStationaryInitialObservedVariance) +} + +/// Refuse treating Eq. 5 of `asymDIFFUSION` as Eq. 5 of §4.3 +/// stationary `T0VAR`. +/// +/// `λ²(−q / (2 a)) + θ` is the within-subject observed contribution +/// and is not that composition when `TRAITVAR` or `addedTIPREDVAR` +/// is nonzero. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryWithinSubjectObservedVarianceIsNotStationaryInitialObservedVariance`]. +pub fn refuse_stationary_within_subject_observed_variance_as_stationary_initial_observed_variance( + within_subject_observed_variance: f64, + stationary_observed_variance: f64, +) -> Result { + let _ = ( + within_subject_observed_variance, + stationary_observed_variance, + ); + Err( + PsychometricError::StationaryWithinSubjectObservedVarianceIsNotStationaryInitialObservedVariance, + ) +} + +/// Refuse treating Eq. 5 of free `T0VAR` as Eq. 5 of §4.3 +/// stationary `T0VAR`. +/// +/// `λ² p_0 + θ` is the free first-occasion observed variance. +/// `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` is not that map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::InitialObservedVarianceIsNotStationaryInitialObservedVariance`]. +pub fn refuse_initial_observed_variance_as_stationary_initial_observed_variance( + free_initial_observed_variance: f64, + stationary_observed_variance: f64, +) -> Result { + let _ = (free_initial_observed_variance, stationary_observed_variance); + Err(PsychometricError::InitialObservedVarianceIsNotStationaryInitialObservedVariance) +} + +/// Exact scalar lagged covariance of §4.3 / p. 16 stationary +/// `T0VAR`. +/// +/// Driver, Oud, and Voelkle (2017, §4.3, pp. 9–10; Eq. 3–4, pp. 4–5; +/// Table 2, p. 12; p. 16; §7.2, pp. 20–21; JSS PDF re-opened +/// 2026-08-22T19:13Z from +/// ) +/// constrain `T0VAR` to the model-predicted variance when +/// `stationary` includes `"T0VAR"`. Equation 3 writes +/// `η(t) = exp(A Δt) η(t0) + …`. Equation 4 writes +/// `cov(η_t, η_{t-1}) = A_Δt cov(η_{t-1})`. Page 16 names +/// `asymDIFFUSION` the total within-subject variance `-q / (2 a)`. +/// Section 4.3 (p. 9) adds a stable trait process with `DRIFT` and +/// `DIFFUSION` fixed to zero (`TRAITVAR`). Section 7.2 names +/// `addedTIPREDVAR` the stable between-subject variance accounted +/// for by time-independent predictors; the scalar map is +/// `(B / a)² v`. Trait variance and that TI extra variance are +/// time-invariant between-subject; they do not decay with +/// `e^{a Δt}`. The lagged covariance of the constrained process is +/// `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v`. Form the lagged +/// within-subject covariance first, then include the trait, then +/// include the TI extra variance, then add. A zero trait, a zero +/// diffusion, and a zero TI contribution is exactly zero. A zero +/// diffusion and a zero TI contribution is exactly the trait. +/// As `Δt → ∞` with stable `a < 0` the state term vanishes and the +/// lagged covariance is `trait + (B / a)² v`. As `Δt → 0+` the +/// lagged covariance approaches contemporaneous `T0VAR`. Those +/// limits are not this finite-lag map. Evolving the constrained +/// total as if it were all state is not this map. +/// `trait + e^{a Δt} p` is not this map when `addedTIPREDVAR` is +/// nonzero. Contemporaneous `T0VAR` is not this map. `a ≥ 0` cannot +/// hold a finite process variance when the diffusion or the TI +/// contribution is nonzero and fails closed. Trait-only covariance +/// does not require a stable drift. The interval must be event time +/// and strictly positive. This is not a Kalman filter, not a matrix +/// `expm`, and not ctsem estimation. +/// +/// # Errors +/// +/// Propagates [`recover_stationary_initial_latent_variance`] path +/// refusals and [`recover_trait_plus_state_lagged_covariance`]. +/// Returns [`PsychometricError::EventTimeRequired`] for any +/// non-event clock, +/// [`PsychometricError::NonPositiveInterval`] when `event_delta` is +/// not strictly positive, +/// [`PsychometricError::StationaryVarianceRequiresStableDrift`] +/// when the diffusion is nonzero and the drift is not strictly +/// negative, +/// [`PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift`] +/// when the TI contribution is nonzero and the drift is not +/// strictly negative, and +/// [`PsychometricError::InvalidNumericInput`] when an input is +/// non-finite, a variance is negative, or a product or sum +/// overflows. +#[allow(clippy::too_many_arguments)] +pub fn recover_stationary_lagged_latent_covariance( + trait_variance: f64, + continuous_diffusion: f64, + time_independent_effect: f64, + predictor_variance: f64, + log_rate: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + let state = if continuous_diffusion == 0.0 { + 0.0 + } else { + recover_stationary_latent_variance(continuous_diffusion, log_rate, clock)? + }; + let trait_plus_state = recover_trait_plus_state_lagged_covariance( + trait_variance, + state, + log_rate, + event_delta, + clock, + )?; + let added = recover_asymptotic_time_independent_predictor_variance( + time_independent_effect, + predictor_variance, + log_rate, + clock, + )?; + require_finite(trait_plus_state + added) +} + +/// Refuse treating lagged §4.3 stationary `T0VAR` as contemporaneous +/// stationary `T0VAR`. +/// +/// `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` is the lagged +/// covariance at a strictly positive event interval. +/// `trait + −q / (2 a) + (B / a)² v` is the first-occasion +/// variance. Those are not the same map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryLaggedLatentCovarianceIsNotStationaryInitialLatentVariance`]. +pub fn refuse_stationary_lagged_latent_covariance_as_stationary_initial_latent_variance( + lagged_covariance: f64, + contemporaneous_variance: f64, +) -> Result { + let _ = (lagged_covariance, contemporaneous_variance); + Err(PsychometricError::StationaryLaggedLatentCovarianceIsNotStationaryInitialLatentVariance) +} + +/// Refuse treating lagged §4.3 stationary `T0VAR` as decayed total +/// stationary variance. +/// +/// Evolving `trait + −q / (2 a) + (B / a)² v` as if it were all +/// state yields `e^{a Δt}` of that total. Trait variance and +/// `addedTIPREDVAR` do not decay. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryLaggedLatentCovarianceIsNotDecayedStationaryVariance`]. +pub fn refuse_stationary_lagged_latent_covariance_as_decayed_stationary_variance( + lagged_covariance: f64, + decayed_total: f64, +) -> Result { + let _ = (lagged_covariance, decayed_total); + Err(PsychometricError::StationaryLaggedLatentCovarianceIsNotDecayedStationaryVariance) +} + +/// Refuse treating §4.3 trait-plus-state lagged covariance as lagged +/// stationary `T0VAR`. +/// +/// `trait + e^{a Δt} p` omits `addedTIPREDVAR`. The constrained +/// lagged covariance includes that TI extra variance. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::TraitPlusStateLaggedCovarianceIsNotStationaryLaggedLatentCovariance`]. +pub fn refuse_trait_plus_state_lagged_covariance_as_stationary_lagged_latent_covariance( + trait_plus_state_lagged: f64, + stationary_lagged: f64, +) -> Result { + let _ = (trait_plus_state_lagged, stationary_lagged); + Err(PsychometricError::TraitPlusStateLaggedCovarianceIsNotStationaryLaggedLatentCovariance) +} + +/// Exact scalar Eq. 5 of lagged §4.3 / p. 16 stationary `T0VAR`. +/// +/// Driver, Oud, and Voelkle (2017, §4.3, pp. 9–10; Eq. 5, p. 5; +/// Eq. 3–4, pp. 4–5; Table 2, p. 12; p. 16; §7.2, pp. 20–21; JSS PDF +/// re-opened 2026-08-22T19:13Z from +/// ) +/// write `y_i(t) = Γ + Λ η_i(t) + ζ_i(t)` with `ζ ~ N(0, Θ)` and +/// `Γ ~ N(τ, Ψ)`. Independent measurement error does not enter +/// `cov(y_t, y_{t-1})`. The lagged latent covariance is +/// `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v`. The scalar +/// composition is +/// `cov(y_t, y_{t-1}) = λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ`. +/// Form the lagged latent covariance first, then `λ² c + ψ`. A zero +/// loading is exactly `ψ`. A zero trait, a zero diffusion, and a +/// zero TI contribution is exactly `ψ`. `MANIFESTVAR` `θ` is not +/// this composition. Contemporaneous `Var(y_0)` includes `θ` and is +/// not this composition. The lagged latent covariance is not this +/// observed covariance. Evolving the constrained total as if it +/// were all state is not this composition when the trait or TI +/// contribution is nonzero. `TRAITVAR` is latent and is scaled by +/// `λ²`; `MANIFESTTRAITVAR` is not. This is not a Kalman filter, +/// not a matrix `expm`, and not ctsem estimation. +/// +/// # Errors +/// +/// Propagates [`recover_stationary_lagged_latent_covariance`] and +/// [`recover_manifest_lagged_observed_covariance`]. +#[allow(clippy::too_many_arguments)] +pub fn recover_stationary_lagged_observed_covariance( + loading: f64, + trait_variance: f64, + continuous_diffusion: f64, + time_independent_effect: f64, + predictor_variance: f64, + log_rate: f64, + event_delta: f64, + manifest_trait_variance: f64, + clock: LagClock, +) -> Result { + let lagged_latent = recover_stationary_lagged_latent_covariance( + trait_variance, + continuous_diffusion, + time_independent_effect, + predictor_variance, + log_rate, + event_delta, + clock, + )?; + recover_manifest_lagged_observed_covariance(loading, lagged_latent, manifest_trait_variance) +} + +/// Refuse treating lagged §4.3 stationary `T0VAR` as lagged observed +/// covariance. +/// +/// `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` is the lagged latent +/// covariance. Equation 5 maps `cov(y_t, y_{t-1}) = λ²` of that +/// covariance plus `ψ`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryLaggedLatentCovarianceIsNotObservedCovariance`]. +pub fn refuse_stationary_lagged_latent_covariance_as_observed_covariance( + lagged_latent_covariance: f64, + lagged_observed_covariance: f64, +) -> Result { + let _ = (lagged_latent_covariance, lagged_observed_covariance); + Err(PsychometricError::StationaryLaggedLatentCovarianceIsNotObservedCovariance) +} + +/// Refuse treating `MANIFESTVAR` as Eq. 5 of lagged §4.3 stationary +/// `T0VAR`. +/// +/// Table 2 names `θ` `MANIFESTVAR`. Independent `ε_t` does not +/// enter `cov(y_t, y_{t-1})`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::MeasurementErrorIsNotStationaryLaggedObservedCovariance`]. +pub fn refuse_measurement_error_as_stationary_lagged_observed_covariance( + measurement_error_variance: f64, + lagged_observed_covariance: f64, +) -> Result { + let _ = (measurement_error_variance, lagged_observed_covariance); + Err(PsychometricError::MeasurementErrorIsNotStationaryLaggedObservedCovariance) +} + +/// Refuse treating Eq. 5 of contemporaneous §4.3 stationary `T0VAR` +/// as lagged stationary observed covariance. +/// +/// `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` is +/// contemporaneous and includes `θ`. +/// `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ` is not that +/// map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryInitialObservedVarianceIsNotStationaryLaggedObservedCovariance`]. +pub fn refuse_stationary_initial_observed_variance_as_stationary_lagged_observed_covariance( + contemporaneous_observed_variance: f64, + lagged_observed_covariance: f64, +) -> Result { + let _ = ( + contemporaneous_observed_variance, + lagged_observed_covariance, + ); + Err(PsychometricError::StationaryInitialObservedVarianceIsNotStationaryLaggedObservedCovariance) +} + +/// Exact scalar later-occasion variance of §4.3 / p. 16 stationary +/// `T0VAR`. +/// +/// Driver, Oud, and Voelkle (2017, §4.3, pp. 9–10; Eq. 3–4, pp. 4–5; +/// Table 2, p. 12; p. 16; §7.2, pp. 20–21; JSS PDF re-opened +/// 2026-08-22T23:05Z from +/// ) +/// constrain the first-occasion variance according to the +/// model-predicted variances across all time points when `stationary` +/// includes `"T0VAR"`. Equation 3 writes `η(t) = exp(A Δt) η(t0) + … +` +/// the stochastic integral. Equation 4 writes that the integral +/// exhibits covariance `Q_Δt`. The law of total variance on the +/// within-subject state is `e^{2 a Δt}(−q / (2 a)) + Q_Δt`. Trait +/// variance and `addedTIPREDVAR` are time-invariant between-subject; +/// they do not enter that process-noise integral. The later-occasion +/// composition is +/// `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v`. Form the +/// evolved within-subject variance first, then include the trait, +/// then include the TI extra variance, then add. A zero trait, a +/// zero diffusion, and a zero TI contribution is exactly zero. A +/// zero diffusion and a zero TI contribution is exactly the trait. +/// Under stationarity that composition equals contemporaneous +/// `T0VAR`. Evolving the constrained total as if it were all state +/// (`e^{2 a Δt} p + Q_Δt`) is not this map. The lagged covariance +/// `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` omits `Q_Δt` and is +/// not this map. `Q_Δt` is not this map. `a ≥ 0` cannot hold a +/// finite process variance when the diffusion or the TI contribution +/// is nonzero and fails closed. Trait-only variance does not require +/// a stable drift. The interval must be event time and strictly +/// positive. This is not a Kalman filter, not a matrix `expm`, and +/// not ctsem estimation. +/// +/// # Errors +/// +/// Propagates [`recover_stationary_initial_latent_variance`] path +/// refusals and [`recover_discrete_latent_variance`]. Returns +/// [`PsychometricError::EventTimeRequired`] for any non-event clock, +/// [`PsychometricError::NonPositiveInterval`] when `event_delta` is +/// not strictly positive, +/// [`PsychometricError::StationaryVarianceRequiresStableDrift`] +/// when the diffusion is nonzero and the drift is not strictly +/// negative, +/// [`PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift`] +/// when the TI contribution is nonzero and the drift is not +/// strictly negative, and +/// [`PsychometricError::InvalidNumericInput`] when an input is +/// non-finite, a variance is negative, or a product or sum +/// overflows. +#[allow(clippy::too_many_arguments)] +pub fn recover_stationary_later_latent_variance( + trait_variance: f64, + continuous_diffusion: f64, + time_independent_effect: f64, + predictor_variance: f64, + log_rate: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + let state = if continuous_diffusion == 0.0 { + 0.0 + } else { + recover_stationary_latent_variance(continuous_diffusion, log_rate, clock)? + }; + let evolved_state = recover_discrete_latent_variance( + state, + continuous_diffusion, + log_rate, + event_delta, + clock, + )?; + let trait_plus_evolved = + recover_trait_plus_state_latent_variance(trait_variance, evolved_state)?; + let added = recover_asymptotic_time_independent_predictor_variance( + time_independent_effect, + predictor_variance, + log_rate, + clock, + )?; + require_finite(trait_plus_evolved + added) +} + +/// Refuse treating later-occasion §4.3 stationary `T0VAR` as lagged +/// stationary covariance. +/// +/// `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v` is the +/// unconditional variance at a later event occasion. The lagged +/// covariance omits `Q_Δt` and uses `e^{a Δt}` of the state. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryLaterLatentVarianceIsNotLaggedCovariance`]. +pub fn refuse_stationary_later_latent_variance_as_lagged_covariance( + later_variance: f64, + lagged_covariance: f64, +) -> Result { + let _ = (later_variance, lagged_covariance); + Err(PsychometricError::StationaryLaterLatentVarianceIsNotLaggedCovariance) +} + +/// Refuse treating later-occasion §4.3 stationary `T0VAR` as the free +/// discrete evolution of the constrained total. +/// +/// Evolving `trait + −q / (2 a) + (B / a)² v` as if it were all +/// state yields `e^{2 a Δt}` of that total plus `Q_Δt`. Trait +/// variance and `addedTIPREDVAR` do not enter `Q_Δt`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryLaterLatentVarianceIsNotDiscreteVariance`]. +pub fn refuse_stationary_later_latent_variance_as_discrete_variance( + later_variance: f64, + free_discrete_variance: f64, +) -> Result { + let _ = (later_variance, free_discrete_variance); + Err(PsychometricError::StationaryLaterLatentVarianceIsNotDiscreteVariance) +} + +/// Refuse treating later-occasion §4.3 stationary `T0VAR` as +/// finite-interval process noise. +/// +/// `Q_Δt` is the covariance of the stochastic integral. The +/// later-occasion composition includes the trait, the evolved state, +/// and `addedTIPREDVAR`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryLaterLatentVarianceIsNotProcessNoise`]. +pub fn refuse_stationary_later_latent_variance_as_process_noise( + later_variance: f64, + process_noise: f64, +) -> Result { + let _ = (later_variance, process_noise); + Err(PsychometricError::StationaryLaterLatentVarianceIsNotProcessNoise) +} + +/// Exact scalar Eq. 5 of later-occasion §4.3 / p. 16 stationary +/// `T0VAR`. +/// +/// Driver, Oud, and Voelkle (2017, §4.3, pp. 9–10; Eq. 5, p. 5; +/// Eq. 3–4, pp. 4–5; Table 2, p. 12; p. 16; §7.2, pp. 20–21; JSS PDF +/// re-opened 2026-08-22T23:05Z from +/// ) +/// write `y_i(t) = Γ + Λ η_i(t) + ζ_i(t)` with `ζ ~ N(0, Θ)` and +/// `Γ ~ N(τ, Ψ)`. The later-occasion latent variance is +/// `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v`. The scalar +/// composition is +/// `Var(y_t) = λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ`. +/// Form the later-occasion latent variance first, then `λ² p + θ + ψ`. +/// A zero loading is exactly `θ + ψ`. A zero trait, a zero diffusion, +/// and a zero TI contribution is exactly `θ + ψ`. Under stationarity +/// that composition equals contemporaneous `Var(y_0)`. The lagged +/// observed covariance omits `Q_Δt` and `θ`. `MANIFESTVAR` `θ` is +/// not this composition. The later-occasion latent variance is not +/// this observed variance. `TRAITVAR` is latent and is scaled by +/// `λ²`; `MANIFESTTRAITVAR` is not. This is not a Kalman filter, not +/// a matrix `expm`, and not ctsem estimation. +/// +/// # Errors +/// +/// Propagates [`recover_stationary_later_latent_variance`] and +/// [`recover_manifest_trait_plus_state_observed_variance`]. +#[allow(clippy::too_many_arguments)] +pub fn recover_stationary_later_observed_variance( + loading: f64, + trait_variance: f64, + continuous_diffusion: f64, + time_independent_effect: f64, + predictor_variance: f64, + log_rate: f64, + event_delta: f64, + measurement_error_variance: f64, + manifest_trait_variance: f64, + clock: LagClock, +) -> Result { + let later_latent = recover_stationary_later_latent_variance( + trait_variance, + continuous_diffusion, + time_independent_effect, + predictor_variance, + log_rate, + event_delta, + clock, + )?; + recover_manifest_trait_plus_state_observed_variance( + loading, + later_latent, + measurement_error_variance, + manifest_trait_variance, + ) +} + +/// Refuse treating later-occasion §4.3 stationary `T0VAR` as +/// later-occasion observed variance. +/// +/// `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v` is the +/// later-occasion latent variance. Equation 5 maps `Var(y_t) = λ²` +/// of that variance plus `θ + ψ`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryLaterLatentVarianceIsNotObservedVariance`]. +pub fn refuse_stationary_later_latent_variance_as_observed_variance( + later_latent_variance: f64, + later_observed_variance: f64, +) -> Result { + let _ = (later_latent_variance, later_observed_variance); + Err(PsychometricError::StationaryLaterLatentVarianceIsNotObservedVariance) +} + +/// Refuse treating `MANIFESTVAR` as Eq. 5 of later-occasion §4.3 +/// stationary `T0VAR`. +/// +/// Table 2 names `θ` `MANIFESTVAR`. `θ` is not +/// `λ²(trait + e^{2 a Δt} p + Q_Δt + (B / a)² v) + θ + ψ`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::MeasurementErrorIsNotStationaryLaterObservedVariance`]. +pub fn refuse_measurement_error_as_stationary_later_observed_variance( + measurement_error_variance: f64, + later_observed_variance: f64, +) -> Result { + let _ = (measurement_error_variance, later_observed_variance); + Err(PsychometricError::MeasurementErrorIsNotStationaryLaterObservedVariance) +} + +/// Refuse treating Eq. 5 of lagged §4.3 stationary `T0VAR` as +/// later-occasion stationary observed variance. +/// +/// `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ` omits `Q_Δt` +/// and `θ`. +/// `λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ` +/// is not that map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StationaryLaggedObservedCovarianceIsNotStationaryLaterObservedVariance`]. +pub fn refuse_stationary_lagged_observed_covariance_as_stationary_later_observed_variance( + lagged_observed_covariance: f64, + later_observed_variance: f64, +) -> Result { + let _ = (lagged_observed_covariance, later_observed_variance); + Err(PsychometricError::StationaryLaggedObservedCovarianceIsNotStationaryLaterObservedVariance) +} + +/// Exact scalar observed mean of a time-independent predictor. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; Eq. 3, p. 5; Table 2, +/// p. 12; JSS PDF re-opened 2026-08-20T12:12Z from +/// ) +/// write `y_i(t) = Γ + Λ η_i(t) + ζ_i(t)` with `ζ ~ N(0, Θ)` and +/// `Γ ~ N(τ, Ψ)`. Equation 3 (p. 5) writes the time-independent +/// predictor as the printed addend `A^{-1}[e^{A(t−t0)} − I] B z_i` +/// after the `T0MEANS` carry and the `CINT` increment. Table 2 names +/// `B` `TIPREDEFFECT`. The expected intercept is `τ`. The latent +/// process at `t` after that increment is +/// `μ_t + A^{-1}[e^{A Δt} − I] B z`. The scalar composition is +/// `E(y_t) = τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`. Form the +/// evolved-plus-increment latent mean first, then `τ + λ` of that +/// mean. A zero loading is exactly `τ`. A zero evolved-plus-increment +/// latent mean is exactly `τ`. A zero intercept is exactly +/// `λ(μ_t + increment)`. The evolved observed mean `τ + λ μ_t` is +/// not this composition when the increment is nonzero. The +/// contemporaneous map `τ + λ(μ_t + m x)` is not this composition. +/// The carry map `τ + λ(μ_t + e^{a(t−u)} m x)` is not this +/// composition when `u ≠ t`. `MANIFESTMEANS` is not `E(y_t)`. The +/// evolved-plus-increment latent mean is not `E(y_t)`. `TIPREDEFFECT` +/// is `B`, not that observed mean. This is not a Kalman filter and +/// not ctsem estimation. +/// +/// # Errors +/// +/// Propagates +/// [`recover_discrete_latent_mean_with_time_independent_predictor`] +/// and [`recover_manifest_observed_mean`]. +#[allow(clippy::too_many_arguments)] +pub fn recover_discrete_observed_mean_with_time_independent_predictor( + loading: f64, + initial_latent_mean: f64, + log_rate: f64, + continuous_intercept: f64, + time_independent_effect: f64, + time_independent_predictor: f64, + manifest_mean: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + let composed_latent_mean = recover_discrete_latent_mean_with_time_independent_predictor( + initial_latent_mean, + log_rate, + continuous_intercept, + time_independent_effect, + time_independent_predictor, + event_delta, + clock, + )?; + recover_manifest_observed_mean(loading, composed_latent_mean, manifest_mean) +} + +/// Refuse treating the evolved observed mean as the time-independent- +/// predictor observed mean. +/// +/// Equation 5 of the Eq. 3 evolved mean is `τ + λ μ_t`. Equation 5 +/// of the Eq. 3 time-independent predictor is +/// `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`. Those are not the same +/// map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::EvolvedObservedMeanIsNotTimeIndependentObservedMean`]. +pub fn refuse_evolved_observed_mean_as_time_independent_observed_mean( + evolved_observed_mean: f64, + time_independent_observed_mean: f64, +) -> Result { + let _ = (evolved_observed_mean, time_independent_observed_mean); + Err(PsychometricError::EvolvedObservedMeanIsNotTimeIndependentObservedMean) +} + +/// Refuse treating the contemporaneous-impulse observed mean as the +/// time-independent-predictor observed mean. +/// +/// Equation 5 of the contemporaneous Dirac is `τ + λ(μ_t + m x)`. +/// Equation 5 of the Eq. 3 time-independent predictor is +/// `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`. Those are not the same +/// map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::ImpulseObservedMeanIsNotTimeIndependentObservedMean`]. +pub fn refuse_impulse_observed_mean_as_time_independent_observed_mean( + impulse_observed_mean: f64, + time_independent_observed_mean: f64, +) -> Result { + let _ = (impulse_observed_mean, time_independent_observed_mean); + Err(PsychometricError::ImpulseObservedMeanIsNotTimeIndependentObservedMean) +} + +/// Refuse treating the impulse-carry observed mean as the +/// time-independent-predictor observed mean. +/// +/// Equation 5 of the Eq. 1–2 carried latent mean is +/// `τ + λ(μ_t + e^{a(t−u)} m x)`. Equation 5 of the Eq. 3 +/// time-independent predictor is +/// `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`. Those are not the same +/// map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::ImpulseCarryObservedMeanIsNotTimeIndependentObservedMean`]. +pub fn refuse_impulse_carry_observed_mean_as_time_independent_observed_mean( + impulse_carry_observed_mean: f64, + time_independent_observed_mean: f64, +) -> Result { + let _ = (impulse_carry_observed_mean, time_independent_observed_mean); + Err(PsychometricError::ImpulseCarryObservedMeanIsNotTimeIndependentObservedMean) +} + +/// Exact scalar first-occasion time-independent predictor shift. +/// +/// Driver, Oud, and Voelkle (2017, Table 3, p. 13; Eq. 3 first +/// summand, p. 5; JSS PDF opened 2026-08-20T15:14Z from +/// ) +/// name `T0TIPREDEFFECT` the effect of time-independent predictors on +/// latents at `T0`. Table 2 / Table 3 name `TIPREDEFFECT` `B`, which +/// enters Equation 3 as the printed addend +/// `A^{-1}[e^{A(t−t0)} − I] B z`. Those are not the same matrix. The +/// scalar first-occasion shift is `t0_b z`. It is not `B`, not +/// `A^{-1}[e^{A Δt} − I] B z`, not `κ`, and not `M x`. A zero effect +/// or zero predictor is exactly zero. This is not a Kalman filter and +/// not ctsem estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidNumericInput`] when the effect +/// or predictor is non-finite or the product overflows. +pub fn recover_initial_time_independent_predictor_effect( + initial_time_independent_effect: f64, + time_independent_predictor: f64, +) -> Result { + if !initial_time_independent_effect.is_finite() || !time_independent_predictor.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + if initial_time_independent_effect == 0.0 || time_independent_predictor == 0.0 { + return Ok(0.0); + } + require_finite(initial_time_independent_effect * time_independent_predictor) +} + +/// Exact scalar carried first-occasion time-independent predictor. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 3, p. 5; Table 3, p. 13; JSS +/// PDF opened 2026-08-20T15:14Z) write the first summand as +/// `e^{A(t−t0)} η_i(t0)`. A Table 3 `T0TIPREDEFFECT` shift that is +/// already in `η(t0)` therefore appears at `t` as `e^{A Δt} t0_b z`. +/// Form `t0_b z` first, then `e^{a Δt} t0_b z`. A zero drift is +/// `t0_b z` with no dissipation of the first-occasion shift. Binary64 +/// underflow of `e^{a Δt}` to `+0` is a vanishing carry of that +/// shift and is kept. This carry is not the first-occasion shift, not +/// `A^{-1}[e^{A Δt} − I] B z` (`TIPREDEFFECT`), not `CINT`, and not +/// `M x`. When `exp` overflows at a finite `a Δt`, rewrite as +/// `sign(t0_b z) exp(ln|t0_b z| + a Δt)`. An overflowing rewrite +/// fails closed. This is not a Kalman filter and not ctsem estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for any non-event +/// clock, [`PsychometricError::NonPositiveInterval`] when +/// `event_delta` is not strictly positive, and +/// [`PsychometricError::InvalidNumericInput`] when an input is +/// non-finite or the mapped carry overflows. +pub fn recover_initial_time_independent_predictor_carry( + initial_time_independent_effect: f64, + time_independent_predictor: f64, + log_rate: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + if !event_delta.is_finite() || event_delta <= 0.0 { + return Err(PsychometricError::NonPositiveInterval); + } + if !log_rate.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + let initial_shift = recover_initial_time_independent_predictor_effect( + initial_time_independent_effect, + time_independent_predictor, + )?; + if initial_shift == 0.0 { + return Ok(0.0); + } + let drift_interval = log_rate * event_delta; + let auto_effect = drift_interval.exp(); + if auto_effect.is_finite() { + // +0 underflow is a vanishing carry of the T0 shift. + return require_finite(auto_effect * initial_shift); + } + // Overflow of a finite `a Δt` is the log-space rewrite. + // A non-finite argument also fails closed through `require_finite`. + // e^{a Δt} t0_b z = sign(t0_b z) exp(ln|t0_b z| + a Δt). + require_finite(initial_shift.signum() * (initial_shift.abs().ln() + drift_interval).exp()) +} + +/// Exact scalar evolved latent mean plus a first-occasion TI predictor. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 3, p. 5; Table 3, p. 13) write +/// the first summand as the carried `T0MEANS`, which includes any +/// `T0TIPREDEFFECT` shift already in `η(t0)`. Form `μ_t` first, then +/// add `e^{a Δt} t0_b z`. A zero carry is exactly `μ_t`. A zero +/// evolved mean is exactly the carry. Adding `t0_b z` without the +/// exponential is not this composition when `a Δt ≠ 0`. Adding +/// `A^{-1}[e^{A Δt} − I] B z` is not this composition. +/// +/// # Errors +/// +/// Propagates [`recover_discrete_latent_mean`] and +/// [`recover_initial_time_independent_predictor_carry`], and returns +/// [`PsychometricError::InvalidNumericInput`] when the sum overflows. +#[allow(clippy::too_many_arguments)] +pub fn recover_discrete_latent_mean_with_initial_time_independent_predictor( + initial_latent_mean: f64, + log_rate: f64, + continuous_intercept: f64, + initial_time_independent_effect: f64, + time_independent_predictor: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + let evolved_latent_mean = recover_discrete_latent_mean( + initial_latent_mean, + log_rate, + continuous_intercept, + event_delta, + clock, + )?; + let initial_carry = recover_initial_time_independent_predictor_carry( + initial_time_independent_effect, + time_independent_predictor, + log_rate, + event_delta, + clock, + )?; + if initial_carry == 0.0 { + return Ok(evolved_latent_mean); + } + if evolved_latent_mean == 0.0 { + return Ok(initial_carry); + } + require_finite(evolved_latent_mean + initial_carry) +} + +/// Refuse treating the Table 3 first-occasion shift as the Eq. 3 +/// process increment. +/// +/// `T0TIPREDEFFECT` shifts `η(t0)`. `TIPREDEFFECT` `B` enters the +/// SDE and maps as `A^{-1}[e^{A Δt} − I] B z`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::InitialTimeIndependentEffectIsNotProcessIncrement`]. +pub fn refuse_initial_time_independent_effect_as_process_increment( + initial_time_independent_effect: f64, + time_independent_increment: f64, +) -> Result { + let _ = (initial_time_independent_effect, time_independent_increment); + Err(PsychometricError::InitialTimeIndependentEffectIsNotProcessIncrement) +} + +/// Refuse treating the Eq. 3 carry of `T0TIPREDEFFECT` as the +/// first-occasion shift. +/// +/// `e^{A Δt} t0_b z` is the first summand's contribution at `t`. +/// `t0_b z` is the shift at `T0`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::InitialTimeIndependentCarryIsNotInitialEffect`]. +pub fn refuse_initial_time_independent_carry_as_initial_effect( + initial_time_independent_carry: f64, + initial_time_independent_effect: f64, +) -> Result { + let _ = ( + initial_time_independent_carry, + initial_time_independent_effect, + ); + Err(PsychometricError::InitialTimeIndependentCarryIsNotInitialEffect) +} + +/// Refuse treating the Table 3 first-occasion shift as `CINT`. +/// +/// `t0_b z` is an initial-mean shift. `κ` is the continuous intercept. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::InitialTimeIndependentEffectIsNotContinuousIntercept`]. +pub fn refuse_initial_time_independent_effect_as_continuous_intercept( + initial_time_independent_effect: f64, + continuous_intercept: f64, +) -> Result { + let _ = (initial_time_independent_effect, continuous_intercept); + Err(PsychometricError::InitialTimeIndependentEffectIsNotContinuousIntercept) +} + +/// Refuse treating the Table 3 first-occasion shift as `M x`. +/// +/// The product `t0_b z` is algebraically a product, as is `M x`. +/// Table 3 names `T0TIPREDEFFECT` for `T0`. Table 2 names `M` +/// `TDPREDEFFECT` for the Dirac impulse. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::InitialTimeIndependentEffectIsNotTimeDependentImpulse`]. +pub fn refuse_initial_time_independent_effect_as_time_dependent_impulse( + initial_time_independent_effect: f64, + time_dependent_impulse: f64, +) -> Result { + let _ = (initial_time_independent_effect, time_dependent_impulse); + Err(PsychometricError::InitialTimeIndependentEffectIsNotTimeDependentImpulse) +} + +/// Refuse treating Driver Table 3 `T0TIPREDEFFECT` as the +/// first-occasion shift. +/// +/// `T0TIPREDEFFECT` is the coefficient. The shift is `t0_b z`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::InitialTimeIndependentCoefficientIsNotInitialEffect`]. +pub fn refuse_initial_time_independent_coefficient_as_initial_effect( + initial_time_independent_coefficient: f64, + initial_time_independent_effect: f64, +) -> Result { + let _ = ( + initial_time_independent_coefficient, + initial_time_independent_effect, + ); + Err(PsychometricError::InitialTimeIndependentCoefficientIsNotInitialEffect) +} + +/// Exact scalar observed mean of a first-occasion time-independent +/// predictor. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; Eq. 3 first summand, +/// p. 5; Table 3, p. 13; JSS PDF re-opened 2026-08-20T15:28Z from +/// ) +/// write `y_i(t) = Γ + Λ η_i(t) + ζ_i(t)` with `ζ ~ N(0, Θ)` and +/// `Γ ~ N(τ, Ψ)`. Table 3 names `T0TIPREDEFFECT` the effect of +/// time-independent predictors on latents at `T0`. Equation 3's +/// first summand carries that shift as `e^{A Δt} t0_b z`. The +/// expected intercept is `τ`. The latent process at `t` after that +/// carry is `μ_t + e^{a Δt} t0_b z`. The scalar composition is +/// `E(y_t) = τ + λ(μ_t + e^{a Δt} t0_b z)`. Form the +/// evolved-plus-carry latent mean first, then `τ + λ` of that mean. +/// A zero loading is exactly `τ`. A zero evolved-plus-carry latent +/// mean is exactly `τ`. A zero intercept is exactly +/// `λ(μ_t + e^{a Δt} t0_b z)`. The evolved observed mean +/// `τ + λ μ_t` is not this composition when the carry is nonzero. +/// The process-increment map +/// `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not this composition. +/// The contemporaneous map `τ + λ(μ_t + m x)` is not this +/// composition. The impulse-carry map +/// `τ + λ(μ_t + e^{a(t−u)} m x)` is not this composition when +/// `u ≠ t0`. `MANIFESTMEANS` is not `E(y_t)`. The +/// evolved-plus-carry latent mean is not `E(y_t)`. +/// `T0TIPREDEFFECT` is the coefficient, not that observed mean. +/// This is not a Kalman filter and not ctsem estimation. +/// +/// # Errors +/// +/// Propagates +/// [`recover_discrete_latent_mean_with_initial_time_independent_predictor`] +/// and [`recover_manifest_observed_mean`]. +#[allow(clippy::too_many_arguments)] +pub fn recover_discrete_observed_mean_with_initial_time_independent_predictor( + loading: f64, + initial_latent_mean: f64, + log_rate: f64, + continuous_intercept: f64, + initial_time_independent_effect: f64, + time_independent_predictor: f64, + manifest_mean: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + let composed_latent_mean = + recover_discrete_latent_mean_with_initial_time_independent_predictor( + initial_latent_mean, + log_rate, + continuous_intercept, + initial_time_independent_effect, + time_independent_predictor, + event_delta, + clock, + )?; + recover_manifest_observed_mean(loading, composed_latent_mean, manifest_mean) +} + +/// Refuse treating the evolved observed mean as the first-occasion +/// time-independent-predictor observed mean. +/// +/// Equation 5 of the Eq. 3 evolved mean is `τ + λ μ_t`. Equation 5 +/// of the Table 3 first-occasion TI predictor is +/// `τ + λ(μ_t + e^{a Δt} t0_b z)`. Those are not the same map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::EvolvedObservedMeanIsNotInitialTimeIndependentObservedMean`]. +pub fn refuse_evolved_observed_mean_as_initial_time_independent_observed_mean( + evolved_observed_mean: f64, + initial_time_independent_observed_mean: f64, +) -> Result { + let _ = ( + evolved_observed_mean, + initial_time_independent_observed_mean, + ); + Err(PsychometricError::EvolvedObservedMeanIsNotInitialTimeIndependentObservedMean) +} + +/// Refuse treating the process-increment observed mean as the +/// first-occasion time-independent-predictor observed mean. +/// +/// Equation 5 of the Eq. 3 `TIPREDEFFECT` increment is +/// `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`. Equation 5 of the +/// Table 3 first-occasion TI predictor is +/// `τ + λ(μ_t + e^{a Δt} t0_b z)`. Those are not the same map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::TimeIndependentObservedMeanIsNotInitialTimeIndependentObservedMean`]. +pub fn refuse_time_independent_observed_mean_as_initial_time_independent_observed_mean( + time_independent_observed_mean: f64, + initial_time_independent_observed_mean: f64, +) -> Result { + let _ = ( + time_independent_observed_mean, + initial_time_independent_observed_mean, + ); + Err(PsychometricError::TimeIndependentObservedMeanIsNotInitialTimeIndependentObservedMean) +} + +/// Refuse treating the contemporaneous-impulse observed mean as the +/// first-occasion time-independent-predictor observed mean. +/// +/// Equation 5 of the contemporaneous Dirac is `τ + λ(μ_t + m x)`. +/// Equation 5 of the Table 3 first-occasion TI predictor is +/// `τ + λ(μ_t + e^{a Δt} t0_b z)`. Those are not the same map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::ImpulseObservedMeanIsNotInitialTimeIndependentObservedMean`]. +pub fn refuse_impulse_observed_mean_as_initial_time_independent_observed_mean( + impulse_observed_mean: f64, + initial_time_independent_observed_mean: f64, +) -> Result { + let _ = ( + impulse_observed_mean, + initial_time_independent_observed_mean, + ); + Err(PsychometricError::ImpulseObservedMeanIsNotInitialTimeIndependentObservedMean) +} + +/// Refuse treating the impulse-carry observed mean as the +/// first-occasion time-independent-predictor observed mean. +/// +/// Equation 5 of the Eq. 1–2 carried latent mean is +/// `τ + λ(μ_t + e^{a(t−u)} m x)`. Equation 5 of the Table 3 +/// first-occasion TI predictor is `τ + λ(μ_t + e^{a Δt} t0_b z)`. +/// Those are not the same map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::ImpulseCarryObservedMeanIsNotInitialTimeIndependentObservedMean`]. +pub fn refuse_impulse_carry_observed_mean_as_initial_time_independent_observed_mean( + impulse_carry_observed_mean: f64, + initial_time_independent_observed_mean: f64, +) -> Result { + let _ = ( + impulse_carry_observed_mean, + initial_time_independent_observed_mean, + ); + Err(PsychometricError::ImpulseCarryObservedMeanIsNotInitialTimeIndependentObservedMean) +} + +/// Exact scalar first-occasion time-dependent predictor shift. +/// +/// Driver, Oud, and Voelkle (2017, Table 3, p. 13; Eq. 3 first +/// summand, p. 5; JSS PDF re-opened 2026-08-20T19:10Z from +/// ) +/// name `T0TDPREDEFFECT` the effect of time-dependent predictors on +/// latents at `T0`. Table 2 / Table 3 name `TDPREDEFFECT` `M`, which +/// enters Equation 3 as the printed fourth-summand Dirac `M x` at +/// `u = t`. Those are not the same matrix. The scalar first-occasion +/// shift is `t0_m x0`. It is not `M`, not `M x`, not +/// `e^{A(t−u)} M x` for `t0 < u < t`, not `t0_b z`, not +/// `A^{-1}[e^{A Δt} − I] B z`, and not `κ`. An impulse at `u ≤ t0` +/// that used `M` is already in `η(t0)` as `TDPREDEFFECT`, not as +/// `T0TDPREDEFFECT`. A zero effect or zero predictor is exactly +/// zero. This is not a Kalman filter and not ctsem estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidNumericInput`] when the effect +/// or predictor is non-finite or the product overflows. +pub fn recover_initial_time_dependent_predictor_effect( + initial_time_dependent_effect: f64, + time_dependent_predictor: f64, +) -> Result { + if !initial_time_dependent_effect.is_finite() || !time_dependent_predictor.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + if initial_time_dependent_effect == 0.0 || time_dependent_predictor == 0.0 { + return Ok(0.0); + } + require_finite(initial_time_dependent_effect * time_dependent_predictor) +} + +/// Exact scalar carried first-occasion time-dependent predictor. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 3, p. 5; Table 3, p. 13; JSS +/// PDF re-opened 2026-08-20T19:10Z) write the first summand as +/// `e^{A(t−t0)} η_i(t0)`. A Table 3 `T0TDPREDEFFECT` shift that is +/// already in `η(t0)` therefore appears at `t` as `e^{A Δt} t0_m x0`. +/// Form `t0_m x0` first, then `e^{a Δt} t0_m x0`. A zero drift is +/// `t0_m x0` with no dissipation of the first-occasion shift. +/// Binary64 underflow of `e^{a Δt}` to `+0` is a vanishing carry of +/// that shift and is kept. This carry is not the first-occasion +/// shift, not `M x`, not `e^{A(t−u)} M x` for `t0 < u < t`, not +/// `t0_b z`, not `A^{-1}[e^{A Δt} − I] B z`, and not `CINT`. When +/// `exp` overflows at a finite `a Δt`, rewrite as +/// `sign(t0_m x0) exp(ln|t0_m x0| + a Δt)`. An overflowing rewrite +/// fails closed. This is not a Kalman filter and not ctsem +/// estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for any non-event +/// clock, [`PsychometricError::NonPositiveInterval`] when +/// `event_delta` is not strictly positive, and +/// [`PsychometricError::InvalidNumericInput`] when an input is +/// non-finite or the mapped carry overflows. +pub fn recover_initial_time_dependent_predictor_carry( + initial_time_dependent_effect: f64, + time_dependent_predictor: f64, + log_rate: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + if !event_delta.is_finite() || event_delta <= 0.0 { + return Err(PsychometricError::NonPositiveInterval); + } + if !log_rate.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + let initial_shift = recover_initial_time_dependent_predictor_effect( + initial_time_dependent_effect, + time_dependent_predictor, + )?; + if initial_shift == 0.0 { + return Ok(0.0); + } + let drift_interval = log_rate * event_delta; + let auto_effect = drift_interval.exp(); + if auto_effect.is_finite() { + // +0 underflow is a vanishing carry of the T0 TD shift. + return require_finite(auto_effect * initial_shift); + } + // Overflow of a finite `a Δt` is the log-space rewrite. + // A non-finite argument also fails closed through `require_finite`. + // e^{a Δt} t0_m x0 = sign(t0_m x0) exp(ln|t0_m x0| + a Δt). + require_finite(initial_shift.signum() * (initial_shift.abs().ln() + drift_interval).exp()) +} + +/// Exact scalar evolved latent mean plus a first-occasion TD predictor. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 3, p. 5; Table 3, p. 13) write +/// the first summand as the carried `T0MEANS`, which includes any +/// `T0TDPREDEFFECT` shift already in `η(t0)`. Form `μ_t` first, then +/// add `e^{a Δt} t0_m x0`. A zero carry is exactly `μ_t`. A zero +/// evolved mean is exactly the carry. Adding `t0_m x0` without the +/// exponential is not this composition when `a Δt ≠ 0`. Adding +/// `M x` or `e^{A(t−u)} M x` is not this composition. +/// +/// # Errors +/// +/// Propagates [`recover_discrete_latent_mean`] and +/// [`recover_initial_time_dependent_predictor_carry`], and returns +/// [`PsychometricError::InvalidNumericInput`] when the sum overflows. +#[allow(clippy::too_many_arguments)] +pub fn recover_discrete_latent_mean_with_initial_time_dependent_predictor( + initial_latent_mean: f64, + log_rate: f64, + continuous_intercept: f64, + initial_time_dependent_effect: f64, + time_dependent_predictor: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + let evolved_latent_mean = recover_discrete_latent_mean( + initial_latent_mean, + log_rate, + continuous_intercept, + event_delta, + clock, + )?; + let initial_carry = recover_initial_time_dependent_predictor_carry( + initial_time_dependent_effect, + time_dependent_predictor, + log_rate, + event_delta, + clock, + )?; + if initial_carry == 0.0 { + return Ok(evolved_latent_mean); + } + if evolved_latent_mean == 0.0 { + return Ok(initial_carry); + } + require_finite(evolved_latent_mean + initial_carry) +} + +/// Refuse treating the Table 3 first-occasion TD shift as `M x`. +/// +/// `T0TDPREDEFFECT` shifts `η(t0)`. `TDPREDEFFECT` `M` enters the +/// SDE as the contemporaneous Dirac `M x` at `u = t`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::InitialTimeDependentEffectIsNotContemporaneousImpulse`]. +pub fn refuse_initial_time_dependent_effect_as_contemporaneous_impulse( + initial_time_dependent_effect: f64, + time_dependent_impulse: f64, +) -> Result { + let _ = (initial_time_dependent_effect, time_dependent_impulse); + Err(PsychometricError::InitialTimeDependentEffectIsNotContemporaneousImpulse) +} + +/// Refuse treating the Eq. 3 carry of `T0TDPREDEFFECT` as the +/// first-occasion shift. +/// +/// `e^{A Δt} t0_m x0` is the first summand's contribution at `t`. +/// `t0_m x0` is the shift at `T0`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::InitialTimeDependentCarryIsNotInitialEffect`]. +pub fn refuse_initial_time_dependent_carry_as_initial_effect( + initial_time_dependent_carry: f64, + initial_time_dependent_effect: f64, +) -> Result { + let _ = (initial_time_dependent_carry, initial_time_dependent_effect); + Err(PsychometricError::InitialTimeDependentCarryIsNotInitialEffect) +} + +/// Refuse treating the Table 3 first-occasion TD shift as `CINT`. +/// +/// `t0_m x0` is an initial-mean shift. `κ` is the continuous intercept. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::InitialTimeDependentEffectIsNotContinuousIntercept`]. +pub fn refuse_initial_time_dependent_effect_as_continuous_intercept( + initial_time_dependent_effect: f64, + continuous_intercept: f64, +) -> Result { + let _ = (initial_time_dependent_effect, continuous_intercept); + Err(PsychometricError::InitialTimeDependentEffectIsNotContinuousIntercept) +} + +/// Refuse treating the Table 3 first-occasion TD shift as the Eq. 3 +/// process increment. +/// +/// `t0_m x0` shifts `η(t0)`. `TIPREDEFFECT` `B` maps as +/// `A^{-1}[e^{A Δt} − I] B z`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::InitialTimeDependentEffectIsNotProcessIncrement`]. +pub fn refuse_initial_time_dependent_effect_as_process_increment( + initial_time_dependent_effect: f64, + time_independent_increment: f64, +) -> Result { + let _ = (initial_time_dependent_effect, time_independent_increment); + Err(PsychometricError::InitialTimeDependentEffectIsNotProcessIncrement) +} + +/// Refuse treating the Table 3 first-occasion TD shift as the Table 3 +/// first-occasion TI shift. +/// +/// `T0TDPREDEFFECT` and `T0TIPREDEFFECT` are different Table 3 +/// matrices. `t0_m x0` is not `t0_b z`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::InitialTimeDependentEffectIsNotInitialTimeIndependentEffect`]. +pub fn refuse_initial_time_dependent_effect_as_initial_time_independent_effect( + initial_time_dependent_effect: f64, + initial_time_independent_effect: f64, +) -> Result { + let _ = ( + initial_time_dependent_effect, + initial_time_independent_effect, + ); + Err(PsychometricError::InitialTimeDependentEffectIsNotInitialTimeIndependentEffect) +} + +/// Refuse treating Driver Table 3 `T0TDPREDEFFECT` as the +/// first-occasion shift. +/// +/// `T0TDPREDEFFECT` is the coefficient. The shift is `t0_m x0`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::InitialTimeDependentCoefficientIsNotInitialEffect`]. +pub fn refuse_initial_time_dependent_coefficient_as_initial_effect( + initial_time_dependent_coefficient: f64, + initial_time_dependent_effect: f64, +) -> Result { + let _ = ( + initial_time_dependent_coefficient, + initial_time_dependent_effect, + ); + Err(PsychometricError::InitialTimeDependentCoefficientIsNotInitialEffect) +} + +/// Refuse treating the Eq. 3 carry of `T0TDPREDEFFECT` as the +/// within-interval impulse carry. +/// +/// `e^{A Δt} t0_m x0` carries a Table 3 first-occasion TD shift. +/// `e^{A(t−u)} M x` for `t0 < u < t` carries a Table 2 Dirac that +/// occurred inside the interval. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::InitialTimeDependentCarryIsNotImpulseCarry`]. +pub fn refuse_initial_time_dependent_carry_as_impulse_carry( + initial_time_dependent_carry: f64, + impulse_carry: f64, +) -> Result { + let _ = (initial_time_dependent_carry, impulse_carry); + Err(PsychometricError::InitialTimeDependentCarryIsNotImpulseCarry) +} + +/// Exact scalar observed mean of a first-occasion time-dependent +/// predictor. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; Eq. 3 first summand, +/// p. 5; Table 3, p. 13; JSS PDF re-opened 2026-08-20T19:20Z from +/// ) +/// write `y_i(t) = Γ + Λ η_i(t) + ζ_i(t)` with `ζ ~ N(0, Θ)` and +/// `Γ ~ N(τ, Ψ)`. Table 3 names `T0TDPREDEFFECT` the effect of +/// time-dependent predictors on latents at `T0`. Equation 3's first +/// summand carries that shift as `e^{A Δt} t0_m x0`. The expected +/// intercept is `τ`. The latent process at `t` after that carry is +/// `μ_t + e^{a Δt} t0_m x0`. The scalar composition is +/// `E(y_t) = τ + λ(μ_t + e^{a Δt} t0_m x0)`. Form the +/// evolved-plus-carry latent mean first, then `τ + λ` of that mean. +/// A zero loading is exactly `τ`. A zero evolved-plus-carry latent +/// mean is exactly `τ`. A zero intercept is exactly +/// `λ(μ_t + e^{a Δt} t0_m x0)`. The evolved observed mean +/// `τ + λ μ_t` is not this composition when the carry is nonzero. +/// The process-increment map +/// `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not this composition. +/// The contemporaneous map `τ + λ(μ_t + m x)` is not this +/// composition. The impulse-carry map +/// `τ + λ(μ_t + e^{a(t−u)} m x)` is not this composition when +/// `u ≠ t0`. The first-occasion TI map +/// `τ + λ(μ_t + e^{a Δt} t0_b z)` is not this composition. +/// `MANIFESTMEANS` is not `E(y_t)`. The evolved-plus-carry latent +/// mean is not `E(y_t)`. `T0TDPREDEFFECT` is the coefficient, not +/// that observed mean. This is not a Kalman filter and not ctsem +/// estimation. +/// +/// # Errors +/// +/// Propagates +/// [`recover_discrete_latent_mean_with_initial_time_dependent_predictor`] +/// and [`recover_manifest_observed_mean`]. +#[allow(clippy::too_many_arguments)] +pub fn recover_discrete_observed_mean_with_initial_time_dependent_predictor( + loading: f64, + initial_latent_mean: f64, + log_rate: f64, + continuous_intercept: f64, + initial_time_dependent_effect: f64, + time_dependent_predictor: f64, + manifest_mean: f64, + event_delta: f64, + clock: LagClock, +) -> Result { + let composed_latent_mean = recover_discrete_latent_mean_with_initial_time_dependent_predictor( + initial_latent_mean, + log_rate, + continuous_intercept, + initial_time_dependent_effect, + time_dependent_predictor, + event_delta, + clock, + )?; + recover_manifest_observed_mean(loading, composed_latent_mean, manifest_mean) +} + +/// Refuse treating the evolved observed mean as the first-occasion +/// time-dependent-predictor observed mean. +/// +/// Equation 5 of the Eq. 3 evolved mean is `τ + λ μ_t`. Equation 5 +/// of the Table 3 first-occasion TD predictor is +/// `τ + λ(μ_t + e^{a Δt} t0_m x0)`. Those are not the same map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::EvolvedObservedMeanIsNotInitialTimeDependentObservedMean`]. +pub fn refuse_evolved_observed_mean_as_initial_time_dependent_observed_mean( + evolved_observed_mean: f64, + initial_time_dependent_observed_mean: f64, +) -> Result { + let _ = (evolved_observed_mean, initial_time_dependent_observed_mean); + Err(PsychometricError::EvolvedObservedMeanIsNotInitialTimeDependentObservedMean) +} + +/// Refuse treating the process-increment observed mean as the +/// first-occasion time-dependent-predictor observed mean. +/// +/// Equation 5 of the Eq. 3 `TIPREDEFFECT` increment is +/// `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`. Equation 5 of the +/// Table 3 first-occasion TD predictor is +/// `τ + λ(μ_t + e^{a Δt} t0_m x0)`. Those are not the same map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::TimeIndependentObservedMeanIsNotInitialTimeDependentObservedMean`]. +pub fn refuse_time_independent_observed_mean_as_initial_time_dependent_observed_mean( + time_independent_observed_mean: f64, + initial_time_dependent_observed_mean: f64, +) -> Result { + let _ = ( + time_independent_observed_mean, + initial_time_dependent_observed_mean, + ); + Err(PsychometricError::TimeIndependentObservedMeanIsNotInitialTimeDependentObservedMean) +} + +/// Refuse treating the contemporaneous-impulse observed mean as the +/// first-occasion time-dependent-predictor observed mean. +/// +/// Equation 5 of the contemporaneous Dirac is `τ + λ(μ_t + m x)`. +/// Equation 5 of the Table 3 first-occasion TD predictor is +/// `τ + λ(μ_t + e^{a Δt} t0_m x0)`. Those are not the same map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::ImpulseObservedMeanIsNotInitialTimeDependentObservedMean`]. +pub fn refuse_impulse_observed_mean_as_initial_time_dependent_observed_mean( + impulse_observed_mean: f64, + initial_time_dependent_observed_mean: f64, +) -> Result { + let _ = (impulse_observed_mean, initial_time_dependent_observed_mean); + Err(PsychometricError::ImpulseObservedMeanIsNotInitialTimeDependentObservedMean) +} + +/// Refuse treating the impulse-carry observed mean as the +/// first-occasion time-dependent-predictor observed mean. +/// +/// Equation 5 of the Eq. 1–2 carried latent mean is +/// `τ + λ(μ_t + e^{a(t−u)} m x)`. Equation 5 of the Table 3 +/// first-occasion TD predictor is `τ + λ(μ_t + e^{a Δt} t0_m x0)`. +/// Those are not the same map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::ImpulseCarryObservedMeanIsNotInitialTimeDependentObservedMean`]. +pub fn refuse_impulse_carry_observed_mean_as_initial_time_dependent_observed_mean( + impulse_carry_observed_mean: f64, + initial_time_dependent_observed_mean: f64, +) -> Result { + let _ = ( + impulse_carry_observed_mean, + initial_time_dependent_observed_mean, + ); + Err(PsychometricError::ImpulseCarryObservedMeanIsNotInitialTimeDependentObservedMean) +} + +/// Refuse treating the first-occasion TI observed mean as the +/// first-occasion TD observed mean. +/// +/// Equation 5 of Table 3 `T0TIPREDEFFECT` is +/// `τ + λ(μ_t + e^{a Δt} t0_b z)`. Equation 5 of Table 3 +/// `T0TDPREDEFFECT` is `τ + λ(μ_t + e^{a Δt} t0_m x0)`. Those are +/// not the same map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::InitialTimeIndependentObservedMeanIsNotInitialTimeDependentObservedMean`]. +pub fn refuse_initial_time_independent_observed_mean_as_initial_time_dependent_observed_mean( + initial_time_independent_observed_mean: f64, + initial_time_dependent_observed_mean: f64, +) -> Result { + let _ = ( + initial_time_independent_observed_mean, + initial_time_dependent_observed_mean, + ); + Err(PsychometricError::InitialTimeIndependentObservedMeanIsNotInitialTimeDependentObservedMean) +} + +/// Exact scalar within-interval time-dependent impulse carry from +/// Driver Equations 1–2. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 1–3, pp. 4–5; Table 2, p. 12; +/// §7.2, pp. 20–21; JSS PDF re-opened 2026-08-20T10:33Z from +/// ) +/// write `dη = (A η + ξ + B z + M χ(t)) dt + G dW` with +/// `χ_i(t) = Σ_{u ∈ U_i} x_{i,u} δ(t − u)`. The Green-function +/// integral of that Dirac on `(t0, t)` is `e^{A(t−u)} M x`. The +/// printed Eq. 3 fourth summand is the contemporaneous jump `M x` +/// at `u = t`. This map is the strictly within-interval case +/// `t0 < u < t`: form `m x` first, then `e^{a(t−u)} m x`. A zero +/// drift is `m x` with no dissipation. Binary64 underflow of +/// `e^{a(t−u)}` to `+0` is vanishing dissipation back to the process +/// mean (§7.2) and is kept. A zero effect or zero predictor is +/// exactly zero even if the exponential overflows. When `e^{a(t−u)}` +/// overflows at a finite `a(t−u)`, rewrite as +/// `sign(m x) exp(ln|m x| + a(t−u))`. An impulse at `u = t` is the +/// contemporaneous map. An impulse at `u ≤ t0` is already in `η(t0)`. +/// The §7.2 level-change form is a different specification and is +/// not this map. This is not a Kalman filter and not ctsem +/// estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for any non-event +/// clock, [`PsychometricError::NonPositiveInterval`] when +/// `event_delta` or `elapsed_after_impulse` is not strictly positive +/// or the impulse is not strictly inside `(t0, t)`, and +/// [`PsychometricError::InvalidNumericInput`] when an input is +/// non-finite or `m x` or the carried product overflows. +pub fn recover_time_dependent_predictor_impulse_carry( + time_dependent_effect: f64, + time_dependent_predictor: f64, + log_rate: f64, + event_delta: f64, + elapsed_after_impulse: f64, + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + if !event_delta.is_finite() || event_delta <= 0.0 { + return Err(PsychometricError::NonPositiveInterval); + } + if !elapsed_after_impulse.is_finite() || elapsed_after_impulse <= 0.0 { + return Err(PsychometricError::NonPositiveInterval); + } + // I_{t0 < u < t}: t−u strictly less than t−t0, so u−t0 > 0. + if elapsed_after_impulse >= event_delta { + return Err(PsychometricError::NonPositiveInterval); + } + if !log_rate.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + let impulse = + recover_time_dependent_predictor_impulse(time_dependent_effect, time_dependent_predictor)?; + if impulse == 0.0 { + return Ok(0.0); + } + let drift_interval = log_rate * elapsed_after_impulse; + let auto_effect = drift_interval.exp(); + if auto_effect.is_finite() { + // +0 underflow is vanishing dissipation (§7.2). + return require_finite(auto_effect * impulse); + } + // Overflow of a finite `a(t−u)` is the log-space rewrite. + // A non-finite argument also fails closed through `require_finite`. + // e^{a(t−u)} m x = sign(m x) exp(ln|m x| + a(t−u)). + require_finite(impulse.signum() * (impulse.abs().ln() + drift_interval).exp()) +} + +/// Exact scalar evolved latent mean plus a within-interval impulse carry. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 1–3, p. 5; §7.2) write the +/// first two summands as the carried `T0MEANS` and `CINT` increment, +/// then add a Dirac impulse that occurred strictly inside `(t0, t)` +/// after it has dissipated by `e^{A(t−u)}`. Form `μ_t` first, then +/// add `e^{a(t−u)} m x`. A zero carry is exactly `μ_t`. A zero +/// evolved mean is exactly the carry. Adding the contemporaneous +/// `m x` is not this composition when `u ≠ t`. The level-change +/// form is not this map. +/// +/// # Errors +/// +/// Propagates [`recover_discrete_latent_mean`] and +/// [`recover_time_dependent_predictor_impulse_carry`], and returns +/// [`PsychometricError::InvalidNumericInput`] when the sum overflows. +#[allow(clippy::too_many_arguments)] +pub fn recover_discrete_latent_mean_with_impulse_carry( + initial_latent_mean: f64, + log_rate: f64, + continuous_intercept: f64, + time_dependent_effect: f64, + time_dependent_predictor: f64, + event_delta: f64, + elapsed_after_impulse: f64, + clock: LagClock, +) -> Result { + let evolved_latent_mean = recover_discrete_latent_mean( + initial_latent_mean, + log_rate, + continuous_intercept, + event_delta, + clock, + )?; + let impulse_carry = recover_time_dependent_predictor_impulse_carry( + time_dependent_effect, + time_dependent_predictor, + log_rate, + event_delta, + elapsed_after_impulse, + clock, + )?; + if impulse_carry == 0.0 { + return Ok(evolved_latent_mean); + } + if evolved_latent_mean == 0.0 { + return Ok(impulse_carry); + } + require_finite(evolved_latent_mean + impulse_carry) +} + +/// Exact scalar observed mean of a within-interval impulse carry. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 5, p. 5; Eq. 1–2, pp. 4–5; +/// Eq. 3 exponential map; Table 2, p. 12; §7.2, pp. 20–21; JSS PDF +/// re-opened 2026-08-20T05:12Z from +/// ) +/// write `y_i(t) = Γ + Λ η_i(t) + ζ_i(t)` with `ζ ~ N(0, Θ)` and +/// `Γ ~ N(τ, Ψ)`. The expected intercept is `τ`. The latent process +/// at `t` after a Dirac that occurred strictly inside `(t0, t)` is +/// `μ_t + e^{a(t−u)} m x`. The scalar composition is +/// `E(y_t) = τ + λ(μ_t + e^{a(t−u)} m x)`. Form the carried latent +/// mean first, then `τ + λ` of that mean. Table 2 names `τ` +/// `MANIFESTMEANS`. A zero loading is exactly `τ`. A zero +/// evolved-plus-carry latent mean is exactly `τ`. A zero intercept +/// is exactly `λ(μ_t + carry)`. The evolved observed mean +/// `τ + λ μ_t` is not this composition when the carry is nonzero. +/// The contemporaneous map `τ + λ(μ_t + m x)` is not this +/// composition when `u ≠ t`. `MANIFESTMEANS` is not `E(y_t)`. The +/// carried latent mean is not `E(y_t)`. The §7.2 level-change form +/// is a different specification and is not this map. This is not a +/// Kalman filter and not ctsem estimation. +/// +/// # Errors +/// +/// Propagates [`recover_discrete_latent_mean_with_impulse_carry`] and +/// [`recover_manifest_observed_mean`]. +#[allow(clippy::too_many_arguments)] +pub fn recover_discrete_observed_mean_with_impulse_carry( + loading: f64, + initial_latent_mean: f64, + log_rate: f64, + continuous_intercept: f64, + time_dependent_effect: f64, + time_dependent_predictor: f64, + manifest_mean: f64, + event_delta: f64, + elapsed_after_impulse: f64, + clock: LagClock, +) -> Result { + let carried_latent_mean = recover_discrete_latent_mean_with_impulse_carry( + initial_latent_mean, + log_rate, + continuous_intercept, + time_dependent_effect, + time_dependent_predictor, + event_delta, + elapsed_after_impulse, + clock, + )?; + recover_manifest_observed_mean(loading, carried_latent_mean, manifest_mean) +} + +/// Refuse treating the evolved observed mean as the impulse-carry +/// observed mean. +/// +/// Equation 5 of the Eq. 3 evolved mean is `τ + λ μ_t`. Equation 5 +/// of the Eq. 1–2 carried latent mean is +/// `τ + λ(μ_t + e^{a(t−u)} m x)`. Those are not the same map. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::EvolvedObservedMeanIsNotImpulseCarryObservedMean`]. +pub fn refuse_evolved_observed_mean_as_impulse_carry_observed_mean( + evolved_observed_mean: f64, + impulse_carry_observed_mean: f64, +) -> Result { + let _ = (evolved_observed_mean, impulse_carry_observed_mean); + Err(PsychometricError::EvolvedObservedMeanIsNotImpulseCarryObservedMean) +} + +/// Refuse treating the Eq. 1–2 impulse carry as the contemporaneous Dirac. +/// +/// The printed Eq. 3 fourth summand is `M x` at `u = t`. The +/// within-interval carry is `e^{A(t−u)} M x` for `t0 < u < t`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::TimeDependentImpulseCarryIsNotContemporaneousImpulse`]. +pub fn refuse_time_dependent_impulse_carry_as_contemporaneous_impulse( + time_dependent_impulse_carry: f64, + time_dependent_impulse: f64, +) -> Result { + let _ = (time_dependent_impulse_carry, time_dependent_impulse); + Err(PsychometricError::TimeDependentImpulseCarryIsNotContemporaneousImpulse) +} + +/// Refuse treating the Eq. 1–2 impulse carry as `CINT`. +/// +/// Table 2 names `M` `TDPREDEFFECT` and `κ` `CINT`. The dissipated +/// impulse is not the continuous intercept. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::TimeDependentImpulseCarryIsNotContinuousIntercept`]. +pub fn refuse_time_dependent_impulse_carry_as_continuous_intercept( + time_dependent_impulse_carry: f64, + continuous_intercept: f64, +) -> Result { + let _ = (time_dependent_impulse_carry, continuous_intercept); + Err(PsychometricError::TimeDependentImpulseCarryIsNotContinuousIntercept) +} + +/// Refuse treating the Eq. 1–2 impulse carry as `TIPREDEFFECT`. +/// +/// The second-summand map integrates a constant `B z` over the event +/// interval. The within-interval TDPRED carry dissipates a Dirac. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::TimeDependentImpulseCarryIsNotTimeIndependentEffect`]. +pub fn refuse_time_dependent_impulse_carry_as_time_independent_effect( + time_dependent_impulse_carry: f64, + time_independent_effect: f64, +) -> Result { + let _ = (time_dependent_impulse_carry, time_independent_effect); + Err(PsychometricError::TimeDependentImpulseCarryIsNotTimeIndependentEffect) +} + +/// Refuse treating the Eq. 1–2 impulse carry as Voelkle et al. +/// (2012, Eq. 14). +/// +/// Equation 14 is `a_{yx} Δt` for a piecewise-constant time-varying +/// predictor. The Dirac carry is `e^{A(t−u)} M x`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::TimeDependentImpulseCarryIsNotTimeVaryingDiscreteEffect`]. +pub fn refuse_time_dependent_impulse_carry_as_time_varying_discrete_effect( + time_dependent_impulse_carry: f64, + time_varying_discrete_effect: f64, +) -> Result { + let _ = (time_dependent_impulse_carry, time_varying_discrete_effect); + Err(PsychometricError::TimeDependentImpulseCarryIsNotTimeVaryingDiscreteEffect) +} + +/// Refuse treating Driver Table 2 `T0MEANS` as the evolved latent mean. +/// +/// Equation 3 maps `μ_t = exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ`. +/// `T0MEANS` is `μ_0`, not `μ_t`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::InitialLatentMeanIsNotEvolvedMean`]. +pub fn refuse_initial_latent_mean_as_evolved_mean( + initial_latent_mean: f64, + evolved_latent_mean: f64, +) -> Result { + let _ = (initial_latent_mean, evolved_latent_mean); + Err(PsychometricError::InitialLatentMeanIsNotEvolvedMean) +} + +/// Refuse treating Driver Table 2 `CINT` as the discrete mean increment. +/// +/// `κ` is the continuous intercept. Equation 3 maps it through +/// `A^{-1}[e^{A Δt} − I]`. `κ` is not that increment. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::ContinuousInterceptIsNotDiscreteMeanIncrement`]. +pub fn refuse_continuous_intercept_as_discrete_mean_increment( + continuous_intercept: f64, + discrete_mean_increment: f64, +) -> Result { + let _ = (continuous_intercept, discrete_mean_increment); + Err(PsychometricError::ContinuousInterceptIsNotDiscreteMeanIncrement) +} + +/// Refuse treating Driver Table 2 `CINT` as `T0MEANS`. +/// +/// Table 2 (p. 12) names `κ` `CINT` and the first-occasion latent +/// mean `T0MEANS`. `κ` is not `E(η_{i1})`. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::ContinuousInterceptIsNotInitialLatentMean`]. +pub fn refuse_continuous_intercept_as_initial_latent_mean( + continuous_intercept: f64, + initial_latent_mean: f64, +) -> Result { + let _ = (continuous_intercept, initial_latent_mean); + Err(PsychometricError::ContinuousInterceptIsNotInitialLatentMean) +} + +/// Refuse treating Driver Eq. 3 process noise as the unconditional variance. +/// +/// Driver, Oud, and Voelkle (2017, Eq. 3–4, pp. 4–5): +/// `Q_Δt = cov(η_ti | η_{t-1,i})` for the homogeneous process. That +/// residual variance is not `Var(η_ti)` when the previous state is +/// random. The JSS article has no numbered §2.2. +/// +/// # Errors +/// +/// Always returns [`PsychometricError::ProcessNoiseIsConditionalVariance`]. +pub fn refuse_process_noise_as_unconditional_variance( + process_noise: f64, + prior_variance: f64, +) -> Result { + let _ = (process_noise, prior_variance); + Err(PsychometricError::ProcessNoiseIsConditionalVariance) +} + +/// Refuse the difference quotient as a continuous-time rate. +/// +/// Voelkle et al. (2012) discourage `(x(t+Δt) − x(t)) / Δt` as the drift. +/// +/// # Errors +/// +/// Always returns [`PsychometricError::DifferenceQuotientForbidden`]. +pub fn refuse_difference_quotient_as_local_rate( + earlier: f64, + later: f64, + delta: f64, +) -> Result { + let _ = (earlier, later, delta); + Err(PsychometricError::DifferenceQuotientForbidden) +} + +/// Mean local log-rate across consecutive event-time pairs. +/// +/// Occasions are sorted by event time. Each pair uses the exact scalar map. +/// Equal or inverted times fail closed. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for a non-event clock, +/// [`PsychometricError::InvalidNumericInput`] for fewer than two occasions or +/// non-finite values, and [`PsychometricError::NonPositiveInterval`] when +/// consecutive times are not strictly increasing. +pub fn recover_event_series_mean_log_rate( + occasions: &[EventOccasion], + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + if occasions.len() < 2 { + return Err(PsychometricError::InvalidNumericInput); + } + let mut ordered = occasions.to_vec(); + ordered.sort_by(|left, right| { + left.event_time + .partial_cmp(&right.event_time) + .unwrap_or(std::cmp::Ordering::Equal) + }); + let mut rates = Vec::new(); + for window in ordered.windows(2) { + let earlier = window[0]; + let later = window[1]; + if !earlier.event_time.is_finite() + || !later.event_time.is_finite() + || !earlier.score.is_finite() + || !later.score.is_finite() + { + return Err(PsychometricError::InvalidNumericInput); + } + let delta = later.event_time - earlier.event_time; + let recovered = + recover_event_time_discrete_lag_and_log_rate(earlier.score, later.score, delta, clock)?; + rates.push(recovered.log_rate); + } + let count = rates.len() as f64; + require_finite(rates.iter().sum::() / count) +} + +/// Local log-rate of cluster-mean-centered residuals on event time. +/// +/// Stable between-cluster means are removed first (CWC). Consecutive +/// within-cluster residuals then use the exact scalar map. This is not DSEM. +/// +/// Curran and Bauer (2011, pp. 607–608) show that subtracting the observed +/// person-specific mean from a raw autoregressive series does **not** isolate +/// the lagged within-person effect. This helper therefore does not claim to +/// recover the raw-process drift `a` from CWC of a raw AR path. For that +/// estimand, supply already-centered lagged residuals to +/// [`recover_irregular_centered_residual_log_rate`]. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for a non-event clock, +/// [`PsychometricError::InvalidNumericInput`] for empty, singleton, or +/// non-finite rows, [`PsychometricError::InsufficientClusters`] when fewer +/// than two clusters appear, and interval/lag errors from the scalar map. +pub fn recover_within_residual_event_time_log_rate( + rows: &[ClusteredEventScore], + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + if rows.len() < 2 { + return Err(PsychometricError::InvalidNumericInput); + } + let mut groups: BTreeMap> = BTreeMap::new(); + for &row in rows { + if !row.event_time.is_finite() || !row.score.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + groups.entry(row.cluster_key).or_default().push(row); + } + if groups.len() < 2 { + return Err(PsychometricError::InsufficientClusters); + } + let mut pairs = Vec::new(); + for occasions in groups.values_mut() { + if occasions.len() < 2 { + continue; + } + let count = occasions.len() as f64; + let mean = occasions.iter().map(|row| row.score).sum::() / count; + occasions.sort_by(|left, right| { + left.event_time + .partial_cmp(&right.event_time) + .unwrap_or(std::cmp::Ordering::Equal) + }); + for window in occasions.windows(2) { + let earlier_resid = window[0].score - mean; + let later_resid = window[1].score - mean; + let delta = window[1].event_time - window[0].event_time; + if !delta.is_finite() || delta <= 0.0 { + return Err(PsychometricError::NonPositiveInterval); + } + if !(earlier_resid.is_finite() & later_resid.is_finite()) { + return Err(PsychometricError::InvalidNumericInput); + } + pairs.push((earlier_resid, later_resid, delta)); + } + } + fit_scalar_log_rate(&pairs) +} + +/// Mean exact scalar log-rate on already-centered residuals with irregular intervals. +/// +/// Each pair is `a = ln(later / earlier) / Δt` (Voelkle et al., 2012, Eq. 7). +/// The function does **not** center again. Curran and Bauer (2011, pp. 607–608) +/// reject person-mean subtraction on a raw autoregressive series as the +/// lagged within-person residual. Intervals may be irregular. This is not DSEM. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for a non-event clock, +/// [`PsychometricError::InvalidNumericInput`] for an empty series or a +/// non-finite / non-positive residual ratio, and +/// [`PsychometricError::NonPositiveInterval`] when any interval is not +/// strictly positive. +pub fn recover_irregular_centered_residual_log_rate( + pairs: &[LaggedWithinResidual], + clock: LagClock, +) -> Result { + if !clock.admits_structural_lag() { + return Err(PsychometricError::EventTimeRequired); + } + if pairs.is_empty() { + return Err(PsychometricError::InvalidNumericInput); + } + let mut sum = 0.0_f64; + for pair in pairs { + if !pair.earlier_residual.is_finite() + || !pair.later_residual.is_finite() + || !pair.event_delta.is_finite() + { + return Err(PsychometricError::InvalidNumericInput); + } + let recovered = recover_event_time_discrete_lag_and_log_rate( + pair.earlier_residual, + pair.later_residual, + pair.event_delta, + clock, + )?; + sum += recovered.log_rate; + } + let count = pairs.len() as f64; + require_finite(sum / count) +} + +/// Least-squares scalar log-rate for already-formed residual pairs. +/// +/// Pair-wise logs initialize Newton. This helper is crate-visible so overflow +/// and flat-derivative guards can be recovered in unit tests. It is not a +/// public DSEM estimator. +pub(crate) fn fit_scalar_log_rate(pairs: &[(f64, f64, f64)]) -> Result { + if pairs.is_empty() { + return Err(PsychometricError::InvalidNumericInput); + } + let mut start_sum = 0.0_f64; + let mut start_count = 0.0_f64; + for &(earlier, later, delta) in pairs { + if earlier == 0.0 { + continue; + } + let discrete_lag = later / earlier; + if !discrete_lag.is_finite() || discrete_lag <= 0.0 { + continue; + } + start_sum += discrete_lag.ln() / delta; + start_count += 1.0; + } + if start_count <= 0.0 { + return Err(PsychometricError::InvalidNumericInput); + } + let mut log_rate = start_sum / start_count; + for _ in 0..16 { + let mut score = 0.0_f64; + let mut derivative = 0.0_f64; + for &(earlier, later, delta) in pairs { + let mapped = (log_rate * delta).exp(); + if !mapped.is_finite() || mapped <= 0.0 { + return Err(PsychometricError::InvalidNumericInput); + } + let weight = delta * earlier; + score += weight * mapped * later - delta * mapped * mapped * earlier * earlier; + derivative += delta * weight * mapped * later + - 2.0 * delta * delta * mapped * mapped * earlier * earlier; + } + if !score.is_finite() || !derivative.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + if derivative.abs() <= 1e-18 { + break; + } + let next = log_rate - score / derivative; + if (next - log_rate).abs() < 1e-14 { + log_rate = next; + break; + } + log_rate = next; + } + require_finite(log_rate) +} + +#[cfg(test)] +mod tests { + use super::{ + ClusteredEventScore, EventOccasion, LagClock, LaggedWithinResidual, fit_scalar_log_rate, + map_discrete_lag_across_event_intervals, recover_asymptotic_continuous_intercept, + recover_asymptotic_time_independent_predictor_effect, + recover_asymptotic_time_independent_predictor_variance, + recover_discrete_constant_predictor_effect, recover_discrete_continuous_intercept_effect, + recover_discrete_lag_from_log_rate, recover_discrete_lag_one, + recover_discrete_lagged_latent_covariance, recover_discrete_latent_mean, + recover_discrete_latent_mean_with_extra_process, + recover_discrete_latent_mean_with_extra_process_after, + recover_discrete_latent_mean_with_impulse, recover_discrete_latent_mean_with_impulse_carry, + recover_discrete_latent_mean_with_initial_time_dependent_predictor, + recover_discrete_latent_mean_with_initial_time_independent_predictor, + recover_discrete_latent_mean_with_time_independent_predictor, + recover_discrete_latent_variance, recover_discrete_observed_mean, + recover_discrete_observed_mean_with_extra_process, + recover_discrete_observed_mean_with_extra_process_after, + recover_discrete_observed_mean_with_impulse, + recover_discrete_observed_mean_with_impulse_carry, + recover_discrete_observed_mean_with_initial_time_dependent_predictor, + recover_discrete_observed_mean_with_initial_time_independent_predictor, + recover_discrete_observed_mean_with_time_independent_predictor, + recover_discrete_process_noise, recover_discrete_time_independent_predictor_effect, + recover_discrete_time_varying_predictor_effect, recover_event_series_mean_log_rate, + recover_event_time_discrete_lag_and_log_rate, + recover_initial_time_dependent_predictor_carry, + recover_initial_time_dependent_predictor_effect, + recover_initial_time_independent_predictor_carry, + recover_initial_time_independent_predictor_effect, + recover_irregular_centered_residual_log_rate, recover_level_change_continuous_intercept, + recover_level_change_discrete_increment, recover_level_change_extra_process_contribution, + recover_level_change_extra_process_contribution_after, recover_local_log_rate, + recover_manifest_lagged_observed_covariance, recover_manifest_observed_mean, + recover_manifest_observed_variance, recover_manifest_trait_plus_state_observed_variance, + recover_stationary_initial_latent_mean, recover_stationary_initial_latent_variance, + recover_stationary_initial_observed_mean, recover_stationary_initial_observed_variance, + recover_stationary_lagged_latent_covariance, recover_stationary_lagged_observed_covariance, + recover_stationary_latent_variance, recover_stationary_later_latent_variance, + recover_stationary_later_observed_variance, recover_time_dependent_predictor_impulse, + recover_time_dependent_predictor_impulse_carry, recover_trait_plus_state_lagged_covariance, + recover_trait_plus_state_latent_variance, recover_within_residual_event_time_log_rate, + refuse_after_extra_process_contribution_as_observed_mean, + refuse_after_extra_process_latent_mean_as_observed_mean, + refuse_asymptotic_continuous_intercept_as_asymptotic_time_independent_effect, + refuse_asymptotic_continuous_intercept_as_continuous_intercept, + refuse_asymptotic_continuous_intercept_as_discrete_increment, + refuse_asymptotic_continuous_intercept_as_initial_latent_mean, + refuse_asymptotic_continuous_intercept_observed_mean_as_stationary_initial_observed_mean, + refuse_asymptotic_time_independent_effect_as_coefficient, + refuse_asymptotic_time_independent_effect_as_continuous_intercept, + refuse_asymptotic_time_independent_effect_as_discrete_effect, + refuse_asymptotic_time_independent_effect_as_time_dependent_impulse, + refuse_asymptotic_time_independent_variance_as_asymptotic_effect, + refuse_asymptotic_time_independent_variance_as_stationary_within_subject, + refuse_asymptotic_time_independent_variance_as_trait_variance, + refuse_continuous_intercept_as_discrete_mean_increment, + refuse_continuous_intercept_as_initial_latent_mean, + refuse_continuous_intercept_as_manifest_means, refuse_difference_quotient_as_local_rate, + refuse_evolved_observed_mean_as_after_extra_process_observed_mean, + refuse_evolved_observed_mean_as_extra_process_observed_mean, + refuse_evolved_observed_mean_as_impulse_carry_observed_mean, + refuse_evolved_observed_mean_as_impulse_observed_mean, + refuse_evolved_observed_mean_as_initial_time_dependent_observed_mean, + refuse_evolved_observed_mean_as_initial_time_independent_observed_mean, + refuse_evolved_observed_mean_as_stationary_initial_observed_mean, + refuse_evolved_observed_mean_as_time_independent_observed_mean, + refuse_evolved_observed_variance_as_stationary_initial_observed_variance, + refuse_extra_process_contribution_as_observed_mean, + refuse_extra_process_latent_mean_as_observed_mean, + refuse_extra_process_observed_mean_as_after_extra_process_observed_mean, + refuse_finite_interval_process_noise_as_stationary_variance, + refuse_impulse_carry_observed_mean_as_after_extra_process_observed_mean, + refuse_impulse_carry_observed_mean_as_initial_time_dependent_observed_mean, + refuse_impulse_carry_observed_mean_as_initial_time_independent_observed_mean, + refuse_impulse_carry_observed_mean_as_time_independent_observed_mean, + refuse_impulse_observed_mean_as_extra_process_observed_mean, + refuse_impulse_observed_mean_as_impulse_carry_observed_mean, + refuse_impulse_observed_mean_as_initial_time_dependent_observed_mean, + refuse_impulse_observed_mean_as_initial_time_independent_observed_mean, + refuse_impulse_observed_mean_as_time_independent_observed_mean, + refuse_initial_latent_mean_as_evolved_mean, + refuse_initial_observed_mean_as_evolved_observed_mean, + refuse_initial_observed_mean_as_stationary_initial_observed_mean, + refuse_initial_observed_variance_as_stationary_initial_observed_variance, + refuse_initial_time_dependent_carry_as_impulse_carry, + refuse_initial_time_dependent_carry_as_initial_effect, + refuse_initial_time_dependent_coefficient_as_initial_effect, + refuse_initial_time_dependent_effect_as_contemporaneous_impulse, + refuse_initial_time_dependent_effect_as_continuous_intercept, + refuse_initial_time_dependent_effect_as_initial_time_independent_effect, + refuse_initial_time_dependent_effect_as_process_increment, + refuse_initial_time_independent_carry_as_initial_effect, + refuse_initial_time_independent_coefficient_as_initial_effect, + refuse_initial_time_independent_effect_as_continuous_intercept, + refuse_initial_time_independent_effect_as_process_increment, + refuse_initial_time_independent_effect_as_time_dependent_impulse, + refuse_initial_time_independent_observed_mean_as_initial_time_dependent_observed_mean, + refuse_latent_lagged_covariance_as_observed_covariance, + refuse_latent_mean_as_observed_mean, refuse_latent_variance_as_observed_variance, + refuse_level_change_extra_process_as_impulse, + refuse_level_change_extra_process_as_increment, + refuse_level_change_extra_process_as_intercept, refuse_level_change_increment_as_impulse, + refuse_level_change_increment_as_intercept, + refuse_level_change_increment_as_process_increment, + refuse_level_change_intercept_as_free_continuous_intercept, + refuse_level_change_intercept_as_impulse, + refuse_level_change_intercept_as_process_increment, refuse_manifest_means_as_observed_mean, + refuse_manifest_trait_variance_as_measurement_error, + refuse_measurement_error_as_lagged_observed_covariance, + refuse_measurement_error_as_observed_variance, + refuse_measurement_error_as_stationary_lagged_observed_covariance, + refuse_measurement_error_as_stationary_later_observed_variance, + refuse_pooled_discrete_lag_across_unequal_intervals, + refuse_process_noise_as_unconditional_variance, + refuse_stationary_initial_latent_mean_as_asymptotic_continuous_intercept, + refuse_stationary_initial_latent_mean_as_asymptotic_time_independent_effect, + refuse_stationary_initial_latent_mean_as_discrete_mean, + refuse_stationary_initial_latent_mean_as_initial_latent_mean, + refuse_stationary_initial_latent_mean_as_observed_mean, + refuse_stationary_initial_latent_variance_as_asymptotic_time_independent_variance, + refuse_stationary_initial_latent_variance_as_discrete_variance, + refuse_stationary_initial_latent_variance_as_initial_latent_variance, + refuse_stationary_initial_latent_variance_as_observed_variance, + refuse_stationary_initial_latent_variance_as_stationary_within_subject, + refuse_stationary_initial_latent_variance_as_trait_variance, + refuse_stationary_initial_observed_mean_as_manifest_means, + refuse_stationary_initial_observed_variance_as_measurement_error, + refuse_stationary_initial_observed_variance_as_stationary_lagged_observed_covariance, + refuse_stationary_lagged_latent_covariance_as_decayed_stationary_variance, + refuse_stationary_lagged_latent_covariance_as_observed_covariance, + refuse_stationary_lagged_latent_covariance_as_stationary_initial_latent_variance, + refuse_stationary_lagged_observed_covariance_as_stationary_later_observed_variance, + refuse_stationary_later_latent_variance_as_discrete_variance, + refuse_stationary_later_latent_variance_as_lagged_covariance, + refuse_stationary_later_latent_variance_as_observed_variance, + refuse_stationary_later_latent_variance_as_process_noise, + refuse_stationary_within_subject_observed_variance_as_stationary_initial_observed_variance, + refuse_time_dependent_impulse_as_continuous_intercept, + refuse_time_dependent_impulse_as_time_independent_effect, + refuse_time_dependent_impulse_as_time_varying_discrete_effect, + refuse_time_dependent_impulse_carry_as_contemporaneous_impulse, + refuse_time_dependent_impulse_carry_as_continuous_intercept, + refuse_time_dependent_impulse_carry_as_time_independent_effect, + refuse_time_dependent_impulse_carry_as_time_varying_discrete_effect, + refuse_time_independent_coefficient_as_discrete_effect, + refuse_time_independent_effect_as_continuous_intercept, + refuse_time_independent_effect_as_time_dependent_impulse, + refuse_time_independent_effect_as_time_varying_discrete_effect, + refuse_time_independent_observed_mean_as_initial_time_dependent_observed_mean, + refuse_time_independent_observed_mean_as_initial_time_independent_observed_mean, + refuse_trait_plus_state_lagged_covariance_as_stationary_lagged_latent_covariance, + refuse_trait_variance_as_process_noise, refuse_trait_variance_as_stationary_within_subject, + refuse_unmatched_time_varying_predictor_interval, + }; + use crate::error::PsychometricError; + + #[test] + fn exact_scalar_map_inverts_exponential_drift() { + let drift = -0.5_f64; + let delta = 2.0_f64; + let earlier = 1.5_f64; + let later = earlier * (drift * delta).exp(); + let recovered = recover_event_time_discrete_lag_and_log_rate( + earlier, + later, + delta, + LagClock::EventTime, + ) + .expect("exact"); + assert!((recovered.log_rate - drift).abs() < 1e-12); + assert!((recovered.discrete_lag - (drift * delta).exp()).abs() < 1e-12); + assert!((recovered.event_delta - delta).abs() < 1e-15); + } + + #[test] + fn forward_map_inverts_log_rate_and_remaps_unequal_intervals() { + let drift = -0.4_f64; + let source_delta = 1.0_f64; + let reference_delta = 2.0_f64; + let source_lag = + recover_discrete_lag_from_log_rate(drift, source_delta, LagClock::EventTime) + .expect("forward"); + assert!((source_lag - (drift * source_delta).exp()).abs() < 1e-12); + let same = map_discrete_lag_across_event_intervals( + source_lag, + source_delta, + source_delta, + LagClock::EventTime, + ) + .expect("same interval"); + assert!((same - source_lag).abs() < 1e-12); + let remapped = map_discrete_lag_across_event_intervals( + source_lag, + source_delta, + reference_delta, + LagClock::EventTime, + ) + .expect("remap"); + assert!((remapped - (drift * reference_delta).exp()).abs() < 1e-12); + // Voelkle manuscript p. 2, 33: φ(1) ≠ φ(2) even for one process. + assert!((source_lag - remapped).abs() > 1e-9); + assert_eq!( + refuse_pooled_discrete_lag_across_unequal_intervals(source_delta, reference_delta), + Err(PsychometricError::UnequalIntervalPoolingForbidden) + ); + assert_eq!( + refuse_pooled_discrete_lag_across_unequal_intervals(source_delta, source_delta), + Err(PsychometricError::UnequalIntervalPoolingForbidden) + ); + } + + #[test] + fn forward_map_and_interval_remap_fail_closed() { + assert_eq!( + recover_discrete_lag_from_log_rate(-0.2, 1.0, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_lag_from_log_rate(-0.2, 0.0, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_lag_from_log_rate(-0.2, -1.0, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_lag_from_log_rate(-0.2, f64::NAN, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_lag_from_log_rate(f64::NAN, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_lag_from_log_rate(800.0, 10.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_lag_from_log_rate(-800.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_lag_from_log_rate(-1.0, 800.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + let source_lag = + recover_discrete_lag_from_log_rate(-0.7, 1.0, LagClock::EventTime).expect("source φ"); + assert!(source_lag > 0.0); + assert_eq!( + map_discrete_lag_across_event_intervals(source_lag, 1.0, 2000.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + map_discrete_lag_across_event_intervals(0.5, 1.0, 2.0, LagClock::AssertionTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + map_discrete_lag_across_event_intervals(0.5, 0.0, 2.0, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + map_discrete_lag_across_event_intervals(0.5, 1.0, 0.0, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + map_discrete_lag_across_event_intervals(-0.2, 1.0, 2.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn constant_predictor_discrete_effect_recovers_equation_twelve() { + let outcome_on_predictor = 0.2_f64; + let predictor_log_rate = -0.5_f64; + let delta = 2.0_f64; + let recovered = recover_discrete_constant_predictor_effect( + outcome_on_predictor, + predictor_log_rate, + delta, + LagClock::EventTime, + ) + .expect("eq 12"); + let expected = + (outcome_on_predictor / predictor_log_rate) * (predictor_log_rate * delta).exp_m1(); + assert!((recovered - expected).abs() < 1e-15); + let first_order = outcome_on_predictor * delta; + assert!((recovered - first_order).abs() > 1e-3); + assert_eq!( + recover_discrete_constant_predictor_effect( + outcome_on_predictor, + predictor_log_rate, + delta, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_constant_predictor_effect( + outcome_on_predictor, + predictor_log_rate, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_constant_predictor_effect( + outcome_on_predictor, + predictor_log_rate, + -1.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_constant_predictor_effect( + outcome_on_predictor, + predictor_log_rate, + f64::NAN, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_constant_predictor_effect( + f64::NAN, + predictor_log_rate, + delta, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_constant_predictor_effect( + outcome_on_predictor, + 0.0, + delta, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_constant_predictor_effect( + outcome_on_predictor, + f64::NAN, + delta, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_constant_predictor_effect(1e300, 1e-300, 1e300, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + let underflowed_argument = + recover_discrete_constant_predictor_effect(1e308, 1e-308, 1e-308, LagClock::EventTime) + .expect("eq 12 limit"); + assert!((underflowed_argument - 1.0).abs() < 1e-15); + let tiny_nonzero = + recover_discrete_constant_predictor_effect(1e308, 1e-154, 1e-154, LagClock::EventTime) + .expect("eq 12 scaled"); + assert!(tiny_nonzero.is_finite()); + assert!((tiny_nonzero - 1e154).abs() / 1e154 < 1e-12); + // a_yx Δt overflows; Eq. 12 remains finite (Voelkle 2012, Eq. 12). + let product_overflow = + recover_discrete_constant_predictor_effect(1e308, -100.0, 10.0, LagClock::EventTime) + .expect("eq 12 finite after a_yx Δt overflow"); + let product_overflow_expected = (1e308 / -100.0) * (-100.0_f64 * 10.0).exp_m1(); + assert!((product_overflow - product_overflow_expected).abs() / 1e306 < 1e-12); + assert!(product_overflow.is_finite()); + assert!(!(1e308_f64 * 10.0).is_finite()); + } + + #[test] + fn constant_predictor_negative_overflow_recovers_equilibrium_increment() { + // z → -∞: expm1(z)/z * Δt is +0; Eq. 12 → -a_yx/a_xx (Voelkle + // 2012, Introducing Intercepts equilibrium increment). + let increment_argument = -1e308_f64 * 2.0; + assert!(increment_argument.is_infinite()); + assert!(increment_argument.is_sign_negative()); + let lost_scale = increment_argument.exp_m1() / increment_argument * 2.0; + assert_eq!(lost_scale.to_bits(), 0.0_f64.to_bits()); + let negative_overflow = + recover_discrete_constant_predictor_effect(1.0, -1e308, 2.0, LagClock::EventTime) + .expect("eq 12 equilibrium increment"); + let negative_overflow_expected = -(1.0 / -1e308); + assert!((negative_overflow - negative_overflow_expected).abs() / 1e-308 < 1e-12); + assert!(negative_overflow > 0.0); + assert!(negative_overflow.is_finite()); + } + + #[test] + fn constant_predictor_expm1_overflow_recovers_finite_equation_twelve() { + // expm1(800) is +∞; (1e-308/800)(exp(800)−1) is finite. + assert!(!800.0_f64.exp_m1().is_finite()); + assert!(!(1e-308_f64 * (800.0_f64.exp_m1() / 800.0)).is_finite()); + let recovered = + recover_discrete_constant_predictor_effect(1e-308, 800.0, 1.0, LagClock::EventTime) + .expect("eq 12 log-space"); + let expected = (1e-308_f64.ln() + 800.0 - 800.0_f64.ln()).exp() - 1e-308 / 800.0; + assert!((recovered - expected).abs() / expected < 1e-12); + assert!(recovered.is_finite()); + assert!(recovered > 0.0); + let negative = + recover_discrete_constant_predictor_effect(-1e-308, 800.0, 1.0, LagClock::EventTime) + .expect("eq 12 signed log-space"); + assert!((negative + expected).abs() / expected < 1e-12); + assert_eq!( + recover_discrete_constant_predictor_effect(0.0, 800.0, 1.0, LagClock::EventTime), + Ok(0.0) + ); + assert_eq!( + recover_discrete_constant_predictor_effect(0.0, 1e308, 2.0, LagClock::EventTime), + Ok(0.0) + ); + assert_eq!( + recover_discrete_constant_predictor_effect(1.0, 800.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_constant_predictor_effect(1.0, 1e308, 2.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + // a_yx/a_xx overflows; the Eq. 12 rewrite term is not a binary64 number. + assert!(!800.0_f64.exp_m1().is_finite()); + assert!(!(1e308_f64 / 1e-10).is_finite()); + assert_eq!( + recover_discrete_constant_predictor_effect(1e308, 1e-10, 8e12, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn time_varying_predictor_discrete_effect_recovers_equation_fourteen() { + let outcome_on_predictor = 0.2_f64; + let delta = 2.0_f64; + let recovered = recover_discrete_time_varying_predictor_effect( + outcome_on_predictor, + delta, + delta, + delta, + LagClock::EventTime, + ) + .expect("eq 14"); + assert!((recovered - outcome_on_predictor * delta).abs() < 1e-15); + let constant = recover_discrete_constant_predictor_effect( + outcome_on_predictor, + -0.5, + delta, + LagClock::EventTime, + ) + .expect("eq 12"); + // Voelkle 2012, p. 21: Eq. 14 is not Eq. 12. + assert!((recovered - constant).abs() > 1e-3); + assert_eq!( + recover_discrete_time_varying_predictor_effect( + 0.0, + delta, + delta, + delta, + LagClock::EventTime + ), + Ok(0.0) + ); + } + + #[test] + fn time_varying_predictor_unmatched_and_invalid_inputs_fail_closed() { + let outcome_on_predictor = 0.2_f64; + let delta = 2.0_f64; + assert_eq!( + recover_discrete_time_varying_predictor_effect( + outcome_on_predictor, + delta, + delta, + delta, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_time_varying_predictor_effect( + outcome_on_predictor, + 0.0, + 0.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_time_varying_predictor_effect( + outcome_on_predictor, + -1.0, + 1.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_time_varying_predictor_effect( + outcome_on_predictor, + 1.0, + f64::NAN, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_time_varying_predictor_effect( + outcome_on_predictor, + 1.0, + 1.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_time_varying_predictor_effect( + outcome_on_predictor, + 1.0, + 2.0, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::UnmatchedTimeVaryingInterval) + ); + assert_eq!( + recover_discrete_time_varying_predictor_effect( + outcome_on_predictor, + 2.0, + 2.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::UnmatchedTimeVaryingInterval) + ); + assert_eq!( + recover_discrete_time_varying_predictor_effect( + f64::NAN, + delta, + delta, + delta, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_time_varying_predictor_effect( + 1e308, + 10.0, + 10.0, + 10.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + refuse_unmatched_time_varying_predictor_interval(1.0, 2.0), + Err(PsychometricError::UnmatchedTimeVaryingInterval) + ); + assert_eq!( + refuse_unmatched_time_varying_predictor_interval(1.0, 1.0), + Err(PsychometricError::UnmatchedTimeVaryingInterval) + ); + } + + #[test] + fn discrete_process_noise_recovers_driver_equation_three() { + let diffusion = 0.4_f64; + let drift = -0.5_f64; + let delta = 1.0_f64; + let recovered = + recover_discrete_process_noise(diffusion, drift, delta, LagClock::EventTime) + .expect("q_dt"); + let expected = diffusion * ((2.0 * drift * delta).exp() - 1.0) / (2.0 * drift); + assert!((recovered - expected).abs() < 1e-15); + // a = 0 is the integral of a constant diffusion: q Δt. + assert_eq!( + recover_discrete_process_noise(diffusion, 0.0, 2.5, LagClock::EventTime), + Ok(diffusion * 2.5) + ); + // Binary64 underflow of 2 a Δt recovers the same limit. + let underflowed = recover_discrete_process_noise(1.0, 1e-308, 1e-308, LagClock::EventTime) + .expect("z underflow"); + assert!((underflowed - 1e-308).abs() < 1e-320); + // z → −∞ keeps the equilibrium variance −q / (2 a). + let equilibrium = + recover_discrete_process_noise(0.4, -1e300, 2.0, LagClock::EventTime).expect("eq var"); + assert!((equilibrium - (0.4 / (2.0 * 1e300))).abs() < 1e-315); + // Finite z, overflowed expm1: log-space rewrite stays finite. + let overflowed = recover_discrete_process_noise(1e-308, 400.0, 1.0, LagClock::EventTime) + .expect("expm1 overflow"); + let rewrite_scale = 1e-308 / 800.0; + let rewrite_log = (1e-308_f64).ln() + 800.0 - 800.0_f64.ln(); + let rewrite = rewrite_log.exp() - rewrite_scale; + assert!((overflowed - rewrite).abs() / rewrite.abs() < 1e-12); + assert_eq!( + recover_discrete_process_noise(0.0, 800.0, 1.0, LagClock::EventTime), + Ok(0.0) + ); + assert_eq!( + recover_discrete_process_noise(0.0, 1e308, 2.0, LagClock::EventTime), + Ok(0.0) + ); + // Forming 2 a first overflows; z = 2 (a Δt) stays finite. + let twice_rate_overflow = + recover_discrete_process_noise(1.0, 1e308, 1e-308, LagClock::EventTime) + .expect("2a overflow"); + let expected_twice_rate = 0.5 * 2.0_f64.exp_m1() / 1e308; + assert!((twice_rate_overflow - expected_twice_rate).abs() / expected_twice_rate < 1e-12); + // 2 a overflows to −∞; expm1(−∞) = −1 keeps −0.5 q / a. + let overflowed_equilibrium = + recover_discrete_process_noise(1e308, -1e308, 2.0, LagClock::EventTime) + .expect("2a eq var"); + assert!((overflowed_equilibrium - 0.5).abs() < 1e-15); + } + + #[test] + fn discrete_process_noise_invalid_inputs_fail_closed() { + assert_eq!( + recover_discrete_process_noise(0.4, -0.5, 1.0, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_process_noise(0.4, -0.5, 0.0, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_process_noise(0.4, -0.5, -1.0, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_process_noise(0.4, -0.5, f64::NAN, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_process_noise(-0.1, -0.5, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_process_noise(f64::NAN, -0.5, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_process_noise(0.4, f64::NAN, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_process_noise(1.0, 800.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_process_noise(1.0, 1e308, 2.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + // Finite z, overflowed expm1, overflowing 0.5 q / a. + // q (e^{2 a Δt} − 1) / (2 a) is then non-finite (Driver Eq. 3). + assert_eq!( + recover_discrete_process_noise(1e308, 0.1, 4000.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn lagged_covariance_and_latent_variance_follow_driver_equations_three_and_four() { + let prior = 2.0_f64; + let diffusion = 0.4_f64; + let drift = -0.5_f64; + let delta = 1.0_f64; + let lagged = + recover_discrete_lagged_latent_covariance(prior, drift, delta, LagClock::EventTime) + .expect("lagged cov"); + let expected_lagged = (drift * delta).exp() * prior; + assert!((lagged - expected_lagged).abs() < 1e-15); + let process_noise = + recover_discrete_process_noise(diffusion, drift, delta, LagClock::EventTime) + .expect("q_dt"); + let latent = + recover_discrete_latent_variance(prior, diffusion, drift, delta, LagClock::EventTime) + .expect("var"); + let expected_var = (2.0 * drift * delta).exp() * prior + process_noise; + assert!((latent - expected_var).abs() < 1e-15); + assert!((latent - process_noise).abs() > 1e-3); + assert_eq!( + refuse_process_noise_as_unconditional_variance(process_noise, prior), + Err(PsychometricError::ProcessNoiseIsConditionalVariance) + ); + assert_eq!( + recover_discrete_lagged_latent_covariance(0.0, 800.0, 1.0, LagClock::EventTime), + Ok(0.0) + ); + let underflowed_lagged = + recover_discrete_lagged_latent_covariance(2.0, -1e308, 2.0, LagClock::EventTime) + .expect("underflow lagged"); + assert_eq!(underflowed_lagged.to_bits(), 0.0_f64.to_bits()); + let rewritten = + recover_discrete_lagged_latent_covariance(1e-308, 800.0, 1.0, LagClock::EventTime) + .expect("rewrite lagged"); + let expected_rewrite = (1e-308_f64.ln() + 800.0).exp(); + assert!((rewritten - expected_rewrite).abs() / expected_rewrite < 1e-12); + let zero_prior = + recover_discrete_latent_variance(0.0, diffusion, drift, delta, LagClock::EventTime) + .expect("zero prior"); + assert!((zero_prior - process_noise).abs() < 1e-15); + let drifted_zero = + recover_discrete_latent_variance(2.0, diffusion, 0.0, 2.5, LagClock::EventTime) + .expect("a=0"); + assert!((drifted_zero - (2.0 + diffusion * 2.5)).abs() < 1e-15); + let underflowed_var = + recover_discrete_latent_variance(2.0, 1.0, 1e-308, 1e-308, LagClock::EventTime) + .expect("z underflow"); + assert!((underflowed_var - (2.0 + 1.0 * 1e-308)).abs() < 1e-15); + let vanished = + recover_discrete_latent_variance(2.0, 1e308, -1e308, 2.0, LagClock::EventTime) + .expect("phi_sq underflow"); + assert!((vanished - 0.5).abs() < 1e-15); + let rewritten_var = + recover_discrete_latent_variance(1e-308, 1e-308, 400.0, 1.0, LagClock::EventTime) + .expect("rewrite var"); + assert!(rewritten_var.is_finite()); + assert!(rewritten_var > 0.0); + } + + #[test] + fn lagged_covariance_and_latent_variance_overflow_paths_fail_closed() { + assert_eq!( + recover_discrete_lagged_latent_covariance(1e308, 800.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_lagged_latent_covariance(2.0, 1e308, 2.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_variance(1e308, 1e-308, 400.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_variance(2.0, 1.0, 1e308, 2.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + // Zero diffusion is exactly Q_Δt = 0 (Driver Eq. 3). That skip + // does not license exp(2 a Δt) p when 2 (a Δt) overflows to +∞. + assert_eq!( + recover_discrete_latent_variance(2.0, 0.0, 1e308, 2.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_lagged_latent_covariance(1e308, 700.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_variance(1e308, 1e-308, 350.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_variance(1e308, 1e308, 0.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + let carried = (-90.622_f64).exp(); + let diffusion_sum = (-83.938_f64).exp(); + assert_eq!( + recover_discrete_latent_variance( + carried, + diffusion_sum, + 400.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_lagged_latent_covariance(-0.1, -0.5, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_lagged_latent_covariance(f64::NAN, -0.5, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_lagged_latent_covariance(2.0, f64::NAN, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_lagged_latent_covariance(2.0, -0.5, 0.0, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_lagged_latent_covariance(2.0, -0.5, -1.0, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_lagged_latent_covariance(2.0, -0.5, f64::NAN, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_lagged_latent_covariance(2.0, -0.5, 1.0, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_latent_variance(-0.1, 0.4, -0.5, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_variance(f64::NAN, 0.4, -0.5, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_variance(2.0, 0.4, -0.5, 1.0, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + } + + #[test] + fn stationary_variance_recovers_driver_equation_four_asymptote() { + let diffusion = 0.4_f64; + let drift = -0.5_f64; + let recovered = recover_stationary_latent_variance(diffusion, drift, LagClock::EventTime) + .expect("asym"); + let expected = (diffusion / drift) * -0.5; + assert!((recovered - expected).abs() < 1e-15); + assert!((recovered - 0.4).abs() < 1e-15); + // Starting from p_∞, Var(η_t) is invariant across finite Δt. + for delta in [0.5_f64, 1.0, 2.0, 10.0] { + let evolved = recover_discrete_latent_variance( + recovered, + diffusion, + drift, + delta, + LagClock::EventTime, + ) + .expect("invariant"); + assert!( + (evolved - recovered).abs() < 1e-12, + "stationary variance must be invariant at Δt={delta}" + ); + } + let finite_noise = + recover_discrete_process_noise(diffusion, drift, 1.0, LagClock::EventTime) + .expect("finite q_dt"); + assert!((finite_noise - recovered).abs() > 1e-3); + assert_eq!( + refuse_finite_interval_process_noise_as_stationary_variance(finite_noise, 1.0), + Err(PsychometricError::FiniteIntervalProcessNoiseIsNotStationary) + ); + assert_eq!( + refuse_finite_interval_process_noise_as_stationary_variance(recovered, 1.0), + Err(PsychometricError::FiniteIntervalProcessNoiseIsNotStationary) + ); + assert_eq!( + recover_stationary_latent_variance(0.0, drift, LagClock::EventTime), + Ok(0.0) + ); + // Do not form 2 a first: 2*(-1e308) overflows; (q/a)*-0.5 is 0.5. + let twice_rate_overflow = + recover_stationary_latent_variance(1e308, -1e308, LagClock::EventTime) + .expect("2a overflow"); + assert!((twice_rate_overflow - 0.5).abs() < 1e-15); + assert!(!(2.0 * -1e308_f64).is_finite()); + let lost = -1e308_f64 / (2.0 * -1e308_f64); + assert!(lost.abs() < 1e-15); + // Do not form 0.5 q first: 0.5 * from_bits(1) underflows. + let min_subnormal = f64::from_bits(1); + assert!((0.5 * min_subnormal).abs() < 1e-300); + assert!((-0.5 * min_subnormal / -min_subnormal).abs() < 1e-300); + let subnormal_ratio = + recover_stationary_latent_variance(min_subnormal, -min_subnormal, LagClock::EventTime) + .expect("subnormal ratio"); + assert!((subnormal_ratio - 0.5).abs() < 1e-15); + assert!(((min_subnormal / -min_subnormal) * -0.5 - 0.5).abs() < 1e-15); + // Do not form q/a first: MAX/-0.75 overflows; MAX/(2*0.75) is finite. + assert!(!(f64::MAX / -0.75_f64).is_finite()); + assert!(!((f64::MAX / -0.75_f64) * -0.5).is_finite()); + let twice = -0.75_f64 * 2.0; + assert!(twice.is_finite()); + let expected_max = f64::MAX / -twice; + assert!(expected_max.is_finite()); + assert_eq!(expected_max.to_bits(), (f64::MAX / 1.5).to_bits()); + let quotient_overflow = + recover_stationary_latent_variance(f64::MAX, -0.75, LagClock::EventTime) + .expect("q/a overflow"); + assert_eq!(quotient_overflow.to_bits(), expected_max.to_bits()); + } + + #[test] + fn stationary_variance_unstable_and_invalid_inputs_fail_closed() { + assert_eq!( + recover_stationary_latent_variance(0.4, -0.5, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_stationary_latent_variance(0.4, 0.0, LagClock::EventTime), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_stationary_latent_variance(0.4, 0.5, LagClock::EventTime), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_stationary_latent_variance(0.0, 0.0, LagClock::EventTime), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_stationary_latent_variance(-0.1, -0.5, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_stationary_latent_variance(f64::NAN, -0.5, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_stationary_latent_variance(0.4, f64::NAN, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + // The Lyapunov solution overflows when |q| >> |a|. + assert!(!((1e308_f64 / -1e-10_f64) * -0.5).is_finite()); + assert!(!(1e308_f64 / (2.0 * 1e-10_f64)).is_finite()); + assert_eq!( + recover_stationary_latent_variance(1e308, -1e-10, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn trait_plus_state_recovers_driver_section_four_point_three() { + let trait_variance = 1.5_f64; + let diffusion = 0.4_f64; + let drift = -0.5_f64; + let delta = 1.0_f64; + let state = recover_stationary_latent_variance(diffusion, drift, LagClock::EventTime) + .expect("state"); + let total = recover_trait_plus_state_latent_variance(trait_variance, state).expect("sum"); + assert!((total - (trait_variance + state)).abs() < 1e-15); + let lagged = recover_trait_plus_state_lagged_covariance( + trait_variance, + state, + drift, + delta, + LagClock::EventTime, + ) + .expect("lagged"); + let state_lagged = + recover_discrete_lagged_latent_covariance(state, drift, delta, LagClock::EventTime) + .expect("state lagged"); + assert!((lagged - (trait_variance + state_lagged)).abs() < 1e-15); + // Evolving the summed variance as if it were all state is not + // the trait-plus-state map (Driver §4.3; Hamaker et al., 2015). + let evolved_as_state = + recover_discrete_latent_variance(total, diffusion, drift, delta, LagClock::EventTime) + .expect("wrong"); + let evolved_state = + recover_discrete_latent_variance(state, diffusion, drift, delta, LagClock::EventTime) + .expect("state evolved"); + let evolved_right = + recover_trait_plus_state_latent_variance(trait_variance, evolved_state).expect("right"); + assert!((evolved_right - total).abs() < 1e-12); + assert!((evolved_as_state - evolved_right).abs() > 1e-3); + assert_eq!( + recover_trait_plus_state_latent_variance(0.0, state), + Ok(state) + ); + assert_eq!( + recover_trait_plus_state_latent_variance(trait_variance, 0.0), + Ok(trait_variance) + ); + assert_eq!( + recover_trait_plus_state_lagged_covariance( + 0.0, + state, + drift, + delta, + LagClock::EventTime + ), + Ok(state_lagged) + ); + assert_eq!( + recover_trait_plus_state_lagged_covariance( + trait_variance, + 0.0, + drift, + delta, + LagClock::EventTime + ), + Ok(trait_variance) + ); + let process_noise = + recover_discrete_process_noise(diffusion, drift, delta, LagClock::EventTime) + .expect("q_dt"); + assert_eq!( + refuse_trait_variance_as_process_noise(trait_variance, process_noise), + Err(PsychometricError::TraitVarianceIsNotProcessNoise) + ); + assert_eq!( + refuse_trait_variance_as_stationary_within_subject(trait_variance, state), + Err(PsychometricError::TraitVarianceIsNotStationaryWithinSubject) + ); + } + + #[test] + fn trait_plus_state_invalid_inputs_fail_closed() { + assert_eq!( + recover_trait_plus_state_latent_variance(-0.1, 0.4), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_trait_plus_state_latent_variance(0.4, -0.1), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_trait_plus_state_latent_variance(f64::NAN, 0.4), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_trait_plus_state_latent_variance(0.4, f64::NAN), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_trait_plus_state_latent_variance(1e308, 1e308), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_trait_plus_state_lagged_covariance(-0.1, 0.4, -0.5, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_trait_plus_state_lagged_covariance( + f64::NAN, + 0.4, + -0.5, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_trait_plus_state_lagged_covariance(0.4, 0.4, -0.5, 1.0, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_trait_plus_state_lagged_covariance(1e308, 1e308, 0.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn non_event_clocks_and_difference_quotient_fail_closed() { + for clock in [ + LagClock::SystemTime, + LagClock::AssertionTime, + LagClock::DocumentTime, + LagClock::AvailabilityTime, + LagClock::KnowledgeCutoff, + ] { + assert_eq!( + recover_local_log_rate(0.5, 1.0, clock), + Err(PsychometricError::EventTimeRequired) + ); + assert!(!clock.admits_structural_lag()); + assert!(!std::hint::black_box(clock).as_str().is_empty()); + } + assert!(LagClock::EventTime.admits_structural_lag()); + assert_eq!( + std::hint::black_box(LagClock::EventTime).as_str(), + "event_time" + ); + assert_eq!( + refuse_difference_quotient_as_local_rate(1.0, 0.5, 1.0), + Err(PsychometricError::DifferenceQuotientForbidden) + ); + } + + #[test] + fn invalid_lag_inputs_fail_closed() { + assert_eq!( + recover_discrete_lag_one(0.0, 1.0), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_lag_one(f64::NAN, 1.0), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_lag_one(1.0, f64::INFINITY), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_local_log_rate(0.5, 0.0, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_local_log_rate(0.5, -1.0, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_local_log_rate(0.5, f64::NAN, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_local_log_rate(0.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_local_log_rate(-0.2, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_local_log_rate(f64::NAN, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn series_mean_log_rate_recovers_and_refuses() { + let drift = -0.25_f64; + let occasions = [ + EventOccasion { + event_time: 0.0, + score: 2.0, + }, + EventOccasion { + event_time: 1.0, + score: 2.0 * drift.exp(), + }, + EventOccasion { + event_time: 3.0, + score: 2.0 * (drift * 3.0).exp(), + }, + ]; + let series = + recover_event_series_mean_log_rate(&occasions, LagClock::EventTime).expect("series"); + assert!((series - drift).abs() < 1e-12); + assert_eq!( + recover_event_series_mean_log_rate(&occasions, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_event_series_mean_log_rate(&[], LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_event_series_mean_log_rate( + &[EventOccasion { + event_time: 0.0, + score: 1.0, + }], + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_event_series_mean_log_rate( + &[ + EventOccasion { + event_time: f64::NAN, + score: 1.0, + }, + EventOccasion { + event_time: 1.0, + score: 0.5, + }, + ], + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_event_series_mean_log_rate( + &[occasion(0.0, 1.0), occasion(f64::NAN, 0.5)], + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_event_series_mean_log_rate( + &[occasion(0.0, f64::NAN), occasion(1.0, 0.5)], + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_event_series_mean_log_rate( + &[occasion(0.0, 1.0), occasion(1.0, f64::NAN)], + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_event_series_mean_log_rate( + &[ + EventOccasion { + event_time: 0.0, + score: 1.0, + }, + EventOccasion { + event_time: 0.0, + score: 0.5, + }, + ], + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + } + + fn clustered(cluster_key: u64, event_time: f64, score: f64) -> ClusteredEventScore { + ClusteredEventScore { + cluster_key, + event_time, + score, + } + } + + fn occasion(event_time: f64, score: f64) -> EventOccasion { + EventOccasion { event_time, score } + } + + fn decaying_clustered_scores(drift: f64) -> [ClusteredEventScore; 12] { + [ + clustered(1, 0.0, 10.0 + 1.0), + clustered(1, 1.0, 10.0 + drift.exp()), + clustered(1, 2.0, 10.0 + (drift * 2.0).exp()), + clustered(1, 3.0, 10.0 + (drift * 3.0).exp()), + clustered(1, 4.0, 10.0 + (drift * 4.0).exp()), + clustered(1, 5.0, 10.0 + (drift * 5.0).exp()), + clustered(2, 0.0, -6.0 + 1.2), + clustered(2, 1.0, -6.0 + 1.2 * drift.exp()), + clustered(2, 2.0, -6.0 + 1.2 * (drift * 2.0).exp()), + clustered(2, 3.0, -6.0 + 1.2 * (drift * 3.0).exp()), + clustered(2, 4.0, -6.0 + 1.2 * (drift * 4.0).exp()), + clustered(2, 5.0, -6.0 + 1.2 * (drift * 5.0).exp()), + ] + } + + #[test] + fn within_residual_paths_recover_and_refuse() { + let drift = -0.25_f64; + let clustered = decaying_clustered_scores(drift); + let within = recover_within_residual_event_time_log_rate(&clustered, LagClock::EventTime) + .expect("cwc lag"); + let within_error = (within - drift).abs(); + assert!(within_error.is_finite()); + } + + #[test] + fn within_residual_invalid_rows_fail_closed() { + let rows = decaying_clustered_scores(-0.25); + assert_eq!( + recover_within_residual_event_time_log_rate(&rows, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_within_residual_event_time_log_rate(&[], LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_within_residual_event_time_log_rate( + &[clustered(1, 0.0, 1.0), clustered(1, 1.0, 0.5)], + LagClock::EventTime + ), + Err(PsychometricError::InsufficientClusters) + ); + assert_eq!( + recover_within_residual_event_time_log_rate( + &[clustered(1, f64::NAN, 1.0), clustered(2, 1.0, 0.5)], + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_event_series_mean_log_rate( + &[occasion(0.0, 1.0), occasion(1.0, f64::NAN)], + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_within_residual_event_time_log_rate( + &[ + clustered(1, 0.0, 1.0), + clustered(1, 1.0, 0.5), + clustered(2, 0.0, 2.0), + clustered(2, 1.0, 1.0), + ], + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_within_residual_event_time_log_rate( + &[clustered(1, 0.0, 1.0), clustered(2, 1.0, f64::INFINITY)], + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_within_residual_event_time_log_rate( + &[ + clustered(1, 0.0, 1.0), + clustered(1, 0.0, 1.2), + clustered(2, 0.0, 2.0), + clustered(2, 1.0, 1.5), + ], + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + } + + fn lagged( + earlier_residual: f64, + later_residual: f64, + event_delta: f64, + ) -> LaggedWithinResidual { + LaggedWithinResidual { + earlier_residual, + later_residual, + event_delta, + } + } + + #[test] + fn irregular_centered_residuals_recover_exact_drift() { + let drift = -0.4_f64; + let pairs = [ + lagged(1.2, 1.2 * (drift * 0.5).exp(), 0.5), + lagged(0.8, 0.8 * (drift * 1.75).exp(), 1.75), + lagged(-1.1, -1.1 * (drift * 2.25).exp(), 2.25), + ]; + let recovered = recover_irregular_centered_residual_log_rate(&pairs, LagClock::EventTime) + .expect("irregular"); + assert!((recovered - drift).abs() < 1e-12); + } + + #[test] + fn irregular_centered_residuals_fail_closed() { + let ok = lagged(1.0, 0.8, 1.0); + assert_eq!( + recover_irregular_centered_residual_log_rate(&[ok], LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_irregular_centered_residual_log_rate(&[], LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_irregular_centered_residual_log_rate( + &[lagged(f64::NAN, 0.8, 1.0)], + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_irregular_centered_residual_log_rate( + &[lagged(1.0, f64::INFINITY, 1.0)], + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_irregular_centered_residual_log_rate( + &[lagged(1.0, 0.8, f64::NAN)], + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_irregular_centered_residual_log_rate( + &[lagged(0.0, 0.8, 1.0)], + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_irregular_centered_residual_log_rate( + &[lagged(1.0, -0.8, 1.0)], + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_irregular_centered_residual_log_rate( + &[lagged(1.0, 0.8, 0.0)], + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_irregular_centered_residual_log_rate( + &[lagged(1.0, 0.8, -0.5)], + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + } + + #[test] + fn singleton_cluster_is_skipped_and_all_singletons_fail_closed() { + let drift = -0.2_f64; + let mixed = [ + clustered(1, 0.0, 10.0 + 1.0), + clustered(1, 1.0, 10.0 + drift.exp()), + clustered(1, 2.0, 10.0 + (drift * 2.0).exp()), + clustered(1, 3.0, 10.0 + (drift * 3.0).exp()), + clustered(2, 0.0, 4.0), + ]; + let recovered = + recover_within_residual_event_time_log_rate(&mixed, LagClock::EventTime).expect("skip"); + assert!(recovered.is_finite()); + assert_eq!( + recover_within_residual_event_time_log_rate( + &[clustered(1, 0.0, 1.0), clustered(2, 1.0, 0.5)], + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn overflowing_cwc_residuals_fail_closed() { + assert_eq!( + recover_within_residual_event_time_log_rate( + &[ + clustered(1, 0.0, f64::MAX), + clustered(1, 1.0, f64::MAX), + clustered(2, 0.0, 1.0), + clustered(2, 1.0, 0.5), + ], + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn newton_overflow_and_flat_derivative_fail_closed() { + assert_eq!( + fit_scalar_log_rate(&[(1e-300, 1.0, 1e-8), (1.0, 1.0, 1.0)]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + fit_scalar_log_rate(&[(1e200, 1e200, 1.0)]), + Err(PsychometricError::InvalidNumericInput) + ); + let flat = fit_scalar_log_rate(&[(1e-50, 1e-200, 1.0)]).expect("flat"); + assert!(flat.is_finite()); + assert_eq!( + fit_scalar_log_rate(&[(0.0, 1.0, 1.0), (1.0, -1.0, 1.0)]), + Err(PsychometricError::InvalidNumericInput) + ); + let skipped_start = + fit_scalar_log_rate(&[(1e-320, 1.0, 1.0), (1.0, 0.5, 1.0)]).expect("skip inf ratio"); + assert!(skipped_start.is_finite()); + let skipped_zero_and_negative = fit_scalar_log_rate(std::hint::black_box(&[ + (0.0, 1.0, 1.0), + (1.0, -1.0, 1.0), + (1.0, 0.5, 1.0), + ])) + .expect("skip zero and negative lags"); + assert!(skipped_zero_and_negative.is_finite()); + assert_eq!( + fit_scalar_log_rate(&[(1e154, 1e154, 1.0)]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + fit_scalar_log_rate(&[(1.0, 1e-300, 1.0), (1.0, 1e-300, 2.0)]), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn one_sided_residual_overflow_and_nonfinite_interval_fail_closed() { + assert_eq!( + recover_within_residual_event_time_log_rate( + &[ + clustered(1, 0.0, -f64::MAX), + clustered(1, 1.0, -f64::MAX), + clustered(1, 2.0, -f64::MAX), + clustered(1, 3.0, f64::MAX), + clustered(2, 0.0, 1.0), + clustered(2, 1.0, 0.8), + ], + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_within_residual_event_time_log_rate( + &[ + clustered(1, f64::MAX, 1.0), + clustered(1, -f64::MAX, 0.5), + clustered(2, 0.0, 1.0), + clustered(2, 1.0, 0.5), + ], + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + } + + #[test] + fn manifest_observed_variance_recovers_driver_equation_five() { + let loading = 2.0_f64; + let latent = 0.4_f64; + let measurement_error = 0.1_f64; + let recovered = + recover_manifest_observed_variance(loading, latent, measurement_error).expect("eq5"); + let expected = (loading * latent) * loading + measurement_error; + assert!((recovered - expected).abs() < 1e-15); + assert!((recovered - 1.7).abs() < 1e-15); + assert!((measurement_error - recovered).abs() > 1e-3); + assert!((latent - recovered).abs() > 1e-3); + assert_eq!( + refuse_measurement_error_as_observed_variance(measurement_error, recovered), + Err(PsychometricError::MeasurementErrorIsNotObservedVariance) + ); + assert_eq!( + refuse_latent_variance_as_observed_variance(latent, recovered), + Err(PsychometricError::LatentVarianceIsNotObservedVariance) + ); + assert_eq!( + recover_manifest_observed_variance(0.0, latent, measurement_error), + Ok(measurement_error) + ); + assert_eq!( + recover_manifest_observed_variance(loading, 0.0, measurement_error), + Ok(measurement_error) + ); + assert_eq!( + recover_manifest_observed_variance(loading, latent, 0.0), + Ok(1.6) + ); + // Do not form λ² first: (1e308)² overflows; (λ p) λ is 1e308. + let scaled = recover_manifest_observed_variance(1e308, 1e-308, 0.0).expect("scale"); + assert!((scaled - 1e308).abs() / 1e308 < 1e-15); + assert!(!(1e308_f64 * 1e308_f64).is_finite()); + } + + #[test] + fn manifest_trait_plus_state_observed_variance_recovers_driver_equation_five() { + let loading = 2.0_f64; + let latent = 0.4_f64; + let measurement_error = 0.1_f64; + let manifest_trait = 0.5_f64; + let recovered = recover_manifest_trait_plus_state_observed_variance( + loading, + latent, + measurement_error, + manifest_trait, + ) + .expect("eq5-trait"); + let expected = (loading * latent) * loading + measurement_error + manifest_trait; + assert!((recovered - expected).abs() < 1e-15); + assert!((recovered - 2.2).abs() < 1e-15); + let without_trait = + recover_manifest_observed_variance(loading, latent, measurement_error).expect("psi0"); + assert_eq!( + recover_manifest_trait_plus_state_observed_variance( + loading, + latent, + measurement_error, + 0.0 + ), + Ok(without_trait) + ); + assert!((without_trait - recovered).abs() > 1e-3); + assert_eq!( + refuse_manifest_trait_variance_as_measurement_error(manifest_trait, measurement_error), + Err(PsychometricError::ManifestTraitVarianceIsNotMeasurementError) + ); + // Zero loading: Var(y) = θ + ψ, not ψ stuffed as Θ. + assert_eq!( + recover_manifest_trait_plus_state_observed_variance( + 0.0, + latent, + measurement_error, + manifest_trait + ), + Ok(measurement_error + manifest_trait) + ); + // TRAITVAR is latent and scaled by λ²; MANIFESTTRAITVAR is not. + let latent_trait_as_state = + recover_manifest_observed_variance(loading, latent + manifest_trait, measurement_error) + .expect("traitvar"); + assert!((latent_trait_as_state - recovered).abs() > 1e-3); + // Do not form λ² first, then add ψ. + let scaled = recover_manifest_trait_plus_state_observed_variance(1e308, 1e-308, 0.0, 1.0) + .expect("scale-psi"); + assert!((scaled - 1e308).abs() / 1e308 < 1e-15); + } + + #[test] + fn manifest_trait_plus_state_observed_variance_invalid_inputs_fail_closed() { + assert_eq!( + recover_manifest_trait_plus_state_observed_variance(2.0, 0.4, 0.1, -0.1), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_manifest_trait_plus_state_observed_variance(2.0, 0.4, 0.1, f64::NAN), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_manifest_trait_plus_state_observed_variance(1e308, 1.0, 0.0, 0.3), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_manifest_trait_plus_state_observed_variance(1e308, 1e-308, 1e308, 1e308), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn manifest_observed_variance_invalid_inputs_fail_closed() { + assert_eq!( + recover_manifest_observed_variance(f64::NAN, 0.4, 0.1), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_manifest_observed_variance(2.0, -0.1, 0.1), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_manifest_observed_variance(2.0, 0.4, -0.1), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_manifest_observed_variance(2.0, f64::NAN, 0.1), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_manifest_observed_variance(2.0, 0.4, f64::NAN), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_manifest_observed_variance(1e308, 1.0, 0.0), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_manifest_observed_variance(1e308, 1.0, 1e308), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn manifest_lagged_observed_covariance_recovers_driver_equation_five() { + let loading = 2.0_f64; + let lagged = 0.4_f64; + let manifest_trait = 0.5_f64; + let recovered = + recover_manifest_lagged_observed_covariance(loading, lagged, manifest_trait) + .expect("eq5-lag"); + let expected = (loading * lagged) * loading + manifest_trait; + assert!((recovered - expected).abs() < 1e-15); + assert!((recovered - 2.1).abs() < 1e-15); + assert_eq!( + recover_manifest_lagged_observed_covariance(loading, lagged, 0.0), + Ok(1.6) + ); + assert_eq!( + recover_manifest_lagged_observed_covariance(0.0, lagged, manifest_trait), + Ok(manifest_trait) + ); + assert_eq!( + recover_manifest_lagged_observed_covariance(loading, 0.0, manifest_trait), + Ok(manifest_trait) + ); + assert_eq!( + refuse_latent_lagged_covariance_as_observed_covariance(lagged, recovered), + Err(PsychometricError::LatentLaggedCovarianceIsNotObservedCovariance) + ); + assert_eq!( + refuse_measurement_error_as_lagged_observed_covariance(0.1, recovered), + Err(PsychometricError::MeasurementErrorIsNotLaggedObservedCovariance) + ); + let scaled = + recover_manifest_lagged_observed_covariance(1e308, 1e-308, 0.0).expect("scale"); + assert!((scaled - 1e308).abs() / 1e308 < 1e-15); + assert!(!(1e308_f64 * 1e308_f64).is_finite()); + } + + #[test] + fn manifest_lagged_observed_covariance_invalid_inputs_fail_closed() { + assert_eq!( + recover_manifest_lagged_observed_covariance(f64::NAN, 0.4, 0.0), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_manifest_lagged_observed_covariance(2.0, -0.1, 0.0), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_manifest_lagged_observed_covariance(2.0, 0.4, -0.1), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_manifest_lagged_observed_covariance(1e308, 1.0, 0.0), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_manifest_lagged_observed_covariance(1e308, 1e-308, 1e308), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn manifest_observed_mean_recovers_driver_equation_five() { + let loading = 2.0_f64; + let latent_mean = 0.4_f64; + let manifest_mean = 0.5_f64; + let recovered = + recover_manifest_observed_mean(loading, latent_mean, manifest_mean).expect("eq5-mean"); + let expected = loading * latent_mean + manifest_mean; + assert!((recovered - expected).abs() < 1e-15); + assert!((recovered - 1.3).abs() < 1e-15); + assert_eq!( + recover_manifest_observed_mean(loading, latent_mean, 0.0), + Ok(0.8) + ); + assert_eq!( + recover_manifest_observed_mean(0.0, latent_mean, manifest_mean), + Ok(manifest_mean) + ); + assert_eq!( + recover_manifest_observed_mean(loading, 0.0, manifest_mean), + Ok(manifest_mean) + ); + assert_eq!(recover_manifest_observed_mean(-2.0, 0.5, 1.0), Ok(0.0)); + assert_eq!( + refuse_manifest_means_as_observed_mean(manifest_mean, recovered), + Err(PsychometricError::ManifestMeansIsNotObservedMean) + ); + assert_eq!( + refuse_latent_mean_as_observed_mean(latent_mean, recovered), + Err(PsychometricError::LatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_continuous_intercept_as_manifest_means(0.3, manifest_mean), + Err(PsychometricError::ContinuousInterceptIsNotManifestMeans) + ); + let scaled = recover_manifest_observed_mean(1e308, 1e-308, 0.0).expect("scale"); + assert!((scaled - 1.0).abs() < 1e-15); + let finite_loaded = recover_manifest_observed_mean(1e308, 1.0, 0.0).expect("lambda-mu"); + assert!((finite_loaded - 1e308).abs() / 1e308 < 1e-15); + assert!(!(1e308_f64 * 1e308_f64).is_finite()); + } + + #[test] + fn manifest_observed_mean_invalid_inputs_fail_closed() { + assert_eq!( + recover_manifest_observed_mean(f64::NAN, 0.4, 0.0), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_manifest_observed_mean(2.0, f64::NAN, 0.0), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_manifest_observed_mean(2.0, 0.4, f64::NAN), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_manifest_observed_mean(1e308, 2.0, 0.0), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_manifest_observed_mean(1.0, 1e308, 1e308), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!(recover_manifest_observed_mean(0.0, 1e308, 0.5), Ok(0.5)); + assert_eq!(recover_manifest_observed_mean(1e308, 0.0, 0.5), Ok(0.5)); + } + + #[test] + fn discrete_latent_mean_recovers_driver_equation_three() { + let drift = -0.5_f64; + let delta = 2.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let recovered = + recover_discrete_latent_mean(initial, drift, intercept, delta, LagClock::EventTime) + .expect("eq3-mean"); + let expected = + (drift * delta).exp() * initial + intercept * ((drift * delta).exp_m1() / drift); + assert!((recovered - expected).abs() < 1e-15); + let increment = recover_discrete_continuous_intercept_effect( + intercept, + drift, + delta, + LagClock::EventTime, + ) + .expect("cint"); + assert!((increment - intercept * ((drift * delta).exp_m1() / drift)).abs() < 1e-15); + assert_eq!( + recover_discrete_latent_mean(0.0, drift, intercept, delta, LagClock::EventTime), + Ok(increment) + ); + assert_eq!( + recover_discrete_latent_mean(initial, drift, 0.0, delta, LagClock::EventTime), + Ok((drift * delta).exp() * initial) + ); + assert_eq!( + recover_discrete_latent_mean(initial, 0.0, intercept, delta, LagClock::EventTime), + Ok(initial + intercept * delta) + ); + assert_eq!( + recover_discrete_continuous_intercept_effect( + intercept, + 0.0, + delta, + LagClock::EventTime + ), + Ok(intercept * delta) + ); + assert_eq!( + recover_discrete_continuous_intercept_effect(0.0, 0.0, delta, LagClock::EventTime), + Ok(0.0) + ); + assert_eq!( + refuse_initial_latent_mean_as_evolved_mean(initial, recovered), + Err(PsychometricError::InitialLatentMeanIsNotEvolvedMean) + ); + assert_eq!( + refuse_continuous_intercept_as_discrete_mean_increment(intercept, increment), + Err(PsychometricError::ContinuousInterceptIsNotDiscreteMeanIncrement) + ); + assert_eq!( + refuse_continuous_intercept_as_initial_latent_mean(intercept, initial), + Err(PsychometricError::ContinuousInterceptIsNotInitialLatentMean) + ); + let equilibrium = + recover_discrete_latent_mean(initial, -1e308, 1.0, 2.0, LagClock::EventTime) + .expect("eq3-equilibrium"); + let equilibrium_expected = -(1.0 / -1e308); + assert!((equilibrium - equilibrium_expected).abs() / 1e-308 < 1e-12); + assert_eq!( + recover_discrete_latent_mean(1e308, 1.0, 0.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_mean(0.0, 1e308, 0.0, 2.0, LagClock::EventTime), + Ok(0.0) + ); + // CINT = 0 so the increment path stays finite; exp(a Δt) then + // overflows and the carried T0MEANS term fails closed. + assert_eq!( + recover_discrete_latent_mean(1.0, 710.0, 0.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert!(!(710.0_f64.exp()).is_finite()); + } + + #[test] + fn discrete_latent_mean_invalid_inputs_fail_closed() { + assert_eq!( + recover_discrete_latent_mean(f64::NAN, -0.5, 0.3, 2.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_mean(1.0, f64::NAN, 0.3, 2.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_mean(1.0, -0.5, f64::NAN, 2.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_mean(1.0, -0.5, 0.3, 0.0, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_latent_mean(1.0, -0.5, 0.3, 2.0, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_continuous_intercept_effect(1.0, 1e308, 2.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_mean(1.0, 1e308, 1.0, 2.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_mean(1e308, 0.0, 1e308, 2.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_mean(1e308, 0.0, 1e308, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + let underflow_argument = 1e-308_f64 * 1e-308_f64; + assert_eq!(underflow_argument.to_bits(), 0.0_f64.to_bits()); + let underflow = recover_discrete_latent_mean(2.0, 1e-308, 4.0, 1e-308, LagClock::EventTime) + .expect("a-delta-underflow"); + assert!((underflow - 2.0).abs() < 1e-15); + } + + #[test] + fn discrete_observed_mean_recovers_driver_equations_three_and_five() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let recovered = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let evolved = + recover_discrete_latent_mean(initial, drift, intercept, delta, LagClock::EventTime) + .expect("mu-t"); + let expected = manifest_mean + loading * evolved; + assert!((recovered - expected).abs() < 1e-15); + let first_occasion = + recover_manifest_observed_mean(loading, initial, manifest_mean).expect("t0"); + assert!((first_occasion - recovered).abs() > 1e-3); + assert_eq!( + recover_discrete_observed_mean( + 0.0, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime + ), + Ok(manifest_mean) + ); + assert_eq!( + recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + 0.0, + delta, + LagClock::EventTime + ), + Ok(loading * evolved) + ); + let zero_evolved = recover_discrete_observed_mean( + loading, + 0.0, + 0.0, + 0.0, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("zero-mu"); + assert!((zero_evolved - manifest_mean).abs() < 1e-15); + let integrator = recover_discrete_observed_mean( + loading, + initial, + 0.0, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("a0"); + assert!( + (integrator - (manifest_mean + loading * (initial + intercept * delta))).abs() < 1e-15 + ); + let equilibrium = recover_discrete_observed_mean( + loading, + initial, + -1e308, + 1.0, + manifest_mean, + 2.0, + LagClock::EventTime, + ) + .expect("eq3-eq5-equilibrium"); + let equilibrium_latent = -(1.0 / -1e308); + assert!((equilibrium - (manifest_mean + loading * equilibrium_latent)).abs() < 1e-15); + } + + #[test] + fn discrete_observed_mean_refuses_first_occasion_and_overflow() { + let loading = 2.0_f64; + let recovered = + recover_discrete_observed_mean(loading, 1.0, -0.5, 0.3, 0.5, 2.0, LagClock::EventTime) + .expect("eq3-eq5-mean"); + let evolved = + recover_discrete_latent_mean(1.0, -0.5, 0.3, 2.0, LagClock::EventTime).expect("mu-t"); + let first_occasion = recover_manifest_observed_mean(loading, 1.0, 0.5).expect("t0"); + assert_eq!( + refuse_initial_observed_mean_as_evolved_observed_mean(first_occasion, recovered), + Err(PsychometricError::InitialObservedMeanIsNotEvolvedObservedMean) + ); + assert_eq!( + refuse_latent_mean_as_observed_mean(evolved, recovered), + Err(PsychometricError::LatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_manifest_means_as_observed_mean(0.5, recovered), + Err(PsychometricError::ManifestMeansIsNotObservedMean) + ); + let scaled = + recover_discrete_observed_mean(1e308, 1e-308, 0.0, 0.0, 0.0, 1.0, LagClock::EventTime) + .expect("scale"); + assert!((scaled - 1.0).abs() < 1e-15); + let finite_loaded = + recover_discrete_observed_mean(1e308, 1.0, 0.0, 0.0, 0.0, 1.0, LagClock::EventTime) + .expect("lambda-mu"); + assert!((finite_loaded - 1e308).abs() / 1e308 < 1e-15); + } + + #[test] + fn discrete_observed_mean_invalid_inputs_fail_closed() { + assert_eq!( + recover_discrete_observed_mean(f64::NAN, 1.0, -0.5, 0.3, 0.5, 2.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_observed_mean(2.0, 1.0, -0.5, 0.3, 0.5, 0.0, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_observed_mean(2.0, 1.0, -0.5, 0.3, 0.5, 2.0, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_observed_mean(1e308, 2.0, 0.0, 0.0, 0.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_observed_mean(1.0, 1.0, 710.0, 0.0, 0.5, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn time_dependent_impulse_recovers_driver_equation_three_fourth_summand() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("tdpred"); + assert!((impulse - 1.2).abs() < 1e-15); + assert_eq!( + recover_time_dependent_predictor_impulse(0.0, predictor), + Ok(0.0) + ); + assert_eq!( + recover_time_dependent_predictor_impulse(effect, 0.0), + Ok(0.0) + ); + let drift = -0.5_f64; + let delta = 2.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let composed = recover_discrete_latent_mean_with_impulse( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-impulse"); + let evolved = + recover_discrete_latent_mean(initial, drift, intercept, delta, LagClock::EventTime) + .expect("mu-t"); + assert!((composed - (evolved + impulse)).abs() < 1e-15); + assert_eq!( + recover_discrete_latent_mean_with_impulse( + initial, + drift, + intercept, + 0.0, + predictor, + delta, + LagClock::EventTime + ), + Ok(evolved) + ); + let intercept_effect = + recover_discrete_continuous_intercept_effect(effect, drift, delta, LagClock::EventTime) + .expect("cint"); + assert!((impulse - intercept_effect).abs() > 1e-3); + let equation_fourteen = recover_discrete_time_varying_predictor_effect( + effect, + delta, + delta, + delta, + LagClock::EventTime, + ) + .expect("eq14"); + assert!((impulse - equation_fourteen).abs() > 1e-3); + } + + #[test] + fn time_dependent_impulse_refuses_cint_tipred_and_equation_fourteen() { + let effect = 0.4_f64; + let predictor = 2.0_f64; + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("tdpred"); + let intercept_effect = + recover_discrete_continuous_intercept_effect(effect, -0.5, 2.0, LagClock::EventTime) + .expect("cint"); + let equation_fourteen = recover_discrete_time_varying_predictor_effect( + effect, + 2.0, + 2.0, + 2.0, + LagClock::EventTime, + ) + .expect("eq14"); + assert_eq!( + refuse_time_dependent_impulse_as_continuous_intercept(impulse, effect), + Err(PsychometricError::TimeDependentImpulseIsNotContinuousIntercept) + ); + assert_eq!( + refuse_time_dependent_impulse_as_time_independent_effect(impulse, intercept_effect), + Err(PsychometricError::TimeDependentImpulseIsNotTimeIndependentEffect) + ); + assert_eq!( + refuse_time_dependent_impulse_as_time_varying_discrete_effect( + impulse, + equation_fourteen + ), + Err(PsychometricError::TimeDependentImpulseIsNotTimeVaryingDiscreteEffect) + ); + } + + #[test] + fn time_dependent_impulse_invalid_inputs_fail_closed() { + assert_eq!( + recover_time_dependent_predictor_impulse(f64::NAN, 1.0), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_time_dependent_predictor_impulse(1.0, f64::INFINITY), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_time_dependent_predictor_impulse(1e308, 2.0), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_mean_with_impulse( + 1.0, + -0.5, + 0.3, + 0.4, + 2.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_latent_mean_with_impulse( + 1.0, + -0.5, + 0.3, + 0.4, + 2.0, + 2.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_latent_mean_with_impulse( + 1.0, + -0.5, + 0.3, + 1e308, + 2.0, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_mean_with_impulse( + 1e308, + 0.0, + 0.0, + 1e308, + 1.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn level_change_continuous_intercept_recovers_driver_section_seven_point_two() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let intercept = recover_level_change_continuous_intercept(effect, predictor, drift) + .expect("level-change"); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("impulse"); + assert!((intercept - 0.6).abs() < 1e-15); + assert!((impulse - 1.2).abs() < 1e-15); + let equilibrium = intercept / (-drift); + assert!((equilibrium - impulse).abs() < 1e-15); + assert_eq!( + recover_level_change_continuous_intercept(0.0, predictor, drift), + Ok(0.0) + ); + assert_eq!( + recover_level_change_continuous_intercept(effect, 0.0, drift), + Ok(0.0) + ); + assert_eq!( + recover_level_change_continuous_intercept(effect, predictor, 0.0), + Err(PsychometricError::LevelChangeRequiresStableDrift) + ); + assert_eq!( + recover_level_change_continuous_intercept(effect, predictor, 0.5), + Err(PsychometricError::LevelChangeRequiresStableDrift) + ); + let increment = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + drift, + 2.0, + LagClock::EventTime, + ) + .expect("tipred"); + assert_eq!( + refuse_level_change_intercept_as_impulse(intercept, impulse), + Err(PsychometricError::LevelChangeInterceptIsNotImpulse) + ); + assert_eq!( + refuse_level_change_intercept_as_free_continuous_intercept(intercept, 0.3), + Err(PsychometricError::LevelChangeInterceptIsNotFreeContinuousIntercept) + ); + assert_eq!( + refuse_level_change_intercept_as_process_increment(intercept, increment), + Err(PsychometricError::LevelChangeInterceptIsNotProcessIncrement) + ); + } + + #[test] + fn level_change_continuous_intercept_invalid_inputs_fail_closed() { + assert_eq!( + recover_level_change_continuous_intercept(f64::NAN, 1.0, -0.5), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_level_change_continuous_intercept(1.0, f64::INFINITY, -0.5), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_level_change_continuous_intercept(0.4, 3.0, f64::NAN), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_level_change_continuous_intercept(1e308, 2.0, -0.5), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_level_change_continuous_intercept(1.0, 2.0, -1e308), + Err(PsychometricError::InvalidNumericInput) + ); + let scaled = recover_level_change_continuous_intercept(1e-308, 1.0, -1.0).expect("scale"); + assert!((scaled - 1e-308).abs() < 1e-320); + let rewritten = + recover_level_change_continuous_intercept(1e-308, 1.0, -1e308).expect("rewrite"); + assert!((rewritten - 1.0).abs() < 1e-12); + } + + #[test] + fn level_change_discrete_increment_recovers_driver_equation_three_of_section_seven_point_two() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let increment = recover_level_change_discrete_increment( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("level-change-increment"); + let intercept = recover_level_change_continuous_intercept(effect, predictor, drift) + .expect("level-change"); + let via_cint = recover_discrete_continuous_intercept_effect( + intercept, + drift, + delta, + LagClock::EventTime, + ) + .expect("cint-increment"); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("impulse"); + let expected = (1.0 - (drift * delta).exp()) * impulse; + assert!((increment - expected).abs() < 1e-15); + assert!((increment - via_cint).abs() < 1e-15); + assert!((increment - impulse).abs() > 1e-3); + assert!((increment - intercept).abs() > 1e-3); + let tipred = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("tipred"); + assert_eq!( + refuse_level_change_increment_as_impulse(increment, impulse), + Err(PsychometricError::LevelChangeIncrementIsNotImpulse) + ); + assert_eq!( + refuse_level_change_increment_as_intercept(increment, intercept), + Err(PsychometricError::LevelChangeIncrementIsNotIntercept) + ); + assert_eq!( + refuse_level_change_increment_as_process_increment(increment, tipred), + Err(PsychometricError::LevelChangeIncrementIsNotProcessIncrement) + ); + let equilibrated = recover_level_change_discrete_increment( + effect, + predictor, + -800.0, + 1.0, + LagClock::EventTime, + ) + .expect("underflow"); + assert!((equilibrated - impulse).abs() < 1e-15); + assert_eq!( + recover_level_change_discrete_increment( + 0.0, + predictor, + drift, + delta, + LagClock::EventTime + ), + Ok(0.0) + ); + } + + #[test] + fn level_change_discrete_increment_invalid_inputs_fail_closed() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + assert_eq!( + recover_level_change_discrete_increment( + effect, + predictor, + 0.0, + delta, + LagClock::EventTime + ), + Err(PsychometricError::LevelChangeRequiresStableDrift) + ); + assert_eq!( + recover_level_change_discrete_increment( + effect, + predictor, + 0.5, + delta, + LagClock::EventTime + ), + Err(PsychometricError::LevelChangeRequiresStableDrift) + ); + assert_eq!( + recover_level_change_discrete_increment( + effect, + predictor, + drift, + delta, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_level_change_discrete_increment( + effect, + predictor, + drift, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_level_change_discrete_increment(1e308, 2.0, drift, delta, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_level_change_discrete_increment( + f64::NAN, + predictor, + drift, + delta, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_level_change_discrete_increment( + 0.0, + predictor, + 0.0, + delta, + LagClock::EventTime + ), + Ok(0.0) + ); + } + + #[test] + fn extra_process_contribution_recovers_driver_section_seven_point_two() { + let coupling = 0.569_907_f64; + let predictor = 1.0_f64; + let original = -0.1393_f64; + let extra = -0.000_001_f64; + let delta = 1.0_f64; + let recovered = recover_level_change_extra_process_contribution( + coupling, + predictor, + original, + extra, + delta, + LagClock::EventTime, + ) + .expect("extra-process"); + let expected = coupling * predictor * ((extra * delta).exp() - (original * delta).exp()) + / (extra - original); + assert!((recovered - expected).abs() < 1e-15); + let equal_rate = recover_level_change_extra_process_contribution( + coupling, + predictor, + extra, + extra, + delta, + LagClock::EventTime, + ) + .expect("equal-rate"); + let equal_expected = coupling * predictor * delta * (extra * delta).exp(); + assert!((equal_rate - equal_expected).abs() < 1e-15); + let brownian = recover_level_change_extra_process_contribution( + coupling, + predictor, + 0.0, + extra, + delta, + LagClock::EventTime, + ) + .expect("brownian-original"); + let brownian_expected = coupling * predictor * (extra * delta).exp_m1() / extra; + assert!((brownian - brownian_expected).abs() < 1e-15); + assert_eq!( + recover_level_change_extra_process_contribution( + 0.0, + predictor, + original, + extra, + delta, + LagClock::EventTime + ), + Ok(0.0) + ); + assert_eq!( + recover_level_change_extra_process_contribution( + coupling, + 0.0, + original, + extra, + delta, + LagClock::EventTime + ), + Ok(0.0) + ); + assert_eq!( + recover_level_change_extra_process_contribution( + coupling, + 0.0, + original, + 0.0, + delta, + LagClock::EventTime + ), + Ok(0.0) + ); + } + + #[test] + fn extra_process_contribution_is_not_cint_rewrite_or_impulse() { + let coupling = 0.4_f64; + let predictor = 3.0_f64; + let original = -0.5_f64; + let extra = -0.05_f64; + let delta = 2.0_f64; + let recovered = recover_level_change_extra_process_contribution( + coupling, + predictor, + original, + extra, + delta, + LagClock::EventTime, + ) + .expect("extra-process"); + let intercept = recover_level_change_continuous_intercept(coupling, predictor, original) + .expect("level-change"); + let increment = recover_level_change_discrete_increment( + coupling, + predictor, + original, + delta, + LagClock::EventTime, + ) + .expect("level-change-increment"); + let impulse = + recover_time_dependent_predictor_impulse(coupling, predictor).expect("impulse"); + assert!((recovered - intercept).abs() > 1e-3); + assert!((recovered - increment).abs() > 1e-3); + assert!((recovered - impulse).abs() > 1e-3); + assert_eq!( + refuse_level_change_extra_process_as_impulse(recovered, impulse), + Err(PsychometricError::LevelChangeExtraProcessIsNotImpulse) + ); + assert_eq!( + refuse_level_change_extra_process_as_intercept(recovered, intercept), + Err(PsychometricError::LevelChangeExtraProcessIsNotIntercept) + ); + assert_eq!( + refuse_level_change_extra_process_as_increment(recovered, increment), + Err(PsychometricError::LevelChangeExtraProcessIsNotIncrement) + ); + } + + #[test] + fn extra_process_contribution_invalid_inputs_fail_closed() { + let coupling = 0.4_f64; + let predictor = 3.0_f64; + let original = -0.5_f64; + let extra = -0.000_001_f64; + let delta = 2.0_f64; + assert_eq!( + recover_level_change_extra_process_contribution( + coupling, + predictor, + original, + 0.0, + delta, + LagClock::EventTime + ), + Err(PsychometricError::LevelChangeExtraProcessRequiresNegativeDrift) + ); + assert_eq!( + recover_level_change_extra_process_contribution( + coupling, + predictor, + original, + 0.5, + delta, + LagClock::EventTime + ), + Err(PsychometricError::LevelChangeExtraProcessRequiresNegativeDrift) + ); + assert_eq!( + recover_level_change_extra_process_contribution( + coupling, + predictor, + original, + extra, + delta, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_level_change_extra_process_contribution( + coupling, + predictor, + original, + extra, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_level_change_extra_process_contribution( + f64::NAN, + predictor, + original, + extra, + delta, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_level_change_extra_process_contribution( + 1e308, + 2.0, + original, + extra, + delta, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_level_change_extra_process_contribution( + coupling, + predictor, + 710.0, + extra, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn nonfinite_short_circuit_operands_of_fail_closed_guards_execute() { + let event = LagClock::EventTime; + assert_eq!( + recover_manifest_lagged_observed_covariance(2.0, f64::NAN, 0.0), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_manifest_lagged_observed_covariance(2.0, 0.4, f64::NAN), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_continuous_intercept_effect(0.3, -0.5, f64::NAN, event), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_level_change_extra_process_contribution(0.4, 3.0, -0.5, -1e-6, f64::NAN, event), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_level_change_extra_process_contribution(0.4, f64::NAN, -0.5, -1e-6, 2.0, event), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_level_change_extra_process_contribution(0.4, 3.0, f64::NAN, -1e-6, 2.0, event), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_level_change_extra_process_contribution(0.4, 3.0, -0.5, f64::NAN, 2.0, event), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_level_change_extra_process_contribution_after( + 0.4, + 3.0, + -0.5, + -0.05, + 2.0, + f64::NAN, + event + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_time_independent_predictor_effect(0.2, 1.0, -0.5, f64::NAN, event), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_time_independent_predictor_effect(0.2, 1.0, f64::NAN, 2.0, event), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_time_independent_predictor_effect(0.2, f64::NAN, -0.5, 2.0, event), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_effect(0.2, f64::NAN, -0.5, event), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_effect(0.2, 1.0, f64::NAN, event), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_variance(f64::NAN, 1.0, -0.5, event), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_variance(0.2, f64::NAN, -0.5, event), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_variance(0.2, 1.0, f64::NAN, event), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_asymptotic_continuous_intercept(0.3, f64::NAN, event), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_initial_time_independent_predictor_carry(0.4, 3.0, -0.5, f64::NAN, event), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_initial_time_dependent_predictor_carry(0.4, 3.0, -0.5, f64::NAN, event), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_time_dependent_predictor_impulse_carry(0.4, 3.0, -0.5, f64::NAN, 1.0, event), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_time_dependent_predictor_impulse_carry(0.4, 3.0, -0.5, 2.0, f64::NAN, event), + Err(PsychometricError::NonPositiveInterval) + ); + } + + #[test] + fn extra_process_contribution_underflow_and_overflow_paths() { + let coupling = 0.4_f64; + let predictor = 3.0_f64; + let original = -0.5_f64; + let extra = -0.000_001_f64; + let vanished = recover_level_change_extra_process_contribution( + coupling, + predictor, + -800.0, + extra, + 1.0, + LagClock::EventTime, + ) + .expect("underflow"); + let vanished_expected = coupling * predictor * (extra * 1.0).exp() / (extra - -800.0); + assert!((vanished - vanished_expected).abs() < 1e-15); + let extra_underflow = recover_level_change_extra_process_contribution( + coupling, + predictor, + original, + -f64::from_bits(1), + 0.5, + LagClock::EventTime, + ) + .expect("extra-argument-underflow"); + assert!(extra_underflow.is_finite()); + let original_underflow = recover_level_change_extra_process_contribution( + coupling, + predictor, + -2e-160_f64, + -1e-160_f64, + 1e-200_f64, + LagClock::EventTime, + ) + .expect("gap-argument-underflow"); + let original_underflow_expected = coupling * predictor * 1e-200_f64; + assert!((original_underflow - original_underflow_expected).abs() <= 1e-200_f64); + let vanished_finite_increment = recover_level_change_extra_process_contribution( + coupling, + predictor, + -800.0, + -92.0, + 1.0, + LagClock::EventTime, + ) + .expect("original-lag-underflow-finite-increment"); + let vanished_finite_expected = coupling * predictor * (-92.0_f64).exp() / (-92.0 - -800.0); + assert!((vanished_finite_increment - vanished_finite_expected).abs() < 1e-15); + let overflow_fallback = recover_level_change_extra_process_contribution( + std::hint::black_box(coupling), + std::hint::black_box(predictor), + std::hint::black_box(-0.8), + std::hint::black_box(extra), + std::hint::black_box(900.0), + LagClock::EventTime, + ) + .expect("expm1-overflow-fallback"); + let overflow_expected = + coupling * predictor * ((extra * 900.0).exp() - (-0.8_f64 * 900.0).exp()) + / (extra - -0.8); + assert!((overflow_fallback - overflow_expected).abs() < 1e-12); + let extra_argument_zero = recover_level_change_extra_process_contribution( + coupling, + predictor, + original, + -f64::from_bits(1), + 1e-320, + LagClock::EventTime, + ) + .expect("extra-argument-zero"); + let extra_zero_delta = 1e-320_f64; + let extra_zero_rate = -f64::from_bits(1); + let extra_zero_expected = coupling + * predictor + * (original * extra_zero_delta).exp() + * ((extra_zero_rate - original) * extra_zero_delta).exp_m1() + / (extra_zero_rate - original); + assert!( + (extra_argument_zero - extra_zero_expected).abs() <= 16.0 * f64::from_bits(1), + "recovered={extra_argument_zero:.e} expected={extra_zero_expected:.e}" + ); + let extra_argument_zero_after = recover_level_change_extra_process_contribution_after( + coupling, + predictor, + original, + -f64::from_bits(1), + 1.0, + 1e-320, + LagClock::EventTime, + ) + .expect("after-extra-argument-zero"); + assert!( + (extra_argument_zero_after - extra_zero_expected).abs() <= 16.0 * f64::from_bits(1), + "after recovered={extra_argument_zero_after:.e} expected={extra_zero_expected:.e}" + ); + assert!((extra_argument_zero - extra_argument_zero_after).abs() < 1e-30); + } + + #[test] + fn extra_process_observed_mean_recovers_driver_equation_five() { + let loading = 2.0_f64; + let coupling = 0.4_f64; + let predictor = 3.0_f64; + let original = -0.5_f64; + let extra = -0.05_f64; + let delta = 2.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let recovered = recover_discrete_observed_mean_with_extra_process( + loading, + initial, + original, + intercept, + coupling, + predictor, + extra, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-extra-process-mean"); + let composed = recover_discrete_latent_mean_with_extra_process( + initial, + original, + intercept, + coupling, + predictor, + extra, + delta, + LagClock::EventTime, + ) + .expect("extra-latent"); + let expected = manifest_mean + loading * composed; + assert!((recovered - expected).abs() < 1e-15); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + original, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + assert!((evolved_observed - recovered).abs() > 1e-3); + let impulse_observed = recover_discrete_observed_mean_with_impulse( + loading, + initial, + original, + intercept, + coupling, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + assert!((impulse_observed - recovered).abs() > 1e-3); + let contribution = recover_level_change_extra_process_contribution( + coupling, + predictor, + original, + extra, + delta, + LagClock::EventTime, + ) + .expect("extra-process"); + assert_eq!( + refuse_evolved_observed_mean_as_extra_process_observed_mean( + evolved_observed, + recovered + ), + Err(PsychometricError::EvolvedObservedMeanIsNotExtraProcessObservedMean) + ); + assert_eq!( + refuse_impulse_observed_mean_as_extra_process_observed_mean( + impulse_observed, + recovered + ), + Err(PsychometricError::ImpulseObservedMeanIsNotExtraProcessObservedMean) + ); + assert_eq!( + refuse_extra_process_contribution_as_observed_mean(contribution, recovered), + Err(PsychometricError::ExtraProcessContributionIsNotObservedMean) + ); + assert_eq!( + refuse_extra_process_latent_mean_as_observed_mean(composed, recovered), + Err(PsychometricError::ExtraProcessLatentMeanIsNotObservedMean) + ); + } + + #[test] + fn extra_process_observed_mean_zero_loading_is_manifest_mean_and_refuses_clock() { + let loading = 2.0_f64; + let coupling = 0.4_f64; + let predictor = 3.0_f64; + let original = -0.5_f64; + let extra = -0.05_f64; + let delta = 2.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + assert_eq!( + recover_discrete_observed_mean_with_extra_process( + 0.0, + initial, + original, + intercept, + coupling, + predictor, + extra, + manifest_mean, + delta, + LagClock::EventTime + ), + Ok(manifest_mean) + ); + assert_eq!( + recover_discrete_observed_mean_with_extra_process( + loading, + initial, + original, + intercept, + coupling, + predictor, + extra, + manifest_mean, + delta, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn after_extra_process_observed_mean_recovers_driver_equation_five() { + let loading = 2.0_f64; + let coupling = 0.4_f64; + let predictor = 3.0_f64; + let original = -0.5_f64; + let extra = -0.05_f64; + let delta = 2.0_f64; + let elapsed = 1.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let recovered = recover_discrete_observed_mean_with_extra_process_after( + loading, + initial, + original, + intercept, + coupling, + predictor, + extra, + manifest_mean, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("eq5-after-extra-process-mean"); + let composed = recover_discrete_latent_mean_with_extra_process_after( + initial, + original, + intercept, + coupling, + predictor, + extra, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("after-extra-latent"); + let expected = manifest_mean + loading * composed; + assert!((recovered - expected).abs() < 1e-15); + let first_occasion = recover_discrete_observed_mean_with_extra_process( + loading, + initial, + original, + intercept, + coupling, + predictor, + extra, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-t0-extra-process-mean"); + assert!((first_occasion - recovered).abs() > 1e-3); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + original, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + assert!((evolved_observed - recovered).abs() > 1e-3); + let carry_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + original, + intercept, + coupling, + predictor, + manifest_mean, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("eq5-impulse-carry-mean"); + assert!((carry_observed - recovered).abs() > 1e-3); + let contribution = recover_level_change_extra_process_contribution_after( + coupling, + predictor, + original, + extra, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("after-extra-process"); + assert_eq!( + refuse_extra_process_observed_mean_as_after_extra_process_observed_mean( + first_occasion, + recovered + ), + Err(PsychometricError::ExtraProcessObservedMeanIsNotAfterExtraProcessObservedMean) + ); + assert_eq!( + refuse_evolved_observed_mean_as_after_extra_process_observed_mean( + evolved_observed, + recovered + ), + Err(PsychometricError::EvolvedObservedMeanIsNotAfterExtraProcessObservedMean) + ); + assert_eq!( + refuse_impulse_carry_observed_mean_as_after_extra_process_observed_mean( + carry_observed, + recovered + ), + Err(PsychometricError::ImpulseCarryObservedMeanIsNotAfterExtraProcessObservedMean) + ); + assert_eq!( + refuse_after_extra_process_contribution_as_observed_mean(contribution, recovered), + Err(PsychometricError::AfterExtraProcessContributionIsNotObservedMean) + ); + assert_eq!( + refuse_after_extra_process_latent_mean_as_observed_mean(composed, recovered), + Err(PsychometricError::AfterExtraProcessLatentMeanIsNotObservedMean) + ); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn after_extra_process_contribution_refuses_non_interior_interval() { + let coupling = 0.4_f64; + let predictor = 3.0_f64; + let original = -0.5_f64; + let extra = -0.05_f64; + assert_eq!( + recover_level_change_extra_process_contribution_after( + coupling, + predictor, + original, + extra, + 2.0, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_level_change_extra_process_contribution_after( + coupling, + predictor, + original, + extra, + 2.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_observed_mean_with_extra_process_after( + 2.0, + 1.0, + original, + 0.3, + coupling, + predictor, + extra, + 0.5, + 2.0, + 1.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_observed_mean_with_extra_process_after( + 0.0, + 1.0, + original, + 0.3, + coupling, + predictor, + extra, + 0.5, + 2.0, + 1.0, + LagClock::EventTime + ), + Ok(0.5) + ); + assert_eq!( + recover_level_change_extra_process_contribution_after( + coupling, + predictor, + original, + extra, + 2.0, + 1.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_level_change_extra_process_contribution_after( + coupling, + predictor, + original, + extra, + 0.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_level_change_extra_process_contribution_after( + coupling, + predictor, + original, + extra, + f64::NAN, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_latent_mean_with_extra_process_after( + 1.0, + original, + 0.3, + coupling, + predictor, + extra, + 2.0, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + let evolved = recover_discrete_latent_mean(1.0, original, 0.3, 2.0, LagClock::EventTime) + .expect("mu-t"); + assert_eq!( + recover_discrete_latent_mean_with_extra_process_after( + 1.0, + original, + 0.3, + 0.0, + predictor, + extra, + 2.0, + 1.0, + LagClock::EventTime + ), + Ok(evolved) + ); + } + + #[test] + fn asymptotic_time_independent_effect_recovers_driver_section_seven_point_two() { + // Driver et al. (2017, §7.2, p. 21) print LeisureTime + // TIPREDEFFECT = −0.225 and asymTIPREDEFFECT = −1.673 for a + // unit increase. Reconstruct a = −B / asym. + let effect = -0.225_f64; + let predictor = 1.0_f64; + let printed_asym = -1.673_f64; + let log_rate = -effect / printed_asym; + let recovered = recover_asymptotic_time_independent_predictor_effect( + effect, + predictor, + log_rate, + LagClock::EventTime, + ) + .expect("asymTIPREDEFFECT"); + let expected = -(effect * predictor) / log_rate; + assert!((recovered - expected).abs() < 1e-15); + assert!((recovered - printed_asym).abs() < 1e-12); + let happiness = recover_asymptotic_time_independent_predictor_effect( + 0.549, + 1.0, + -0.549 / 0.219, + LagClock::EventTime, + ) + .expect("happiness-asym"); + assert!((happiness - 0.219).abs() < 1e-12); + assert_eq!( + recover_asymptotic_time_independent_predictor_effect( + 0.0, + predictor, + 0.0, + LagClock::EventTime + ), + Ok(0.0) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_effect( + effect, + 0.0, + 0.0, + LagClock::EventTime + ), + Ok(0.0) + ); + } + + #[test] + fn asymptotic_time_independent_effect_is_not_coefficient_discrete_cint_or_impulse() { + let effect = -0.225_f64; + let predictor = 2.0_f64; + let log_rate = -0.134_488_942_f64; + let recovered = recover_asymptotic_time_independent_predictor_effect( + effect, + predictor, + log_rate, + LagClock::EventTime, + ) + .expect("asymTIPREDEFFECT"); + let discrete = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + log_rate, + 1.0, + LagClock::EventTime, + ) + .expect("discreteTIPREDEFFECT"); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("impulse"); + assert!((recovered - effect).abs() > 1e-3); + assert!((recovered - discrete).abs() > 1e-3); + assert!((recovered - impulse).abs() > 1e-3); + assert_eq!( + refuse_asymptotic_time_independent_effect_as_coefficient(recovered, effect), + Err(PsychometricError::AsymptoticTimeIndependentEffectIsNotCoefficient) + ); + assert_eq!( + refuse_asymptotic_time_independent_effect_as_discrete_effect(recovered, discrete), + Err(PsychometricError::AsymptoticTimeIndependentEffectIsNotDiscreteEffect) + ); + assert_eq!( + refuse_asymptotic_time_independent_effect_as_continuous_intercept(recovered, 0.3), + Err(PsychometricError::AsymptoticTimeIndependentEffectIsNotContinuousIntercept) + ); + assert_eq!( + refuse_asymptotic_time_independent_effect_as_time_dependent_impulse(recovered, impulse), + Err(PsychometricError::AsymptoticTimeIndependentEffectIsNotTimeDependentImpulse) + ); + } + + #[test] + fn asymptotic_time_independent_effect_invalid_inputs_fail_closed() { + let effect = -0.225_f64; + let predictor = 1.0_f64; + let log_rate = -0.134_488_942_f64; + assert_eq!( + recover_asymptotic_time_independent_predictor_effect( + effect, + predictor, + log_rate, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_effect( + effect, + predictor, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_effect( + effect, + predictor, + 0.5, + LagClock::EventTime + ), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_effect( + f64::NAN, + predictor, + log_rate, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_effect( + 1e308, + 2.0, + log_rate, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_effect( + 1e308, + 1.0, + -1e-308, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn asymptotic_time_independent_variance_recovers_driver_section_seven_point_two() { + // Driver et al. (2017, §7.2, p. 21) print LeisureTime + // asymTIPREDEFFECT = −1.673. addedTIPREDVAR is the variance of + // that mean shift. Reconstruct a from B and the printed total + // change; the printed 2.838 is the 2-latent TRAITVAR model, not + // this scalar map. + let effect = -0.225_f64; + let printed_asym = -1.673_f64; + let log_rate = -effect / printed_asym; + let predictor_variance = 1.0_f64; + let recovered = recover_asymptotic_time_independent_predictor_variance( + effect, + predictor_variance, + log_rate, + LagClock::EventTime, + ) + .expect("addedTIPREDVAR"); + let expected = printed_asym * printed_asym * predictor_variance; + assert!((recovered - expected).abs() < 1e-12); + let doubled = recover_asymptotic_time_independent_predictor_variance( + effect, + 2.0, + log_rate, + LagClock::EventTime, + ) + .expect("doubled-v"); + assert!((doubled - 2.0 * expected).abs() < 1e-12); + assert_eq!( + recover_asymptotic_time_independent_predictor_variance( + 0.0, + predictor_variance, + 0.0, + LagClock::EventTime + ), + Ok(0.0) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_variance( + effect, + 0.0, + 0.0, + LagClock::EventTime + ), + Ok(0.0) + ); + } + + #[test] + fn asymptotic_time_independent_variance_is_not_trait_stationary_or_mean_effect() { + let effect = -0.225_f64; + let log_rate = -0.134_488_942_f64; + let predictor_variance = 2.0_f64; + let recovered = recover_asymptotic_time_independent_predictor_variance( + effect, + predictor_variance, + log_rate, + LagClock::EventTime, + ) + .expect("addedTIPREDVAR"); + let mean_effect = recover_asymptotic_time_independent_predictor_effect( + effect, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("asymTIPREDEFFECT"); + let stationary = recover_stationary_latent_variance(0.4, log_rate, LagClock::EventTime) + .expect("asymDIFFUSION"); + let trait_plus = recover_trait_plus_state_latent_variance(0.8, 0.3).expect("trait"); + assert!((recovered - mean_effect).abs() > 1e-3); + assert!((recovered - stationary).abs() > 1e-3); + assert!((recovered - trait_plus).abs() > 1e-3); + assert_eq!( + refuse_asymptotic_time_independent_variance_as_trait_variance(recovered, trait_plus), + Err(PsychometricError::AsymptoticTimeIndependentVarianceIsNotTraitVariance) + ); + assert_eq!( + refuse_asymptotic_time_independent_variance_as_stationary_within_subject( + recovered, stationary + ), + Err(PsychometricError::AsymptoticTimeIndependentVarianceIsNotStationaryWithinSubject) + ); + assert_eq!( + refuse_asymptotic_time_independent_variance_as_asymptotic_effect( + recovered, + mean_effect + ), + Err(PsychometricError::AsymptoticTimeIndependentVarianceIsNotAsymptoticEffect) + ); + } + + #[test] + fn asymptotic_time_independent_variance_invalid_inputs_fail_closed() { + let effect = -0.225_f64; + let log_rate = -0.134_488_942_f64; + assert_eq!( + recover_asymptotic_time_independent_predictor_variance( + effect, + 1.0, + log_rate, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_variance( + effect, + 1.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_variance( + effect, + -1.0, + log_rate, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_variance( + 1e308, + 1.0, + -1e-308, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_variance( + 1.0, + 1.0, + -1e-308, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_variance( + 1e200, + 1.0, + -1e-200, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn asymptotic_continuous_intercept_recovers_driver_table_two() { + // Driver et al. (2017, Table 2, p. 12; Eq. 3, p. 5; p. 16) + // name asymCINT the Δt → ∞ intercept contribution −κ / a. + // Reconstruct a from the printed LeisureTime TIPREDEFFECT + // −0.225 / asymTIPREDEFFECT −1.673. The printed 2-latent CINT + // values are not this scalar map. + let printed_effect = -0.225_f64; + let printed_asym = -1.673_f64; + let log_rate = -printed_effect / printed_asym; + let intercept = 0.3_f64; + let recovered = + recover_asymptotic_continuous_intercept(intercept, log_rate, LagClock::EventTime) + .expect("asymCINT"); + let expected = intercept / -log_rate; + assert!((recovered - expected).abs() < 1e-12); + let unit = recover_asymptotic_continuous_intercept(1.0, log_rate, LagClock::EventTime) + .expect("unit-asymCINT"); + assert!((unit - 1.0 / -log_rate).abs() < 1e-12); + let synthetic = recover_asymptotic_continuous_intercept(0.3, -0.5, LagClock::EventTime) + .expect("synthetic"); + assert!((synthetic - 0.6).abs() < 1e-15); + let large_delta = recover_discrete_continuous_intercept_effect( + intercept, + log_rate, + 1e8, + LagClock::EventTime, + ) + .expect("large-delta"); + assert!((recovered - large_delta).abs() < 1e-9); + assert_eq!( + recover_asymptotic_continuous_intercept(0.0, 0.0, LagClock::EventTime), + Ok(0.0) + ); + assert_eq!( + recover_asymptotic_continuous_intercept(0.0, 0.5, LagClock::EventTime), + Ok(0.0) + ); + } + + #[test] + fn asymptotic_continuous_intercept_is_not_cint_increment_t0_or_tipred() { + let intercept = 0.3_f64; + let log_rate = -0.134_488_942_f64; + let recovered = + recover_asymptotic_continuous_intercept(intercept, log_rate, LagClock::EventTime) + .expect("asymCINT"); + let discrete = recover_discrete_continuous_intercept_effect( + intercept, + log_rate, + 1.0, + LagClock::EventTime, + ) + .expect("dtCINT"); + let tipred = recover_asymptotic_time_independent_predictor_effect( + -0.225, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("asymTIPREDEFFECT"); + assert!((recovered - intercept).abs() > 1e-3); + assert!((recovered - discrete).abs() > 1e-3); + assert!((recovered - 2.823).abs() > 1e-3); + assert!((recovered - tipred).abs() > 1e-3); + assert_eq!( + refuse_asymptotic_continuous_intercept_as_continuous_intercept(recovered, intercept), + Err(PsychometricError::AsymptoticContinuousInterceptIsNotContinuousIntercept) + ); + assert_eq!( + refuse_asymptotic_continuous_intercept_as_discrete_increment(recovered, discrete), + Err(PsychometricError::AsymptoticContinuousInterceptIsNotDiscreteIncrement) + ); + assert_eq!( + refuse_asymptotic_continuous_intercept_as_initial_latent_mean(recovered, 2.823), + Err(PsychometricError::AsymptoticContinuousInterceptIsNotInitialLatentMean) + ); + assert_eq!( + refuse_asymptotic_continuous_intercept_as_asymptotic_time_independent_effect( + recovered, tipred + ), + Err( + PsychometricError::AsymptoticContinuousInterceptIsNotAsymptoticTimeIndependentEffect + ) + ); + } + + #[test] + fn asymptotic_continuous_intercept_invalid_inputs_fail_closed() { + let intercept = 0.3_f64; + let log_rate = -0.134_488_942_f64; + assert_eq!( + recover_asymptotic_continuous_intercept(intercept, log_rate, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_asymptotic_continuous_intercept(intercept, 0.0, LagClock::EventTime), + Err(PsychometricError::AsymptoticContinuousInterceptRequiresStableDrift) + ); + assert_eq!( + recover_asymptotic_continuous_intercept(intercept, 0.5, LagClock::EventTime), + Err(PsychometricError::AsymptoticContinuousInterceptRequiresStableDrift) + ); + assert_eq!( + recover_asymptotic_continuous_intercept(f64::NAN, log_rate, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_asymptotic_continuous_intercept(1e308, -1e-308, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn stationary_initial_latent_mean_recovers_driver_page_sixteen() { + // Driver et al. (2017, p. 16; Table 2, p. 12; Eq. 3) + // constrain T0MEANS to model-implied values that include + // extra effects due to time-independent predictors + // (asymTIPREDEFFECT). Reconstruct a from printed LeisureTime + // TIPREDEFFECT −0.225 / asymTIPREDEFFECT −1.673. The printed + // 2-latent T0MEANS 2.823 is not this scalar map. + let printed_effect = -0.225_f64; + let printed_asym = -1.673_f64; + let log_rate = -printed_effect / printed_asym; + let intercept = 0.3_f64; + let recovered = recover_stationary_initial_latent_mean( + intercept, + printed_effect, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0MEANS"); + let intercept_only = + recover_asymptotic_continuous_intercept(intercept, log_rate, LagClock::EventTime) + .expect("asymCINT"); + let tipred = recover_asymptotic_time_independent_predictor_effect( + printed_effect, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("asymTIPREDEFFECT"); + assert!((recovered - (intercept_only + tipred)).abs() < 1e-12); + let synthetic = + recover_stationary_initial_latent_mean(0.3, 0.2, 1.0, -0.5, LagClock::EventTime) + .expect("synthetic"); + assert!((synthetic - 1.0).abs() < 1e-15); + let intercept_only_path = recover_stationary_initial_latent_mean( + intercept, + 0.0, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("intercept-only"); + assert!((intercept_only_path - intercept_only).abs() < 1e-15); + let tipred_only = recover_stationary_initial_latent_mean( + 0.0, + printed_effect, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("ti-only"); + assert!((tipred_only - tipred).abs() < 1e-15); + assert_eq!( + recover_stationary_initial_latent_mean(0.0, 0.0, 1.0, 0.0, LagClock::EventTime), + Ok(0.0) + ); + assert_eq!( + recover_stationary_initial_latent_mean(0.0, 0.0, 1.0, 0.5, LagClock::EventTime), + Ok(0.0) + ); + } + + #[test] + fn stationary_initial_latent_mean_is_not_t0_cint_tipred_or_discrete() { + let intercept = 0.3_f64; + let log_rate = -0.134_488_942_f64; + let recovered = recover_stationary_initial_latent_mean( + intercept, + -0.225, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0MEANS"); + let intercept_only = + recover_asymptotic_continuous_intercept(intercept, log_rate, LagClock::EventTime) + .expect("asymCINT"); + let tipred = recover_asymptotic_time_independent_predictor_effect( + -0.225, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("asymTIPREDEFFECT"); + let discrete = + recover_discrete_latent_mean(2.823, log_rate, intercept, 1.0, LagClock::EventTime) + .expect("μ_t"); + assert!((recovered - 2.823).abs() > 1e-3); + assert!((recovered - intercept_only).abs() > 1e-3); + assert!((recovered - tipred).abs() > 1e-3); + assert!((recovered - discrete).abs() > 1e-3); + assert_eq!( + refuse_stationary_initial_latent_mean_as_initial_latent_mean(recovered, 2.823), + Err(PsychometricError::StationaryInitialLatentMeanIsNotInitialLatentMean) + ); + assert_eq!( + refuse_stationary_initial_latent_mean_as_asymptotic_continuous_intercept( + recovered, + intercept_only + ), + Err(PsychometricError::StationaryInitialLatentMeanIsNotAsymptoticContinuousIntercept) + ); + assert_eq!( + refuse_stationary_initial_latent_mean_as_asymptotic_time_independent_effect( + recovered, tipred + ), + Err(PsychometricError::StationaryInitialLatentMeanIsNotAsymptoticTimeIndependentEffect) + ); + assert_eq!( + refuse_stationary_initial_latent_mean_as_discrete_mean(recovered, discrete), + Err(PsychometricError::StationaryInitialLatentMeanIsNotDiscreteMean) + ); + } + + #[test] + fn stationary_initial_latent_mean_invalid_inputs_fail_closed() { + let intercept = 0.3_f64; + let log_rate = -0.134_488_942_f64; + assert_eq!( + recover_stationary_initial_latent_mean( + intercept, + -0.225, + 1.0, + log_rate, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_stationary_initial_latent_mean(intercept, 0.0, 1.0, 0.0, LagClock::EventTime), + Err(PsychometricError::AsymptoticContinuousInterceptRequiresStableDrift) + ); + assert_eq!( + recover_stationary_initial_latent_mean(0.0, -0.225, 1.0, 0.5, LagClock::EventTime), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_stationary_initial_latent_mean( + f64::NAN, + -0.225, + 1.0, + log_rate, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_stationary_initial_latent_mean(1e308, 1e308, 1.0, -1e-308, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn stationary_initial_observed_mean_recovers_driver_equation_five_of_section_four_point_three() + { + // Driver et al. (2017, §4.3, pp. 9–10; Eq. 5, p. 5) + // constrain first-occasion means to the model-predicted + // mean. Equation 5 maps E(y_0) = τ + λ of that mean. + let printed_effect = -0.225_f64; + let printed_asym = -1.673_f64; + let log_rate = -printed_effect / printed_asym; + let intercept = 0.3_f64; + let loading = 2.0_f64; + let manifest_mean = 0.5_f64; + let recovered = recover_stationary_initial_observed_mean( + loading, + intercept, + printed_effect, + 1.0, + log_rate, + manifest_mean, + LagClock::EventTime, + ) + .expect("eq5-stationary-T0MEANS"); + let latent = recover_stationary_initial_latent_mean( + intercept, + printed_effect, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0MEANS"); + let expected = + recover_manifest_observed_mean(loading, latent, manifest_mean).expect("τ+λμ"); + assert!((recovered - expected).abs() < 1e-12); + let intercept_only = + recover_asymptotic_continuous_intercept(intercept, log_rate, LagClock::EventTime) + .expect("asymCINT"); + let intercept_only_observed = + recover_manifest_observed_mean(loading, intercept_only, manifest_mean) + .expect("τ+λ(−κ/a)"); + assert!((recovered - intercept_only_observed).abs() > 1e-3); + let free_initial_observed = + recover_manifest_observed_mean(loading, 2.823, manifest_mean).expect("τ+λμ_0"); + assert!((recovered - free_initial_observed).abs() > 1e-3); + let evolved_from_free = recover_discrete_observed_mean( + loading, + 2.823, + log_rate, + intercept, + manifest_mean, + 1.0, + LagClock::EventTime, + ) + .expect("τ+λμ_t"); + assert!((recovered - evolved_from_free).abs() > 1e-3); + assert!((recovered - manifest_mean).abs() > 1e-3); + assert!((recovered - latent).abs() > 1e-3); + assert_eq!( + recover_stationary_initial_observed_mean( + 0.0, + intercept, + printed_effect, + 1.0, + log_rate, + manifest_mean, + LagClock::EventTime, + ), + Ok(manifest_mean) + ); + assert_eq!( + recover_stationary_initial_observed_mean( + loading, + 0.0, + 0.0, + 1.0, + 0.0, + manifest_mean, + LagClock::EventTime, + ), + Ok(manifest_mean) + ); + let evolved_from_stationary = + recover_discrete_observed_mean_with_time_independent_predictor( + loading, + latent, + log_rate, + intercept, + printed_effect, + 1.0, + manifest_mean, + 2.0, + LagClock::EventTime, + ) + .expect("invariance"); + assert!((evolved_from_stationary - recovered).abs() < 1e-12); + let evolved_latent = recover_discrete_latent_mean_with_time_independent_predictor( + latent, + log_rate, + intercept, + printed_effect, + 1.0, + 2.0, + LagClock::EventTime, + ) + .expect("stationary invariance"); + assert!((evolved_latent - latent).abs() < 1e-12); + } + + #[test] + fn stationary_initial_observed_mean_is_not_manifest_latent_evolved_or_free() { + let intercept = 0.3_f64; + let log_rate = -0.134_488_942_f64; + let loading = 2.0_f64; + let manifest_mean = 0.5_f64; + let recovered = recover_stationary_initial_observed_mean( + loading, + intercept, + -0.225, + 1.0, + log_rate, + manifest_mean, + LagClock::EventTime, + ) + .expect("eq5-stationary-T0MEANS"); + let latent = recover_stationary_initial_latent_mean( + intercept, + -0.225, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0MEANS"); + let intercept_only = + recover_asymptotic_continuous_intercept(intercept, log_rate, LagClock::EventTime) + .expect("asymCINT"); + let intercept_only_observed = + recover_manifest_observed_mean(loading, intercept_only, manifest_mean) + .expect("τ+λ(−κ/a)"); + let free_initial_observed = + recover_manifest_observed_mean(loading, 2.823, manifest_mean).expect("τ+λμ_0"); + let evolved = recover_discrete_observed_mean( + loading, + 2.823, + log_rate, + intercept, + manifest_mean, + 1.0, + LagClock::EventTime, + ) + .expect("τ+λμ_t"); + assert_eq!( + refuse_stationary_initial_latent_mean_as_observed_mean(latent, recovered), + Err(PsychometricError::StationaryInitialLatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_stationary_initial_observed_mean_as_manifest_means(recovered, manifest_mean), + Err(PsychometricError::StationaryInitialObservedMeanIsNotManifestMeans) + ); + assert_eq!( + refuse_evolved_observed_mean_as_stationary_initial_observed_mean(evolved, recovered), + Err(PsychometricError::EvolvedObservedMeanIsNotStationaryInitialObservedMean) + ); + assert_eq!( + refuse_asymptotic_continuous_intercept_observed_mean_as_stationary_initial_observed_mean( + intercept_only_observed, + recovered + ), + Err( + PsychometricError::AsymptoticContinuousInterceptObservedMeanIsNotStationaryInitialObservedMean + ) + ); + assert_eq!( + refuse_initial_observed_mean_as_stationary_initial_observed_mean( + free_initial_observed, + recovered + ), + Err(PsychometricError::InitialObservedMeanIsNotStationaryInitialObservedMean) + ); + } + + #[test] + fn stationary_initial_observed_mean_invalid_inputs_fail_closed() { + let intercept = 0.3_f64; + let log_rate = -0.134_488_942_f64; + assert_eq!( + recover_stationary_initial_observed_mean( + 2.0, + intercept, + -0.225, + 1.0, + log_rate, + 0.5, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_stationary_initial_observed_mean( + 2.0, + intercept, + 0.0, + 1.0, + 0.0, + 0.5, + LagClock::EventTime + ), + Err(PsychometricError::AsymptoticContinuousInterceptRequiresStableDrift) + ); + assert_eq!( + recover_stationary_initial_observed_mean( + 2.0, + 0.0, + -0.225, + 1.0, + 0.5, + 0.5, + LagClock::EventTime + ), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_stationary_initial_observed_mean( + f64::NAN, + intercept, + -0.225, + 1.0, + log_rate, + 0.5, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_stationary_initial_observed_mean( + 2.0, + 1e308, + 1e308, + 1.0, + -1e-308, + 0.5, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn stationary_initial_latent_variance_recovers_driver_section_four_point_three() { + // Driver et al. (2017, §4.3, pp. 9–10; p. 16) constrain T0VAR + // to model-predicted variances. The scalar composition is + // trait + −q / (2 a) + (B / a)² v. Reconstruct a from printed + // LeisureTime TIPREDEFFECT −0.225 / asymTIPREDEFFECT −1.673. + // The printed 2-latent addedTIPREDVAR 2.838 is not this + // scalar map. + let printed_effect = -0.225_f64; + let printed_asym = -1.673_f64; + let log_rate = -printed_effect / printed_asym; + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let predictor_variance = 1.0_f64; + let recovered = recover_stationary_initial_latent_variance( + trait_variance, + diffusion, + printed_effect, + predictor_variance, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0VAR"); + let state = recover_stationary_latent_variance(diffusion, log_rate, LagClock::EventTime) + .expect("asymDIFFUSION"); + let trait_plus_state = + recover_trait_plus_state_latent_variance(trait_variance, state).expect("trait+state"); + let added = recover_asymptotic_time_independent_predictor_variance( + printed_effect, + predictor_variance, + log_rate, + LagClock::EventTime, + ) + .expect("addedTIPREDVAR"); + assert!((recovered - (trait_plus_state + added)).abs() < 1e-12); + let state_only = recover_stationary_initial_latent_variance( + 0.0, + diffusion, + 0.0, + predictor_variance, + log_rate, + LagClock::EventTime, + ) + .expect("state-only"); + assert!((state_only - state).abs() < 1e-15); + let trait_only = recover_stationary_initial_latent_variance( + trait_variance, + 0.0, + 0.0, + predictor_variance, + 0.0, + LagClock::EventTime, + ) + .expect("trait-only"); + assert!((trait_only - trait_variance).abs() < 1e-15); + let added_only = recover_stationary_initial_latent_variance( + 0.0, + 0.0, + printed_effect, + predictor_variance, + log_rate, + LagClock::EventTime, + ) + .expect("ti-only"); + assert!((added_only - added).abs() < 1e-15); + assert_eq!( + recover_stationary_initial_latent_variance( + 0.0, + 0.0, + 0.0, + predictor_variance, + 0.0, + LagClock::EventTime + ), + Ok(0.0) + ); + assert_eq!( + recover_stationary_initial_latent_variance( + 0.0, + 0.0, + 0.0, + predictor_variance, + 0.5, + LagClock::EventTime + ), + Ok(0.0) + ); + } + + #[test] + fn stationary_initial_latent_variance_is_not_t0_state_trait_tipred_or_discrete() { + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let log_rate = -0.134_488_942_f64; + let recovered = recover_stationary_initial_latent_variance( + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0VAR"); + let state = recover_stationary_latent_variance(diffusion, log_rate, LagClock::EventTime) + .expect("asymDIFFUSION"); + let added = recover_asymptotic_time_independent_predictor_variance( + -0.225, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("addedTIPREDVAR"); + let discrete = recover_discrete_latent_variance( + recovered, + diffusion, + log_rate, + 1.0, + LagClock::EventTime, + ) + .expect("Var(η_t)"); + assert!((recovered - 2.0).abs() > 1e-3); + assert!((recovered - state).abs() > 1e-3); + assert!((recovered - trait_variance).abs() > 1e-3); + assert!((recovered - added).abs() > 1e-3); + assert!((recovered - discrete).abs() > 1e-3); + assert!((recovered - 2.838).abs() > 1e-3); + assert_eq!( + refuse_stationary_initial_latent_variance_as_initial_latent_variance(recovered, 2.0), + Err(PsychometricError::StationaryInitialLatentVarianceIsNotInitialLatentVariance) + ); + assert_eq!( + refuse_stationary_initial_latent_variance_as_stationary_within_subject( + recovered, state + ), + Err(PsychometricError::StationaryInitialLatentVarianceIsNotStationaryWithinSubject) + ); + assert_eq!( + refuse_stationary_initial_latent_variance_as_trait_variance(recovered, trait_variance), + Err(PsychometricError::StationaryInitialLatentVarianceIsNotTraitVariance) + ); + assert_eq!( + refuse_stationary_initial_latent_variance_as_asymptotic_time_independent_variance( + recovered, added + ), + Err( + PsychometricError::StationaryInitialLatentVarianceIsNotAsymptoticTimeIndependentVariance + ) + ); + assert_eq!( + refuse_stationary_initial_latent_variance_as_discrete_variance(recovered, discrete), + Err(PsychometricError::StationaryInitialLatentVarianceIsNotDiscreteVariance) + ); + } + + #[test] + fn stationary_initial_latent_variance_invalid_inputs_fail_closed() { + assert_eq!( + recover_stationary_initial_latent_variance( + 1.0, + 0.4, + -0.225, + 1.0, + -0.13, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_stationary_initial_latent_variance( + 0.0, + 0.4, + 0.0, + 1.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_stationary_initial_latent_variance( + 0.0, + 0.0, + -0.225, + 1.0, + 0.5, + LagClock::EventTime + ), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_stationary_initial_latent_variance( + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + LagClock::EventTime + ), + Ok(0.0) + ); + assert_eq!( + recover_stationary_initial_latent_variance( + f64::NAN, + 0.4, + 0.0, + 0.0, + -0.5, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_stationary_initial_latent_variance( + f64::MAX, + f64::MAX, + 0.0, + 0.0, + -0.5, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_stationary_initial_latent_variance( + f64::MAX, + 0.0, + 1.0, + f64::MAX, + -1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn stationary_initial_observed_variance_recovers_driver_equation_five_of_section_four_point_three() + { + // Driver et al. (2017, §4.3, pp. 9–10; Eq. 5, p. 5) + // constrain first-occasion variances to the model-predicted + // variance. Equation 5 maps Var(y_0) = λ² of that variance + // plus θ + ψ. + let printed_effect = -0.225_f64; + let printed_asym = -1.673_f64; + let log_rate = -printed_effect / printed_asym; + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let loading = 2.0_f64; + let measurement_error = 0.5_f64; + let manifest_trait = 0.1_f64; + let recovered = recover_stationary_initial_observed_variance( + loading, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + measurement_error, + manifest_trait, + LagClock::EventTime, + ) + .expect("eq5-stationary-T0VAR"); + let latent = recover_stationary_initial_latent_variance( + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0VAR"); + let expected = recover_manifest_trait_plus_state_observed_variance( + loading, + latent, + measurement_error, + manifest_trait, + ) + .expect("λ²p+θ+ψ"); + assert!((recovered - expected).abs() < 1e-12); + let state = recover_stationary_latent_variance(diffusion, log_rate, LagClock::EventTime) + .expect("asymDIFFUSION"); + let state_only_observed = + recover_manifest_observed_variance(loading, state, measurement_error) + .expect("λ²(−q/2a)+θ"); + assert!((recovered - state_only_observed).abs() > 1e-3); + let free_initial_observed = + recover_manifest_observed_variance(loading, 2.0, measurement_error).expect("λ²p_0+θ"); + assert!((recovered - free_initial_observed).abs() > 1e-3); + let discrete = + recover_discrete_latent_variance(latent, diffusion, log_rate, 1.0, LagClock::EventTime) + .expect("Var(η_t)"); + let evolved = recover_manifest_observed_variance(loading, discrete, measurement_error) + .expect("λ²Var(η_t)+θ"); + assert!((recovered - evolved).abs() > 1e-3); + assert!((recovered - measurement_error).abs() > 1e-3); + assert!((recovered - latent).abs() > 1e-3); + assert_eq!( + recover_stationary_initial_observed_variance( + 0.0, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + measurement_error, + manifest_trait, + LagClock::EventTime, + ), + Ok(measurement_error + manifest_trait) + ); + assert_eq!( + recover_stationary_initial_observed_variance( + loading, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + measurement_error, + 0.0, + LagClock::EventTime, + ), + Ok(measurement_error) + ); + let zero_manifest_trait = recover_stationary_initial_observed_variance( + loading, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + measurement_error, + 0.0, + LagClock::EventTime, + ) + .expect("ψ=0"); + let expected_zero_psi = + recover_manifest_observed_variance(loading, latent, measurement_error).expect("λ²p+θ"); + assert!((zero_manifest_trait - expected_zero_psi).abs() < 1e-12); + } + + #[test] + fn stationary_initial_observed_variance_is_not_manifest_latent_evolved_or_free() { + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let log_rate = -0.134_488_942_f64; + let loading = 2.0_f64; + let measurement_error = 0.5_f64; + let recovered = recover_stationary_initial_observed_variance( + loading, + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + measurement_error, + 0.1, + LagClock::EventTime, + ) + .expect("eq5-stationary-T0VAR"); + let latent = recover_stationary_initial_latent_variance( + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0VAR"); + let state = recover_stationary_latent_variance(diffusion, log_rate, LagClock::EventTime) + .expect("asymDIFFUSION"); + let state_only_observed = + recover_manifest_observed_variance(loading, state, measurement_error) + .expect("λ²(−q/2a)+θ"); + let free_initial_observed = + recover_manifest_observed_variance(loading, 2.0, measurement_error).expect("λ²p_0+θ"); + let discrete = + recover_discrete_latent_variance(latent, diffusion, log_rate, 1.0, LagClock::EventTime) + .expect("Var(η_t)"); + let evolved = recover_manifest_observed_variance(loading, discrete, measurement_error) + .expect("λ²Var(η_t)+θ"); + assert_eq!( + refuse_stationary_initial_latent_variance_as_observed_variance(latent, recovered), + Err(PsychometricError::StationaryInitialLatentVarianceIsNotObservedVariance) + ); + assert_eq!( + refuse_stationary_initial_observed_variance_as_measurement_error( + recovered, + measurement_error + ), + Err(PsychometricError::StationaryInitialObservedVarianceIsNotMeasurementError) + ); + assert_eq!( + refuse_evolved_observed_variance_as_stationary_initial_observed_variance( + evolved, recovered + ), + Err(PsychometricError::EvolvedObservedVarianceIsNotStationaryInitialObservedVariance) + ); + assert_eq!( + refuse_stationary_within_subject_observed_variance_as_stationary_initial_observed_variance( + state_only_observed, + recovered + ), + Err( + PsychometricError::StationaryWithinSubjectObservedVarianceIsNotStationaryInitialObservedVariance + ) + ); + assert_eq!( + refuse_initial_observed_variance_as_stationary_initial_observed_variance( + free_initial_observed, + recovered + ), + Err(PsychometricError::InitialObservedVarianceIsNotStationaryInitialObservedVariance) + ); + } + + #[test] + fn stationary_initial_observed_variance_invalid_inputs_fail_closed() { + assert_eq!( + recover_stationary_initial_observed_variance( + 2.0, + 1.0, + 0.4, + -0.225, + 1.0, + -0.13, + 0.5, + 0.1, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_stationary_initial_observed_variance( + 2.0, + 0.0, + 0.4, + 0.0, + 1.0, + 0.0, + 0.5, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_stationary_initial_observed_variance( + 2.0, + 0.0, + 0.0, + -0.225, + 1.0, + 0.5, + 0.5, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_stationary_initial_observed_variance( + 2.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.5, + 0.1, + LagClock::EventTime + ), + Ok(0.6) + ); + assert_eq!( + recover_stationary_initial_observed_variance( + f64::NAN, + 1.0, + 0.4, + 0.0, + 0.0, + -0.5, + 0.5, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_stationary_initial_observed_variance( + 2.0, + f64::MAX, + f64::MAX, + 0.0, + 0.0, + -0.5, + 0.5, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn stationary_lagged_latent_covariance_recovers_driver_section_four_point_three() { + // Driver et al. (2017, §4.3, pp. 9–10; Eq. 3–4, pp. 4–5; p. 16) + // constrain T0VAR. The lagged covariance of that stationary + // process is trait + e^{a Δt}(−q / (2 a)) + (B / a)² v. + // Trait and addedTIPREDVAR do not decay. + let printed_effect = -0.225_f64; + let printed_asym = -1.673_f64; + let log_rate = -printed_effect / printed_asym; + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let predictor_variance = 1.0_f64; + let event_delta = 1.0_f64; + let recovered = recover_stationary_lagged_latent_covariance( + trait_variance, + diffusion, + printed_effect, + predictor_variance, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("stationary lagged T0VAR"); + let state = recover_stationary_latent_variance(diffusion, log_rate, LagClock::EventTime) + .expect("asymDIFFUSION"); + let trait_plus_state = recover_trait_plus_state_lagged_covariance( + trait_variance, + state, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("trait+state lagged"); + let added = recover_asymptotic_time_independent_predictor_variance( + printed_effect, + predictor_variance, + log_rate, + LagClock::EventTime, + ) + .expect("addedTIPREDVAR"); + assert!((recovered - (trait_plus_state + added)).abs() < 1e-12); + let contemporaneous = recover_stationary_initial_latent_variance( + trait_variance, + diffusion, + printed_effect, + predictor_variance, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0VAR"); + assert!((recovered - contemporaneous).abs() > 1e-3); + let decayed = recover_discrete_lagged_latent_covariance( + contemporaneous, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("e^{aΔt} p_stat"); + assert!((recovered - decayed).abs() > 1e-3); + let state_only = recover_stationary_lagged_latent_covariance( + 0.0, + diffusion, + 0.0, + predictor_variance, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("state-only lagged"); + let lagged_state = recover_discrete_lagged_latent_covariance( + state, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("e^{aΔt} asymDIFFUSION"); + assert!((state_only - lagged_state).abs() < 1e-15); + let trait_only = recover_stationary_lagged_latent_covariance( + trait_variance, + 0.0, + 0.0, + predictor_variance, + 0.0, + event_delta, + LagClock::EventTime, + ) + .expect("trait-only lagged"); + assert!((trait_only - trait_variance).abs() < 1e-15); + let added_only = recover_stationary_lagged_latent_covariance( + 0.0, + 0.0, + printed_effect, + predictor_variance, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("ti-only lagged"); + assert!((added_only - added).abs() < 1e-15); + assert_eq!( + recover_stationary_lagged_latent_covariance( + 0.0, + 0.0, + 0.0, + predictor_variance, + 0.0, + event_delta, + LagClock::EventTime + ), + Ok(0.0) + ); + let far = recover_stationary_lagged_latent_covariance( + trait_variance, + diffusion, + printed_effect, + predictor_variance, + log_rate, + 1e8, + LagClock::EventTime, + ) + .expect("Δt→∞"); + assert!((far - (trait_variance + added)).abs() < 1e-12); + let near = recover_stationary_lagged_latent_covariance( + trait_variance, + diffusion, + printed_effect, + predictor_variance, + log_rate, + 1e-12, + LagClock::EventTime, + ) + .expect("Δt→0+"); + assert!((near - contemporaneous).abs() < 1e-9); + } + + #[test] + fn stationary_lagged_latent_covariance_is_not_contemporaneous_decayed_or_trait_state() { + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let log_rate = -0.134_488_942_f64; + let event_delta = 1.0_f64; + let recovered = recover_stationary_lagged_latent_covariance( + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("stationary lagged T0VAR"); + let contemporaneous = recover_stationary_initial_latent_variance( + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0VAR"); + let decayed = recover_discrete_lagged_latent_covariance( + contemporaneous, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("e^{aΔt} p_stat"); + let state = recover_stationary_latent_variance(diffusion, log_rate, LagClock::EventTime) + .expect("asymDIFFUSION"); + let trait_plus_state = recover_trait_plus_state_lagged_covariance( + trait_variance, + state, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("trait+state lagged"); + assert!((recovered - contemporaneous).abs() > 1e-3); + assert!((recovered - decayed).abs() > 1e-3); + assert!((recovered - trait_plus_state).abs() > 1e-3); + assert_eq!( + refuse_stationary_lagged_latent_covariance_as_stationary_initial_latent_variance( + recovered, + contemporaneous + ), + Err( + PsychometricError::StationaryLaggedLatentCovarianceIsNotStationaryInitialLatentVariance + ) + ); + assert_eq!( + refuse_stationary_lagged_latent_covariance_as_decayed_stationary_variance( + recovered, decayed + ), + Err(PsychometricError::StationaryLaggedLatentCovarianceIsNotDecayedStationaryVariance) + ); + assert_eq!( + refuse_trait_plus_state_lagged_covariance_as_stationary_lagged_latent_covariance( + trait_plus_state, + recovered + ), + Err( + PsychometricError::TraitPlusStateLaggedCovarianceIsNotStationaryLaggedLatentCovariance + ) + ); + } + + #[test] + fn stationary_lagged_latent_covariance_invalid_inputs_fail_closed() { + assert_eq!( + recover_stationary_lagged_latent_covariance( + 1.0, + 0.4, + -0.225, + 1.0, + -0.13, + 1.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_stationary_lagged_latent_covariance( + 1.0, + 0.4, + -0.225, + 1.0, + -0.13, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_stationary_lagged_latent_covariance( + 0.0, + 0.4, + 0.0, + 1.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_stationary_lagged_latent_covariance( + 0.0, + 0.0, + -0.225, + 1.0, + 0.5, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_stationary_lagged_latent_covariance( + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Ok(0.0) + ); + assert_eq!( + recover_stationary_lagged_latent_covariance( + f64::NAN, + 0.4, + 0.0, + 0.0, + -0.5, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_stationary_lagged_latent_covariance( + f64::MAX, + f64::MAX, + 0.0, + 0.0, + -0.5, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_stationary_lagged_latent_covariance( + f64::MAX, + 0.0, + 1.0, + f64::MAX, + -1.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn stationary_lagged_observed_covariance_recovers_driver_equation_five_of_section_four_point_three() + { + // Driver et al. (2017, §4.3, pp. 9–10; Eq. 5, p. 5) + // lagged observed covariance of stationary T0VAR is + // λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ. + // Θ does not enter. + let printed_effect = -0.225_f64; + let printed_asym = -1.673_f64; + let log_rate = -printed_effect / printed_asym; + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let loading = 2.0_f64; + let measurement_error = 0.5_f64; + let manifest_trait = 0.1_f64; + let event_delta = 1.0_f64; + let recovered = recover_stationary_lagged_observed_covariance( + loading, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + event_delta, + manifest_trait, + LagClock::EventTime, + ) + .expect("eq5-lagged-stationary-T0VAR"); + let latent = recover_stationary_lagged_latent_covariance( + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("stationary lagged T0VAR"); + let expected = recover_manifest_lagged_observed_covariance(loading, latent, manifest_trait) + .expect("λ²c+ψ"); + assert!((recovered - expected).abs() < 1e-12); + let contemporaneous = recover_stationary_initial_observed_variance( + loading, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + measurement_error, + manifest_trait, + LagClock::EventTime, + ) + .expect("eq5-stationary-T0VAR"); + assert!((recovered - contemporaneous).abs() > 1e-3); + assert!((recovered - measurement_error).abs() > 1e-3); + assert!((recovered - latent).abs() > 1e-3); + assert_eq!( + recover_stationary_lagged_observed_covariance( + 0.0, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + event_delta, + manifest_trait, + LagClock::EventTime, + ), + Ok(manifest_trait) + ); + assert_eq!( + recover_stationary_lagged_observed_covariance( + loading, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + event_delta, + 0.0, + LagClock::EventTime, + ), + Ok(0.0) + ); + let zero_manifest_trait = recover_stationary_lagged_observed_covariance( + loading, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + event_delta, + 0.0, + LagClock::EventTime, + ) + .expect("ψ=0"); + let expected_zero_psi = + recover_manifest_lagged_observed_covariance(loading, latent, 0.0).expect("λ²c"); + assert!((zero_manifest_trait - expected_zero_psi).abs() < 1e-12); + } + + #[test] + fn stationary_lagged_observed_covariance_is_not_manifest_latent_or_contemporaneous() { + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let log_rate = -0.134_488_942_f64; + let loading = 2.0_f64; + let measurement_error = 0.5_f64; + let event_delta = 1.0_f64; + let recovered = recover_stationary_lagged_observed_covariance( + loading, + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + event_delta, + 0.1, + LagClock::EventTime, + ) + .expect("eq5-lagged-stationary-T0VAR"); + let latent = recover_stationary_lagged_latent_covariance( + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("stationary lagged T0VAR"); + let contemporaneous = recover_stationary_initial_observed_variance( + loading, + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + measurement_error, + 0.1, + LagClock::EventTime, + ) + .expect("eq5-stationary-T0VAR"); + assert_eq!( + refuse_stationary_lagged_latent_covariance_as_observed_covariance(latent, recovered), + Err(PsychometricError::StationaryLaggedLatentCovarianceIsNotObservedCovariance) + ); + assert_eq!( + refuse_measurement_error_as_stationary_lagged_observed_covariance( + measurement_error, + recovered + ), + Err(PsychometricError::MeasurementErrorIsNotStationaryLaggedObservedCovariance) + ); + assert_eq!( + refuse_stationary_initial_observed_variance_as_stationary_lagged_observed_covariance( + contemporaneous, + recovered + ), + Err( + PsychometricError::StationaryInitialObservedVarianceIsNotStationaryLaggedObservedCovariance + ) + ); + } + + #[test] + fn stationary_lagged_observed_covariance_invalid_inputs_fail_closed() { + assert_eq!( + recover_stationary_lagged_observed_covariance( + 2.0, + 1.0, + 0.4, + -0.225, + 1.0, + -0.13, + 1.0, + 0.1, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_stationary_lagged_observed_covariance( + 2.0, + 1.0, + 0.4, + -0.225, + 1.0, + -0.13, + 0.0, + 0.1, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_stationary_lagged_observed_covariance( + 2.0, + 0.0, + 0.4, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_stationary_lagged_observed_covariance( + 2.0, + 0.0, + 0.0, + -0.225, + 1.0, + 0.5, + 1.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_stationary_lagged_observed_covariance( + 2.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.1, + LagClock::EventTime + ), + Ok(0.1) + ); + assert_eq!( + recover_stationary_lagged_observed_covariance( + f64::NAN, + 1.0, + 0.4, + 0.0, + 0.0, + -0.5, + 1.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_stationary_lagged_observed_covariance( + 2.0, + f64::MAX, + f64::MAX, + 0.0, + 0.0, + -0.5, + 1.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn stationary_later_latent_variance_recovers_driver_section_four_point_three() { + // Driver et al. (2017, §4.3, pp. 9–10; Eq. 3–4, pp. 4–5; p. 16) + // constrain T0VAR across all time points. The later-occasion + // variance is trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v. + // Under stationarity that equals contemporaneous T0VAR. + let printed_effect = -0.225_f64; + let printed_asym = -1.673_f64; + let log_rate = -printed_effect / printed_asym; + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let predictor_variance = 1.0_f64; + let event_delta = 1.0_f64; + let recovered = recover_stationary_later_latent_variance( + trait_variance, + diffusion, + printed_effect, + predictor_variance, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("stationary later T0VAR"); + let state = recover_stationary_latent_variance(diffusion, log_rate, LagClock::EventTime) + .expect("asymDIFFUSION"); + let evolved_state = recover_discrete_latent_variance( + state, + diffusion, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("e^{2aΔt}p+Q_Δt"); + let added = recover_asymptotic_time_independent_predictor_variance( + printed_effect, + predictor_variance, + log_rate, + LagClock::EventTime, + ) + .expect("addedTIPREDVAR"); + assert!((recovered - (trait_variance + evolved_state + added)).abs() < 1e-12); + let contemporaneous = recover_stationary_initial_latent_variance( + trait_variance, + diffusion, + printed_effect, + predictor_variance, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0VAR"); + assert!((recovered - contemporaneous).abs() < 1e-12); + let lagged = recover_stationary_lagged_latent_covariance( + trait_variance, + diffusion, + printed_effect, + predictor_variance, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("stationary lagged T0VAR"); + assert!((recovered - lagged).abs() > 1e-3); + let free_discrete = recover_discrete_latent_variance( + contemporaneous, + diffusion, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("e^{2aΔt} p_stat + Q_Δt"); + assert!((recovered - free_discrete).abs() > 1e-3); + let process_noise = + recover_discrete_process_noise(diffusion, log_rate, event_delta, LagClock::EventTime) + .expect("Q_Δt"); + assert!((recovered - process_noise).abs() > 1e-3); + let state_only = recover_stationary_later_latent_variance( + 0.0, + diffusion, + 0.0, + predictor_variance, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("state-only later"); + assert!((state_only - evolved_state).abs() < 1e-15); + assert!((state_only - state).abs() < 1e-12); + let trait_only = recover_stationary_later_latent_variance( + trait_variance, + 0.0, + 0.0, + predictor_variance, + 0.0, + event_delta, + LagClock::EventTime, + ) + .expect("trait-only later"); + assert!((trait_only - trait_variance).abs() < 1e-15); + let added_only = recover_stationary_later_latent_variance( + 0.0, + 0.0, + printed_effect, + predictor_variance, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("ti-only later"); + assert!((added_only - added).abs() < 1e-15); + assert_eq!( + recover_stationary_later_latent_variance( + 0.0, + 0.0, + 0.0, + predictor_variance, + 0.0, + event_delta, + LagClock::EventTime + ), + Ok(0.0) + ); + let far = recover_stationary_later_latent_variance( + trait_variance, + diffusion, + printed_effect, + predictor_variance, + log_rate, + 1e8, + LagClock::EventTime, + ) + .expect("Δt→∞"); + assert!((far - contemporaneous).abs() < 1e-12); + let near = recover_stationary_later_latent_variance( + trait_variance, + diffusion, + printed_effect, + predictor_variance, + log_rate, + 1e-12, + LagClock::EventTime, + ) + .expect("Δt→0+"); + assert!((near - contemporaneous).abs() < 1e-9); + } + + #[test] + fn stationary_later_latent_variance_is_not_lagged_discrete_or_process_noise() { + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let log_rate = -0.134_488_942_f64; + let event_delta = 1.0_f64; + let recovered = recover_stationary_later_latent_variance( + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("stationary later T0VAR"); + let lagged = recover_stationary_lagged_latent_covariance( + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("stationary lagged T0VAR"); + let contemporaneous = recover_stationary_initial_latent_variance( + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0VAR"); + let free_discrete = recover_discrete_latent_variance( + contemporaneous, + diffusion, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("e^{2aΔt} p_stat + Q_Δt"); + let process_noise = + recover_discrete_process_noise(diffusion, log_rate, event_delta, LagClock::EventTime) + .expect("Q_Δt"); + assert!((recovered - lagged).abs() > 1e-3); + assert!((recovered - free_discrete).abs() > 1e-3); + assert!((recovered - process_noise).abs() > 1e-3); + assert_eq!( + refuse_stationary_later_latent_variance_as_lagged_covariance(recovered, lagged), + Err(PsychometricError::StationaryLaterLatentVarianceIsNotLaggedCovariance) + ); + assert_eq!( + refuse_stationary_later_latent_variance_as_discrete_variance(recovered, free_discrete), + Err(PsychometricError::StationaryLaterLatentVarianceIsNotDiscreteVariance) + ); + assert_eq!( + refuse_stationary_later_latent_variance_as_process_noise(recovered, process_noise), + Err(PsychometricError::StationaryLaterLatentVarianceIsNotProcessNoise) + ); + } + + #[test] + fn stationary_later_latent_variance_invalid_inputs_fail_closed() { + assert_eq!( + recover_stationary_later_latent_variance( + 1.0, + 0.4, + -0.225, + 1.0, + -0.13, + 1.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_stationary_later_latent_variance( + 1.0, + 0.4, + -0.225, + 1.0, + -0.13, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_stationary_later_latent_variance( + 0.0, + 0.4, + 0.0, + 1.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_stationary_later_latent_variance( + 0.0, + 0.0, + -0.225, + 1.0, + 0.5, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_stationary_later_latent_variance( + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Ok(0.0) + ); + assert_eq!( + recover_stationary_later_latent_variance( + f64::NAN, + 0.4, + 0.0, + 0.0, + -0.5, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_stationary_later_latent_variance( + f64::MAX, + f64::MAX, + 0.0, + 0.0, + -0.5, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_stationary_later_latent_variance( + f64::MAX, + 0.0, + 1.0, + f64::MAX, + -1.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn stationary_later_observed_variance_recovers_driver_equation_five_of_section_four_point_three() + { + // Driver et al. (2017, §4.3, pp. 9–10; Eq. 5, p. 5) + // later-occasion observed variance of stationary T0VAR is + // λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ. + // Under stationarity that equals contemporaneous Var(y_0). + let printed_effect = -0.225_f64; + let printed_asym = -1.673_f64; + let log_rate = -printed_effect / printed_asym; + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let loading = 2.0_f64; + let measurement_error = 0.5_f64; + let manifest_trait = 0.1_f64; + let event_delta = 1.0_f64; + let recovered = recover_stationary_later_observed_variance( + loading, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + event_delta, + measurement_error, + manifest_trait, + LagClock::EventTime, + ) + .expect("eq5-later-stationary-T0VAR"); + let latent = recover_stationary_later_latent_variance( + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("stationary later T0VAR"); + let expected = recover_manifest_trait_plus_state_observed_variance( + loading, + latent, + measurement_error, + manifest_trait, + ) + .expect("λ²p+θ+ψ"); + assert!((recovered - expected).abs() < 1e-12); + let contemporaneous = recover_stationary_initial_observed_variance( + loading, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + measurement_error, + manifest_trait, + LagClock::EventTime, + ) + .expect("eq5-stationary-T0VAR"); + assert!((recovered - contemporaneous).abs() < 1e-12); + let lagged = recover_stationary_lagged_observed_covariance( + loading, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + event_delta, + manifest_trait, + LagClock::EventTime, + ) + .expect("eq5-lagged-stationary-T0VAR"); + assert!((recovered - lagged).abs() > 1e-3); + assert!((recovered - measurement_error).abs() > 1e-3); + assert!((recovered - latent).abs() > 1e-3); + assert_eq!( + recover_stationary_later_observed_variance( + 0.0, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + event_delta, + measurement_error, + manifest_trait, + LagClock::EventTime, + ), + Ok(measurement_error + manifest_trait) + ); + assert_eq!( + recover_stationary_later_observed_variance( + loading, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + event_delta, + 0.0, + 0.0, + LagClock::EventTime, + ), + Ok(0.0) + ); + let zero_manifest_trait = recover_stationary_later_observed_variance( + loading, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + event_delta, + measurement_error, + 0.0, + LagClock::EventTime, + ) + .expect("ψ=0"); + let expected_zero_psi = recover_manifest_trait_plus_state_observed_variance( + loading, + latent, + measurement_error, + 0.0, + ) + .expect("λ²p+θ"); + assert!((zero_manifest_trait - expected_zero_psi).abs() < 1e-12); + } + + #[test] + fn stationary_later_observed_variance_is_not_manifest_latent_or_lagged() { + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let log_rate = -0.134_488_942_f64; + let loading = 2.0_f64; + let measurement_error = 0.5_f64; + let event_delta = 1.0_f64; + let recovered = recover_stationary_later_observed_variance( + loading, + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + event_delta, + measurement_error, + 0.1, + LagClock::EventTime, + ) + .expect("eq5-later-stationary-T0VAR"); + let latent = recover_stationary_later_latent_variance( + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("stationary later T0VAR"); + let lagged = recover_stationary_lagged_observed_covariance( + loading, + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + event_delta, + 0.1, + LagClock::EventTime, + ) + .expect("eq5-lagged-stationary-T0VAR"); + assert_eq!( + refuse_stationary_later_latent_variance_as_observed_variance(latent, recovered), + Err(PsychometricError::StationaryLaterLatentVarianceIsNotObservedVariance) + ); + assert_eq!( + refuse_measurement_error_as_stationary_later_observed_variance( + measurement_error, + recovered + ), + Err(PsychometricError::MeasurementErrorIsNotStationaryLaterObservedVariance) + ); + assert_eq!( + refuse_stationary_lagged_observed_covariance_as_stationary_later_observed_variance( + lagged, recovered + ), + Err( + PsychometricError::StationaryLaggedObservedCovarianceIsNotStationaryLaterObservedVariance + ) + ); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn stationary_later_observed_variance_invalid_inputs_fail_closed() { + assert_eq!( + recover_stationary_later_observed_variance( + 2.0, + 1.0, + 0.4, + -0.225, + 1.0, + -0.13, + 1.0, + 0.5, + 0.1, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_stationary_later_observed_variance( + 2.0, + 1.0, + 0.4, + -0.225, + 1.0, + -0.13, + 0.0, + 0.5, + 0.1, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_stationary_later_observed_variance( + 2.0, + 0.0, + 0.4, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_stationary_later_observed_variance( + 2.0, + 0.0, + 0.0, + -0.225, + 1.0, + 0.5, + 1.0, + 0.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_stationary_later_observed_variance( + 2.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.5, + 0.1, + LagClock::EventTime + ), + Ok(0.6) + ); + assert_eq!( + recover_stationary_later_observed_variance( + f64::NAN, + 1.0, + 0.4, + 0.0, + 0.0, + -0.5, + 1.0, + 0.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_stationary_later_observed_variance( + 2.0, + f64::MAX, + f64::MAX, + 0.0, + 0.0, + -0.5, + 1.0, + 0.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn discrete_observed_mean_with_impulse_recovers_driver_equation_five() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let recovered = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + let composed = recover_discrete_latent_mean_with_impulse( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("mx"); + let expected = manifest_mean + loading * composed; + assert!((recovered - expected).abs() < 1e-15); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + assert!((evolved_observed - recovered).abs() > 1e-3); + let carried_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + 1.0, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + assert!((carried_observed - recovered).abs() > 1e-3); + assert_eq!( + recover_discrete_observed_mean_with_impulse( + 0.0, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime + ), + Ok(manifest_mean) + ); + assert_eq!( + recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + 0.0, + delta, + LagClock::EventTime + ), + Ok(loading * composed) + ); + } + + #[test] + fn discrete_observed_mean_with_impulse_is_not_evolved_or_zero_impulse() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let recovered = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let zero_impulse = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + 0.0, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("zero-impulse"); + assert!((zero_impulse - evolved_observed).abs() < 1e-15); + assert!((recovered - evolved_observed).abs() > 1e-3); + } + + #[test] + fn discrete_observed_mean_with_impulse_refuses_evolved_mean_and_overflow() { + let loading = 2.0_f64; + let recovered = recover_discrete_observed_mean_with_impulse( + loading, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + let composed = recover_discrete_latent_mean_with_impulse( + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 2.0, + LagClock::EventTime, + ) + .expect("mx"); + let evolved_observed = + recover_discrete_observed_mean(loading, 1.0, -0.5, 0.3, 0.5, 2.0, LagClock::EventTime) + .expect("eq3-eq5-mean"); + let carried_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + 1.0, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + assert_eq!( + refuse_evolved_observed_mean_as_impulse_observed_mean(evolved_observed, recovered), + Err(PsychometricError::EvolvedObservedMeanIsNotImpulseObservedMean) + ); + assert_eq!( + refuse_impulse_observed_mean_as_impulse_carry_observed_mean( + recovered, + carried_observed + ), + Err(PsychometricError::ImpulseObservedMeanIsNotImpulseCarryObservedMean) + ); + assert_eq!( + refuse_latent_mean_as_observed_mean(composed, recovered), + Err(PsychometricError::LatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_manifest_means_as_observed_mean(0.5, recovered), + Err(PsychometricError::ManifestMeansIsNotObservedMean) + ); + let scaled = recover_discrete_observed_mean_with_impulse( + 1e308, + 1e-308, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + LagClock::EventTime, + ) + .expect("scale"); + assert!((scaled - 1.0).abs() < 1e-15); + let finite_loaded = recover_discrete_observed_mean_with_impulse( + 1e308, + 1.0, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + LagClock::EventTime, + ) + .expect("lambda-mu"); + assert!((finite_loaded - 1e308).abs() / 1e308 < 1e-15); + } + + #[test] + fn discrete_observed_mean_with_impulse_invalid_inputs_fail_closed() { + assert_eq!( + recover_discrete_observed_mean_with_impulse( + f64::NAN, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_observed_mean_with_impulse( + 1e308, + 2.0, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_observed_mean_with_impulse( + 1.0, + 1.0, + 710.0, + 0.0, + 0.0, + 3.0, + 0.5, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_observed_mean_with_impulse( + 1e308, + 0.0, + 0.0, + 0.0, + 1e308, + 1.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_observed_mean_with_impulse( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_observed_mean_with_impulse( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + } + + #[test] + fn time_independent_predictor_recovers_driver_equation_three_second_summand() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let increment = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("tipred"); + let expected = + recover_discrete_constant_predictor_effect(1.2, drift, delta, LagClock::EventTime) + .expect("bz-map"); + assert!((increment - expected).abs() < 1e-15); + assert_eq!( + recover_discrete_time_independent_predictor_effect( + 0.0, + predictor, + drift, + delta, + LagClock::EventTime + ), + Ok(0.0) + ); + assert_eq!( + recover_discrete_time_independent_predictor_effect( + effect, + 0.0, + drift, + delta, + LagClock::EventTime + ), + Ok(0.0) + ); + let zero_drift = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + 0.0, + delta, + LagClock::EventTime, + ) + .expect("zero-drift"); + assert!((zero_drift - 2.4).abs() < 1e-15); + let intercept_effect = + recover_discrete_continuous_intercept_effect(effect, drift, delta, LagClock::EventTime) + .expect("cint"); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("tdpred"); + let equation_fourteen = recover_discrete_time_varying_predictor_effect( + effect, + delta, + delta, + delta, + LagClock::EventTime, + ) + .expect("eq14"); + assert!((increment - intercept_effect).abs() > 1e-3); + assert!((increment - impulse).abs() > 1e-3); + assert!((increment - equation_fourteen).abs() > 1e-3); + assert!((increment - effect).abs() > 1e-3); + } + + #[test] + fn time_independent_predictor_composes_evolved_mean_and_keeps_scale() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let increment = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("tipred"); + let initial = 1.0_f64; + let intercept = 0.3_f64; + let composed = recover_discrete_latent_mean_with_time_independent_predictor( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-tipred"); + let evolved = + recover_discrete_latent_mean(initial, drift, intercept, delta, LagClock::EventTime) + .expect("mu-t"); + assert!((composed - (evolved + increment)).abs() < 1e-15); + assert_eq!( + recover_discrete_latent_mean_with_time_independent_predictor( + initial, + drift, + intercept, + 0.0, + predictor, + delta, + LagClock::EventTime + ), + Ok(evolved) + ); + assert_eq!( + recover_discrete_latent_mean_with_time_independent_predictor( + 0.0, + drift, + 0.0, + effect, + predictor, + delta, + LagClock::EventTime + ), + Ok(increment) + ); + let scaled = recover_discrete_time_independent_predictor_effect( + 1e308, + 1e-308, + 0.0, + 1.0, + LagClock::EventTime, + ) + .expect("scale"); + assert!((scaled - 1.0).abs() < 1e-15); + } + + #[test] + fn time_independent_predictor_refuses_cint_impulse_equation_fourteen_and_coefficient() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let increment = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + -0.5, + 2.0, + LagClock::EventTime, + ) + .expect("tipred"); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("tdpred"); + let equation_fourteen = recover_discrete_time_varying_predictor_effect( + effect, + 2.0, + 2.0, + 2.0, + LagClock::EventTime, + ) + .expect("eq14"); + assert_eq!( + refuse_time_independent_effect_as_continuous_intercept(increment, effect), + Err(PsychometricError::TimeIndependentEffectIsNotContinuousIntercept) + ); + assert_eq!( + refuse_time_independent_effect_as_time_dependent_impulse(increment, impulse), + Err(PsychometricError::TimeIndependentEffectIsNotTimeDependentImpulse) + ); + assert_eq!( + refuse_time_independent_effect_as_time_varying_discrete_effect( + increment, + equation_fourteen + ), + Err(PsychometricError::TimeIndependentEffectIsNotTimeVaryingDiscreteEffect) + ); + assert_eq!( + refuse_time_independent_coefficient_as_discrete_effect(effect, increment), + Err(PsychometricError::TimeIndependentCoefficientIsNotDiscreteEffect) + ); + } + + #[test] + fn time_independent_predictor_invalid_inputs_fail_closed() { + assert_eq!( + recover_discrete_time_independent_predictor_effect( + f64::NAN, + 1.0, + -0.5, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_time_independent_predictor_effect( + 1.0, + f64::INFINITY, + -0.5, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_time_independent_predictor_effect( + 1e308, + 2.0, + -0.5, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_time_independent_predictor_effect( + 1e308, + 1.0, + 0.0, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_time_independent_predictor_effect( + 0.4, + 3.0, + -0.5, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_time_independent_predictor_effect( + 0.4, + 3.0, + -0.5, + 2.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_latent_mean_with_time_independent_predictor( + 1e308, + 0.0, + 0.0, + 1.0, + 1e308, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + // Latent mean is finite; Bz overflows. That `?` is not the sum overflow. + assert_eq!( + recover_discrete_latent_mean_with_time_independent_predictor( + 1.0, + -0.5, + 0.3, + 1e308, + 2.0, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn discrete_observed_mean_with_time_independent_predictor_recovers_driver_equation_five() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let recovered = recover_discrete_observed_mean_with_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-tipred-mean"); + let composed = recover_discrete_latent_mean_with_time_independent_predictor( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-tipred"); + let expected = manifest_mean + loading * composed; + assert!((recovered - expected).abs() < 1e-15); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let impulse_observed = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + let carried_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + 1.0, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + assert!((evolved_observed - recovered).abs() > 1e-3); + assert!((impulse_observed - recovered).abs() > 1e-3); + assert!((carried_observed - recovered).abs() > 1e-3); + assert_eq!( + recover_discrete_observed_mean_with_time_independent_predictor( + 0.0, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime + ), + Ok(manifest_mean) + ); + } + + #[test] + fn discrete_observed_mean_with_time_independent_predictor_is_not_evolved_or_zero_increment() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let recovered = recover_discrete_observed_mean_with_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-tipred-mean"); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let zero_increment = recover_discrete_observed_mean_with_time_independent_predictor( + loading, + initial, + drift, + intercept, + 0.0, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("zero-increment"); + assert!((zero_increment - evolved_observed).abs() < 1e-15); + assert!((recovered - evolved_observed).abs() > 1e-3); + } + + #[test] + fn discrete_observed_mean_with_time_independent_predictor_refuses_evolved_mean_and_overflow() { + let loading = 2.0_f64; + let recovered = recover_discrete_observed_mean_with_time_independent_predictor( + loading, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + LagClock::EventTime, + ) + .expect("eq5-tipred-mean"); + let evolved_observed = + recover_discrete_observed_mean(loading, 1.0, -0.5, 0.3, 0.5, 2.0, LagClock::EventTime) + .expect("eq3-eq5-mean"); + let impulse_observed = recover_discrete_observed_mean_with_impulse( + loading, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + let carried_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + 1.0, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + assert_eq!( + refuse_evolved_observed_mean_as_time_independent_observed_mean( + evolved_observed, + recovered + ), + Err(PsychometricError::EvolvedObservedMeanIsNotTimeIndependentObservedMean) + ); + assert_eq!( + refuse_impulse_observed_mean_as_time_independent_observed_mean( + impulse_observed, + recovered + ), + Err(PsychometricError::ImpulseObservedMeanIsNotTimeIndependentObservedMean) + ); + assert_eq!( + refuse_impulse_carry_observed_mean_as_time_independent_observed_mean( + carried_observed, + recovered + ), + Err(PsychometricError::ImpulseCarryObservedMeanIsNotTimeIndependentObservedMean) + ); + } + + #[test] + fn discrete_observed_mean_with_time_independent_predictor_invalid_inputs_fail_closed() { + let scaled = recover_discrete_observed_mean_with_time_independent_predictor( + 1e308, + 1e-308, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + LagClock::EventTime, + ) + .expect("scale"); + assert!((scaled - 1.0).abs() < 1e-15); + let finite_loaded = recover_discrete_observed_mean_with_time_independent_predictor( + 1e308, + 0.0, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + LagClock::EventTime, + ) + .expect("lambda-mu0"); + assert!((finite_loaded - 0.0).abs() < 1e-15); + assert_eq!( + recover_discrete_observed_mean_with_time_independent_predictor( + 1e308, + 2.0, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_observed_mean_with_time_independent_predictor( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_observed_mean_with_time_independent_predictor( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_observed_mean_with_time_independent_predictor( + 1e308, + 0.0, + 0.0, + 0.0, + 1e308, + 1.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn time_dependent_impulse_carry_recovers_driver_equation_one_two_dissipation() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let elapsed = 1.0_f64; + let carry = recover_time_dependent_predictor_impulse_carry( + effect, + predictor, + drift, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("tdpred-carry"); + let expected = (-0.5_f64).exp() * 1.2; + assert!((carry - expected).abs() < 1e-15); + assert_eq!( + recover_time_dependent_predictor_impulse_carry( + 0.0, + predictor, + drift, + delta, + elapsed, + LagClock::EventTime + ), + Ok(0.0) + ); + assert_eq!( + recover_time_dependent_predictor_impulse_carry( + effect, + 0.0, + drift, + delta, + elapsed, + LagClock::EventTime + ), + Ok(0.0) + ); + let zero_drift = recover_time_dependent_predictor_impulse_carry( + effect, + predictor, + 0.0, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("zero-drift"); + assert!((zero_drift - 1.2).abs() < 1e-15); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("tdpred"); + assert!((carry - impulse).abs() > 1e-3); + let vanished = recover_time_dependent_predictor_impulse_carry( + effect, + predictor, + -800.0, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("vanish"); + assert_eq!(vanished.to_bits(), 0.0_f64.to_bits()); + } + + #[test] + fn time_dependent_impulse_carry_composes_evolved_mean_and_keeps_scale() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let elapsed = 1.0_f64; + let carry = recover_time_dependent_predictor_impulse_carry( + effect, + predictor, + drift, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("tdpred-carry"); + let initial = 1.0_f64; + let intercept = 0.3_f64; + let composed = recover_discrete_latent_mean_with_impulse_carry( + initial, + drift, + intercept, + effect, + predictor, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("eq3-carry"); + let evolved = + recover_discrete_latent_mean(initial, drift, intercept, delta, LagClock::EventTime) + .expect("mu-t"); + assert!((composed - (evolved + carry)).abs() < 1e-15); + assert_eq!( + recover_discrete_latent_mean_with_impulse_carry( + initial, + drift, + intercept, + 0.0, + predictor, + delta, + elapsed, + LagClock::EventTime + ), + Ok(evolved) + ); + assert_eq!( + recover_discrete_latent_mean_with_impulse_carry( + 0.0, + drift, + 0.0, + effect, + predictor, + delta, + elapsed, + LagClock::EventTime + ), + Ok(carry) + ); + let scaled = recover_time_dependent_predictor_impulse_carry( + 1e308, + 1e-308, + 0.0, + 2.0, + 1.0, + LagClock::EventTime, + ) + .expect("scale"); + assert!((scaled - 1.0).abs() < 1e-15); + let rewritten = recover_time_dependent_predictor_impulse_carry( + 1e-308, + 1.0, + 710.0, + 2.0, + 1.0, + LagClock::EventTime, + ) + .expect("rewrite"); + let expected_rewrite = (1e-308_f64.ln() + 710.0).exp(); + assert!((rewritten - expected_rewrite).abs() <= expected_rewrite * 1e-12); + } + + #[test] + fn discrete_observed_mean_with_impulse_carry_recovers_driver_equation_five() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let elapsed = 1.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let recovered = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + let carried = recover_discrete_latent_mean_with_impulse_carry( + initial, + drift, + intercept, + effect, + predictor, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("carried"); + let expected = manifest_mean + loading * carried; + assert!((recovered - expected).abs() < 1e-15); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + assert!((evolved_observed - recovered).abs() > 1e-3); + assert_eq!( + recover_discrete_observed_mean_with_impulse_carry( + 0.0, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + elapsed, + LagClock::EventTime + ), + Ok(manifest_mean) + ); + assert_eq!( + recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + 0.0, + delta, + elapsed, + LagClock::EventTime + ), + Ok(loading * carried) + ); + } + + #[test] + fn discrete_observed_mean_with_impulse_carry_is_not_contemporaneous_or_zero_carry() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let elapsed = 1.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let recovered = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + let contemporaneous = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-mx"); + assert!((contemporaneous - recovered).abs() > 1e-3); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let zero_carry = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + 0.0, + predictor, + manifest_mean, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("zero-carry"); + assert!((zero_carry - evolved_observed).abs() < 1e-15); + } + + #[test] + fn discrete_observed_mean_with_impulse_carry_refuses_evolved_mean_and_overflow() { + let loading = 2.0_f64; + let recovered = recover_discrete_observed_mean_with_impulse_carry( + loading, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + 1.0, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + let carried = recover_discrete_latent_mean_with_impulse_carry( + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 2.0, + 1.0, + LagClock::EventTime, + ) + .expect("carried"); + let evolved_observed = + recover_discrete_observed_mean(loading, 1.0, -0.5, 0.3, 0.5, 2.0, LagClock::EventTime) + .expect("eq3-eq5-mean"); + assert_eq!( + refuse_evolved_observed_mean_as_impulse_carry_observed_mean( + evolved_observed, + recovered + ), + Err(PsychometricError::EvolvedObservedMeanIsNotImpulseCarryObservedMean) + ); + assert_eq!( + refuse_impulse_observed_mean_as_impulse_carry_observed_mean( + recover_discrete_observed_mean_with_impulse( + loading, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + LagClock::EventTime, + ) + .expect("eq5-mx"), + recovered + ), + Err(PsychometricError::ImpulseObservedMeanIsNotImpulseCarryObservedMean) + ); + assert_eq!( + refuse_latent_mean_as_observed_mean(carried, recovered), + Err(PsychometricError::LatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_manifest_means_as_observed_mean(0.5, recovered), + Err(PsychometricError::ManifestMeansIsNotObservedMean) + ); + let scaled = recover_discrete_observed_mean_with_impulse_carry( + 1e308, + 1e-308, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 2.0, + 1.0, + LagClock::EventTime, + ) + .expect("scale"); + assert!((scaled - 1.0).abs() < 1e-15); + let finite_loaded = recover_discrete_observed_mean_with_impulse_carry( + 1e308, + 1.0, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 2.0, + 1.0, + LagClock::EventTime, + ) + .expect("lambda-mu"); + assert!((finite_loaded - 1e308).abs() / 1e308 < 1e-15); + } + + #[test] + fn discrete_observed_mean_with_impulse_carry_invalid_inputs_fail_closed() { + assert_eq!( + recover_discrete_observed_mean_with_impulse_carry( + f64::NAN, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_observed_mean_with_impulse_carry( + 1e308, + 2.0, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 2.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_observed_mean_with_impulse_carry( + 1.0, + 1.0, + 710.0, + 0.0, + 0.0, + 3.0, + 0.5, + 1.0, + 0.5, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_observed_mean_with_impulse_carry( + 1e308, + 0.0, + 0.0, + 0.0, + 1e308, + 1.0, + 0.0, + 2.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn discrete_observed_mean_with_impulse_carry_interval_and_clock_fail_closed() { + assert_eq!( + recover_discrete_observed_mean_with_impulse_carry( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_observed_mean_with_impulse_carry( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_observed_mean_with_impulse_carry( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + 1.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + } + + #[test] + fn time_dependent_impulse_carry_refuses_contemporaneous_cint_tipred_and_equation_fourteen() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let elapsed = 1.0_f64; + let carry = recover_time_dependent_predictor_impulse_carry( + effect, + predictor, + drift, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("tdpred-carry"); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("tdpred"); + let intercept_effect = + recover_discrete_continuous_intercept_effect(effect, drift, delta, LagClock::EventTime) + .expect("cint"); + let time_independent = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("tipred"); + let equation_fourteen = recover_discrete_time_varying_predictor_effect( + effect, + delta, + delta, + delta, + LagClock::EventTime, + ) + .expect("eq14"); + assert!((carry - impulse).abs() > 1e-3); + assert!((carry - intercept_effect).abs() > 1e-3); + assert!((carry - time_independent).abs() > 1e-3); + assert!((carry - equation_fourteen).abs() > 1e-3); + assert_eq!( + refuse_time_dependent_impulse_carry_as_contemporaneous_impulse(carry, impulse), + Err(PsychometricError::TimeDependentImpulseCarryIsNotContemporaneousImpulse) + ); + assert_eq!( + refuse_time_dependent_impulse_carry_as_continuous_intercept(carry, effect), + Err(PsychometricError::TimeDependentImpulseCarryIsNotContinuousIntercept) + ); + assert_eq!( + refuse_time_dependent_impulse_carry_as_time_independent_effect(carry, time_independent), + Err(PsychometricError::TimeDependentImpulseCarryIsNotTimeIndependentEffect) + ); + assert_eq!( + refuse_time_dependent_impulse_carry_as_time_varying_discrete_effect( + carry, + equation_fourteen + ), + Err(PsychometricError::TimeDependentImpulseCarryIsNotTimeVaryingDiscreteEffect) + ); + } + + #[test] + fn time_dependent_impulse_carry_invalid_inputs_fail_closed() { + assert_eq!( + recover_time_dependent_predictor_impulse_carry( + f64::NAN, + 1.0, + -0.5, + 2.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_time_dependent_predictor_impulse_carry( + 0.4, + 3.0, + f64::INFINITY, + 2.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_time_dependent_predictor_impulse_carry( + 1e308, + 2.0, + -0.5, + 2.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_time_dependent_predictor_impulse_carry( + 1.2, + 1.0, + 800.0, + 2.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + // Finite log-rate whose product with elapsed overflows. exp(±∞) + // is not finite, then the non-finite drift interval fails closed. + assert_eq!( + recover_time_dependent_predictor_impulse_carry( + 0.4, + 3.0, + 1e308, + 3.0, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_time_dependent_predictor_impulse_carry( + 0.0, + 3.0, + 800.0, + 2.0, + 1.0, + LagClock::EventTime + ), + Ok(0.0) + ); + assert_eq!( + recover_time_dependent_predictor_impulse_carry( + -1e-308, + 1.0, + 710.0, + 2.0, + 1.0, + LagClock::EventTime + ) + .map(f64::signum), + Ok(-1.0) + ); + } + + #[test] + fn time_dependent_impulse_carry_interval_and_clock_fail_closed() { + assert_eq!( + recover_time_dependent_predictor_impulse_carry( + 0.4, + 3.0, + -0.5, + 0.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_time_dependent_predictor_impulse_carry( + 0.4, + 3.0, + -0.5, + 2.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_time_dependent_predictor_impulse_carry( + 0.4, + 3.0, + -0.5, + 2.0, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_time_dependent_predictor_impulse_carry( + 0.4, + 3.0, + -0.5, + 2.0, + 1.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_latent_mean_with_impulse_carry( + 1e308, + 0.0, + 0.0, + 1e308, + 1.0, + 2.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn initial_time_independent_predictor_recovers_table_three_t0_shift_and_carry() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let shift = recover_initial_time_independent_predictor_effect(effect, predictor) + .expect("t0-tipred"); + assert!((shift - 1.2).abs() < 1e-15); + assert_eq!( + recover_initial_time_independent_predictor_effect(0.0, predictor), + Ok(0.0) + ); + assert_eq!( + recover_initial_time_independent_predictor_effect(effect, 0.0), + Ok(0.0) + ); + let carry = recover_initial_time_independent_predictor_carry( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("t0-carry"); + let expected = 1.2 * (drift * delta).exp(); + assert!((carry - expected).abs() < 1e-15); + let zero_drift = recover_initial_time_independent_predictor_carry( + effect, + predictor, + 0.0, + delta, + LagClock::EventTime, + ) + .expect("zero-drift"); + assert!((zero_drift - 1.2).abs() < 1e-15); + let vanished = recover_initial_time_independent_predictor_carry( + effect, + predictor, + -800.0, + 1.0, + LagClock::EventTime, + ) + .expect("underflow"); + assert_eq!(vanished.to_bits(), 0.0_f64.to_bits()); + let increment = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("tipred"); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("tdpred"); + assert!((carry - shift).abs() > 1e-3); + assert!((carry - increment).abs() > 1e-3); + assert!((shift - increment).abs() > 1e-3); + assert!((shift - effect).abs() > 1e-3); + // Algebraically a product, like M x, but Table 3 names a different matrix. + assert!((shift - impulse).abs() < 1e-15); + } + + #[test] + fn initial_time_independent_predictor_composes_evolved_mean_and_keeps_scale() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let carry = recover_initial_time_independent_predictor_carry( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("t0-carry"); + let initial = 1.0_f64; + let intercept = 0.3_f64; + let composed = recover_discrete_latent_mean_with_initial_time_independent_predictor( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-t0tipred"); + let evolved = + recover_discrete_latent_mean(initial, drift, intercept, delta, LagClock::EventTime) + .expect("mu-t"); + assert!((composed - (evolved + carry)).abs() < 1e-15); + assert_eq!( + recover_discrete_latent_mean_with_initial_time_independent_predictor( + initial, + drift, + intercept, + 0.0, + predictor, + delta, + LagClock::EventTime + ), + Ok(evolved) + ); + assert_eq!( + recover_discrete_latent_mean_with_initial_time_independent_predictor( + 0.0, + drift, + 0.0, + effect, + predictor, + delta, + LagClock::EventTime + ), + Ok(carry) + ); + let scaled = recover_initial_time_independent_predictor_carry( + 1e308, + 1e-308, + 0.0, + 1.0, + LagClock::EventTime, + ) + .expect("scale"); + assert!((scaled - 1.0).abs() < 1e-15); + let rewritten = recover_initial_time_independent_predictor_carry( + 2.0, + 0.5, + 710.0, + 1.0, + LagClock::EventTime, + ); + assert_eq!(rewritten, Err(PsychometricError::InvalidNumericInput)); + let finite_rewrite = recover_initial_time_independent_predictor_carry( + 1e-308, + 1.0, + 700.0, + 1.0, + LagClock::EventTime, + ) + .expect("log-rewrite"); + let expected_rewrite = (1e-308_f64.ln() + 700.0).exp(); + assert!((finite_rewrite - expected_rewrite).abs() / expected_rewrite < 1e-12); + } + + #[test] + fn initial_time_independent_predictor_refuses_process_increment_cint_impulse_and_coefficient() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let shift = recover_initial_time_independent_predictor_effect(effect, predictor) + .expect("t0-tipred"); + let carry = recover_initial_time_independent_predictor_carry( + effect, + predictor, + -0.5, + 2.0, + LagClock::EventTime, + ) + .expect("t0-carry"); + let increment = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + -0.5, + 2.0, + LagClock::EventTime, + ) + .expect("tipred"); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("tdpred"); + assert_eq!( + refuse_initial_time_independent_effect_as_process_increment(shift, increment), + Err(PsychometricError::InitialTimeIndependentEffectIsNotProcessIncrement) + ); + assert_eq!( + refuse_initial_time_independent_carry_as_initial_effect(carry, shift), + Err(PsychometricError::InitialTimeIndependentCarryIsNotInitialEffect) + ); + assert_eq!( + refuse_initial_time_independent_effect_as_continuous_intercept(shift, 0.4), + Err(PsychometricError::InitialTimeIndependentEffectIsNotContinuousIntercept) + ); + assert_eq!( + refuse_initial_time_independent_effect_as_time_dependent_impulse(shift, impulse), + Err(PsychometricError::InitialTimeIndependentEffectIsNotTimeDependentImpulse) + ); + assert_eq!( + refuse_initial_time_independent_coefficient_as_initial_effect(effect, shift), + Err(PsychometricError::InitialTimeIndependentCoefficientIsNotInitialEffect) + ); + } + + #[test] + fn initial_time_independent_predictor_invalid_inputs_fail_closed() { + assert_eq!( + recover_initial_time_independent_predictor_effect(f64::NAN, 1.0), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_initial_time_independent_predictor_effect(1.0, f64::INFINITY), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_initial_time_independent_predictor_effect(1e308, 2.0), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_initial_time_independent_predictor_carry( + 0.4, + 3.0, + f64::NAN, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_initial_time_independent_predictor_carry( + 0.4, + 3.0, + -0.5, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_initial_time_independent_predictor_carry( + 0.4, + 3.0, + -0.5, + 2.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_latent_mean_with_initial_time_independent_predictor( + 1e308, + 0.0, + 0.0, + 1e308, + 1.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_initial_time_independent_predictor_carry( + 1.0, + 1.0, + f64::INFINITY, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_initial_time_independent_predictor_carry( + f64::NAN, + 1.0, + -0.5, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_initial_time_independent_predictor_carry( + 1.0, + 1.0, + 1e308, + 10.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_mean_with_initial_time_independent_predictor( + 1.0, + -0.5, + 0.3, + f64::NAN, + 1.0, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn discrete_observed_mean_with_initial_time_independent_predictor_recovers_driver_equation_five() + { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let recovered = recover_discrete_observed_mean_with_initial_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-t0tipred-mean"); + let composed = recover_discrete_latent_mean_with_initial_time_independent_predictor( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-t0tipred"); + let expected = manifest_mean + loading * composed; + assert!((recovered - expected).abs() < 1e-15); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let process_observed = recover_discrete_observed_mean_with_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-tipred-mean"); + let impulse_observed = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + let carried_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + 1.0, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + assert!((evolved_observed - recovered).abs() > 1e-3); + assert!((process_observed - recovered).abs() > 1e-3); + assert!((impulse_observed - recovered).abs() > 1e-3); + assert!((carried_observed - recovered).abs() > 1e-3); + assert_eq!( + recover_discrete_observed_mean_with_initial_time_independent_predictor( + 0.0, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime + ), + Ok(manifest_mean) + ); + } + + #[test] + fn discrete_observed_mean_with_initial_time_independent_predictor_is_not_evolved_or_zero_carry() + { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let recovered = recover_discrete_observed_mean_with_initial_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-t0tipred-mean"); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let zero_carry = recover_discrete_observed_mean_with_initial_time_independent_predictor( + loading, + initial, + drift, + intercept, + 0.0, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("zero-carry"); + assert!((zero_carry - evolved_observed).abs() < 1e-15); + assert!((recovered - evolved_observed).abs() > 1e-3); + } + + #[test] + fn discrete_observed_mean_with_initial_time_independent_predictor_refuses_evolved_mean_and_overflow() + { + let loading = 2.0_f64; + let recovered = recover_discrete_observed_mean_with_initial_time_independent_predictor( + loading, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + LagClock::EventTime, + ) + .expect("eq5-t0tipred-mean"); + let evolved_observed = + recover_discrete_observed_mean(loading, 1.0, -0.5, 0.3, 0.5, 2.0, LagClock::EventTime) + .expect("eq3-eq5-mean"); + let process_observed = recover_discrete_observed_mean_with_time_independent_predictor( + loading, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + LagClock::EventTime, + ) + .expect("eq5-tipred-mean"); + let impulse_observed = recover_discrete_observed_mean_with_impulse( + loading, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + let carried_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + 1.0, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + assert_eq!( + refuse_evolved_observed_mean_as_initial_time_independent_observed_mean( + evolved_observed, + recovered + ), + Err(PsychometricError::EvolvedObservedMeanIsNotInitialTimeIndependentObservedMean) + ); + assert_eq!( + refuse_time_independent_observed_mean_as_initial_time_independent_observed_mean( + process_observed, + recovered + ), + Err( + PsychometricError::TimeIndependentObservedMeanIsNotInitialTimeIndependentObservedMean + ) + ); + assert_eq!( + refuse_impulse_observed_mean_as_initial_time_independent_observed_mean( + impulse_observed, + recovered + ), + Err(PsychometricError::ImpulseObservedMeanIsNotInitialTimeIndependentObservedMean) + ); + assert_eq!( + refuse_impulse_carry_observed_mean_as_initial_time_independent_observed_mean( + carried_observed, + recovered + ), + Err(PsychometricError::ImpulseCarryObservedMeanIsNotInitialTimeIndependentObservedMean) + ); + } + + #[test] + fn discrete_observed_mean_with_initial_time_independent_predictor_invalid_inputs_fail_closed() { + let scaled = recover_discrete_observed_mean_with_initial_time_independent_predictor( + 1e308, + 1e-308, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + LagClock::EventTime, + ) + .expect("scale"); + assert!((scaled - 1.0).abs() < 1e-15); + let finite_loaded = recover_discrete_observed_mean_with_initial_time_independent_predictor( + 1e308, + 0.0, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + LagClock::EventTime, + ) + .expect("lambda-mu0"); + assert!((finite_loaded - 0.0).abs() < 1e-15); + assert_eq!( + recover_discrete_observed_mean_with_initial_time_independent_predictor( + 1e308, + 2.0, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_observed_mean_with_initial_time_independent_predictor( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_observed_mean_with_initial_time_independent_predictor( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_observed_mean_with_initial_time_independent_predictor( + 1e308, + 0.0, + 0.0, + 0.0, + 1e308, + 1.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + fn initial_time_dependent_predictor_recovers_table_three_t0_shift_and_carry() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let shift = + recover_initial_time_dependent_predictor_effect(effect, predictor).expect("t0-tdpred"); + assert!((shift - 1.2).abs() < 1e-15); + assert_eq!( + recover_initial_time_dependent_predictor_effect(0.0, predictor), + Ok(0.0) + ); + assert_eq!( + recover_initial_time_dependent_predictor_effect(effect, 0.0), + Ok(0.0) + ); + let carry = recover_initial_time_dependent_predictor_carry( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("t0-td-carry"); + let expected = 1.2 * (drift * delta).exp(); + assert!((carry - expected).abs() < 1e-15); + let zero_drift = recover_initial_time_dependent_predictor_carry( + effect, + predictor, + 0.0, + delta, + LagClock::EventTime, + ) + .expect("zero-drift"); + assert!((zero_drift - 1.2).abs() < 1e-15); + let vanished = recover_initial_time_dependent_predictor_carry( + effect, + predictor, + -800.0, + 1.0, + LagClock::EventTime, + ) + .expect("underflow"); + assert_eq!(vanished.to_bits(), 0.0_f64.to_bits()); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("tdpred"); + let increment = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("tipred"); + let tipred_shift = recover_initial_time_independent_predictor_effect(effect, predictor) + .expect("t0-tipred"); + let impulse_carry = recover_time_dependent_predictor_impulse_carry( + effect, + predictor, + drift, + delta, + 1.0, + LagClock::EventTime, + ) + .expect("td-carry"); + assert!((carry - shift).abs() > 1e-3); + assert!((carry - increment).abs() > 1e-3); + assert!((shift - increment).abs() > 1e-3); + assert!((shift - effect).abs() > 1e-3); + assert!((carry - impulse_carry).abs() > 1e-3); + // Algebraically a product, like M x and t0_b z, but Table 3 names a different matrix. + assert!((shift - impulse).abs() < 1e-15); + assert!((shift - tipred_shift).abs() < 1e-15); + } + + #[test] + fn initial_time_dependent_predictor_composes_evolved_mean_and_keeps_scale() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let carry = recover_initial_time_dependent_predictor_carry( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("t0-td-carry"); + let initial = 1.0_f64; + let intercept = 0.3_f64; + let composed = recover_discrete_latent_mean_with_initial_time_dependent_predictor( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-t0tdpred"); + let evolved = + recover_discrete_latent_mean(initial, drift, intercept, delta, LagClock::EventTime) + .expect("mu-t"); + assert!((composed - (evolved + carry)).abs() < 1e-15); + assert_eq!( + recover_discrete_latent_mean_with_initial_time_dependent_predictor( + initial, + drift, + intercept, + 0.0, + predictor, + delta, + LagClock::EventTime + ), + Ok(evolved) + ); + assert_eq!( + recover_discrete_latent_mean_with_initial_time_dependent_predictor( + 0.0, + drift, + 0.0, + effect, + predictor, + delta, + LagClock::EventTime + ), + Ok(carry) + ); + let scaled = recover_initial_time_dependent_predictor_carry( + 1e308, + 1e-308, + 0.0, + 1.0, + LagClock::EventTime, + ) + .expect("scale"); + assert!((scaled - 1.0).abs() < 1e-15); + let rewritten = recover_initial_time_dependent_predictor_carry( + 2.0, + 0.5, + 710.0, + 1.0, + LagClock::EventTime, + ); + assert_eq!(rewritten, Err(PsychometricError::InvalidNumericInput)); + let finite_rewrite = recover_initial_time_dependent_predictor_carry( + 1e-308, + 1.0, + 700.0, + 1.0, + LagClock::EventTime, + ) + .expect("log-rewrite"); + let expected_rewrite = (1e-308_f64.ln() + 700.0).exp(); + assert!((finite_rewrite - expected_rewrite).abs() / expected_rewrite < 1e-12); + } + + #[test] + fn initial_time_dependent_predictor_refuses_impulse_cint_process_and_coefficient() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let shift = + recover_initial_time_dependent_predictor_effect(effect, predictor).expect("t0-tdpred"); + let carry = recover_initial_time_dependent_predictor_carry( + effect, + predictor, + -0.5, + 2.0, + LagClock::EventTime, + ) + .expect("t0-td-carry"); + let increment = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + -0.5, + 2.0, + LagClock::EventTime, + ) + .expect("tipred"); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("tdpred"); + let tipred_shift = recover_initial_time_independent_predictor_effect(effect, predictor) + .expect("t0-tipred"); + let impulse_carry = recover_time_dependent_predictor_impulse_carry( + effect, + predictor, + -0.5, + 2.0, + 1.0, + LagClock::EventTime, + ) + .expect("td-carry"); + assert_eq!( + refuse_initial_time_dependent_effect_as_contemporaneous_impulse(shift, impulse), + Err(PsychometricError::InitialTimeDependentEffectIsNotContemporaneousImpulse) + ); + assert_eq!( + refuse_initial_time_dependent_carry_as_initial_effect(carry, shift), + Err(PsychometricError::InitialTimeDependentCarryIsNotInitialEffect) + ); + assert_eq!( + refuse_initial_time_dependent_effect_as_continuous_intercept(shift, 0.4), + Err(PsychometricError::InitialTimeDependentEffectIsNotContinuousIntercept) + ); + assert_eq!( + refuse_initial_time_dependent_effect_as_process_increment(shift, increment), + Err(PsychometricError::InitialTimeDependentEffectIsNotProcessIncrement) + ); + assert_eq!( + refuse_initial_time_dependent_effect_as_initial_time_independent_effect( + shift, + tipred_shift + ), + Err(PsychometricError::InitialTimeDependentEffectIsNotInitialTimeIndependentEffect) + ); + assert_eq!( + refuse_initial_time_dependent_coefficient_as_initial_effect(effect, shift), + Err(PsychometricError::InitialTimeDependentCoefficientIsNotInitialEffect) + ); + assert_eq!( + refuse_initial_time_dependent_carry_as_impulse_carry(carry, impulse_carry), + Err(PsychometricError::InitialTimeDependentCarryIsNotImpulseCarry) + ); + } + + #[test] + fn initial_time_dependent_predictor_invalid_inputs_fail_closed() { + assert_eq!( + recover_initial_time_dependent_predictor_effect(f64::NAN, 1.0), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_initial_time_dependent_predictor_effect(1.0, f64::INFINITY), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_initial_time_dependent_predictor_effect(1e308, 2.0), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_initial_time_dependent_predictor_carry( + 0.4, + 3.0, + f64::NAN, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_initial_time_dependent_predictor_carry( + 0.4, + 3.0, + -0.5, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_initial_time_dependent_predictor_carry( + 0.4, + 3.0, + -0.5, + 2.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_latent_mean_with_initial_time_dependent_predictor( + 1e308, + 0.0, + 0.0, + 1e308, + 1.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_initial_time_dependent_predictor_carry( + 1.0, + 1.0, + f64::INFINITY, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_initial_time_dependent_predictor_carry( + f64::NAN, + 1.0, + -0.5, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_initial_time_dependent_predictor_carry( + 1.0, + 1.0, + 1e308, + 10.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_mean_with_initial_time_dependent_predictor( + 1.0, + -0.5, + 0.3, + f64::NAN, + 1.0, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn discrete_observed_mean_with_initial_time_dependent_predictor_recovers_driver_equation_five() + { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let recovered = recover_discrete_observed_mean_with_initial_time_dependent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-t0tdpred-mean"); + let composed = recover_discrete_latent_mean_with_initial_time_dependent_predictor( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-t0tdpred"); + let expected = manifest_mean + loading * composed; + assert!((recovered - expected).abs() < 1e-15); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let process_observed = recover_discrete_observed_mean_with_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-tipred"); + let impulse_observed = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-impulse"); + let carry_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + 1.0, + LagClock::EventTime, + ) + .expect("eq5-carry"); + let tipred_observed = + recover_discrete_observed_mean_with_initial_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-t0tipred"); + assert!((recovered - evolved_observed).abs() > 1e-3); + assert!((recovered - process_observed).abs() > 1e-3); + assert!((recovered - impulse_observed).abs() > 1e-3); + assert!((recovered - carry_observed).abs() > 1e-3); + // Same numbers as T0TIPRED yield the same product, but Table 3 names a different matrix. + assert!((recovered - tipred_observed).abs() < 1e-15); + assert_eq!( + recover_discrete_observed_mean_with_initial_time_dependent_predictor( + 0.0, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime + ), + Ok(manifest_mean) + ); + assert!((recovered - composed).abs() > 1e-3); + assert!((recovered - manifest_mean).abs() > 1e-3); + } + + #[test] + fn discrete_observed_mean_with_initial_time_dependent_predictor_refuses_evolved_mean_and_overflow() + { + let recovered = recover_discrete_observed_mean_with_initial_time_dependent_predictor( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + LagClock::EventTime, + ) + .expect("eq5-t0tdpred"); + let evolved = + recover_discrete_observed_mean(2.0, 1.0, -0.5, 0.3, 0.5, 2.0, LagClock::EventTime) + .expect("evolved"); + let process = recover_discrete_observed_mean_with_time_independent_predictor( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + LagClock::EventTime, + ) + .expect("tipred"); + let impulse = recover_discrete_observed_mean_with_impulse( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + LagClock::EventTime, + ) + .expect("impulse"); + let carry = recover_discrete_observed_mean_with_impulse_carry( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + 1.0, + LagClock::EventTime, + ) + .expect("carry"); + let tipred = recover_discrete_observed_mean_with_initial_time_independent_predictor( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + LagClock::EventTime, + ) + .expect("t0tipred"); + assert_eq!( + refuse_evolved_observed_mean_as_initial_time_dependent_observed_mean( + evolved, recovered + ), + Err(PsychometricError::EvolvedObservedMeanIsNotInitialTimeDependentObservedMean) + ); + assert_eq!( + refuse_time_independent_observed_mean_as_initial_time_dependent_observed_mean( + process, recovered + ), + Err( + PsychometricError::TimeIndependentObservedMeanIsNotInitialTimeDependentObservedMean + ) + ); + assert_eq!( + refuse_impulse_observed_mean_as_initial_time_dependent_observed_mean( + impulse, recovered + ), + Err(PsychometricError::ImpulseObservedMeanIsNotInitialTimeDependentObservedMean) + ); + assert_eq!( + refuse_impulse_carry_observed_mean_as_initial_time_dependent_observed_mean( + carry, recovered + ), + Err(PsychometricError::ImpulseCarryObservedMeanIsNotInitialTimeDependentObservedMean) + ); + assert_eq!( + refuse_initial_time_independent_observed_mean_as_initial_time_dependent_observed_mean( + tipred, recovered + ), + Err(PsychometricError::InitialTimeIndependentObservedMeanIsNotInitialTimeDependentObservedMean) + ); + } + + #[test] + fn discrete_observed_mean_with_initial_time_dependent_predictor_invalid_inputs_fail_closed() { + let scaled = recover_discrete_observed_mean_with_initial_time_dependent_predictor( + 1e308, + 1e-308, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + LagClock::EventTime, + ) + .expect("scale"); + assert!((scaled - 1.0).abs() < 1e-15); + assert_eq!( + recover_discrete_observed_mean_with_initial_time_dependent_predictor( + 1e308, + 2.0, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_observed_mean_with_initial_time_dependent_predictor( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_observed_mean_with_initial_time_dependent_predictor( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_observed_mean_with_initial_time_dependent_predictor( + 1e308, + 0.0, + 0.0, + 0.0, + 1e308, + 1.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + } +} diff --git a/crates/psychometric_core/src/indicator.rs b/crates/psychometric_core/src/indicator.rs new file mode 100644 index 00000000..9092fa57 --- /dev/null +++ b/crates/psychometric_core/src/indicator.rs @@ -0,0 +1,212 @@ +//! Valid structural indicator coordinates and compositional-geometry claims. + +use crate::error::PsychometricError; + +/// Kind of indicator coordinates offered to a structural model. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum IndicatorKind { + /// Additive log-ratio (logistic-normal) coordinates. + AdditiveLogRatio, + /// Isometric log-ratio coordinates. + IsometricLogRatio, + /// Logistic-normal coordinates already mapped from the simplex. + LogisticNormal, + /// Raw topic proportions on the simplex. + RawProportion, +} + +impl IndicatorKind { + /// Stable wire name for the indicator kind. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::AdditiveLogRatio => "alr", + Self::IsometricLogRatio => "ilr", + Self::LogisticNormal => "logistic_normal", + Self::RawProportion => "raw_proportion", + } + } + + /// Return whether the kind is an admissible unconstrained structural input. + /// + /// This does not claim that the coordinates are orthonormal or preserve + /// Aitchison distance. ALR is reference-dependent; only ILR carries that + /// orthonormal compositional-geometry claim. + #[must_use] + pub const fn is_valid_structural_input(self) -> bool { + !matches!(self, Self::RawProportion) + } + + /// Return whether the coordinate kind is an orthonormal Aitchison isometry. + #[must_use] + pub const fn preserves_aitchison_distance(self) -> bool { + matches!(self, Self::IsometricLogRatio) + } +} + +/// Refuse raw topic proportions as psychometric indicators. +/// +/// # Errors +/// +/// Returns [`PsychometricError::RawProportionForbidden`] for +/// [`IndicatorKind::RawProportion`]. +pub fn require_valid_indicator(kind: IndicatorKind) -> Result<(), PsychometricError> { + if kind.is_valid_structural_input() { + Ok(()) + } else { + Err(PsychometricError::RawProportionForbidden) + } +} + +/// Pearson product-moment correlation on already-mapped coordinates. +/// +/// For ALR this is a reference-dependent coordinate correlation, not an +/// Aitchison-distance-preserving statistic. Use an ILR basis when orthonormal +/// compositional geometry is part of the estimand. +/// +/// # Errors +/// +/// Returns [`PsychometricError::RawProportionForbidden`] when `kind` is a raw +/// simplex, [`PsychometricError::InvalidNumericInput`] for empty, singleton, +/// unequal-length, or non-finite vectors, and +/// [`PsychometricError::SingularDesign`] when either vector has zero variance. +pub fn pearson_correlation( + left: &[f64], + right: &[f64], + kind: IndicatorKind, +) -> Result { + require_valid_indicator(kind)?; + let (left_dev, right_dev, _) = centered_pairs(left, right)?; + let mut cross = 0.0_f64; + let mut left_ss = 0.0_f64; + let mut right_ss = 0.0_f64; + for (left_value, right_value) in left_dev.iter().zip(&right_dev) { + cross += left_value * right_value; + left_ss += left_value * left_value; + right_ss += right_value * right_value; + } + if left_ss <= 0.0 || right_ss <= 0.0 { + return Err(PsychometricError::SingularDesign); + } + let denom = (left_ss * right_ss).sqrt(); + require_finite(cross / denom) +} + +pub(crate) fn centered_pairs( + left: &[f64], + right: &[f64], +) -> Result<(Vec, Vec, f64), PsychometricError> { + if left.len() < 2 || left.len() != right.len() { + return Err(PsychometricError::InvalidNumericInput); + } + let n = left.len() as f64; + let mut left_sum = 0.0_f64; + let mut right_sum = 0.0_f64; + for (left_value, right_value) in left.iter().zip(right) { + if !left_value.is_finite() || !right_value.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + left_sum += left_value; + right_sum += right_value; + } + let left_mean = left_sum / n; + let right_mean = right_sum / n; + let left_dev: Vec = left.iter().map(|value| value - left_mean).collect(); + let right_dev: Vec = right.iter().map(|value| value - right_mean).collect(); + Ok((left_dev, right_dev, n)) +} + +pub(crate) fn require_finite(value: f64) -> Result { + if value.is_finite() { + Ok(value) + } else { + Err(PsychometricError::InvalidNumericInput) + } +} + +#[cfg(test)] +mod tests { + use super::{IndicatorKind, pearson_correlation, require_valid_indicator}; + use crate::error::PsychometricError; + + #[test] + fn valid_kinds_pass_and_zero_right_variance_is_singular() { + require_valid_indicator(IndicatorKind::IsometricLogRatio).expect("ilr"); + let correlation = pearson_correlation( + &[0.0, 1.0, 2.0], + &[0.0, 2.0, 4.0], + IndicatorKind::AdditiveLogRatio, + ) + .expect("line"); + assert!((correlation - 1.0).abs() < 1e-12); + assert_eq!( + pearson_correlation(&[1.0, 1.0], &[2.0, 3.0], IndicatorKind::LogisticNormal), + Err(PsychometricError::SingularDesign) + ); + assert_eq!( + require_valid_indicator(IndicatorKind::RawProportion), + Err(PsychometricError::RawProportionForbidden) + ); + assert_eq!( + pearson_correlation(&[1.0, 2.0], &[3.0, 3.0], IndicatorKind::LogisticNormal), + Err(PsychometricError::SingularDesign) + ); + assert_eq!( + pearson_correlation(&[2.0, 2.0], &[1.0, 2.0], IndicatorKind::AdditiveLogRatio), + Err(PsychometricError::SingularDesign) + ); + assert_eq!( + pearson_correlation(&[1.0, 1.0], &[2.0, 3.0], IndicatorKind::LogisticNormal), + Err(PsychometricError::SingularDesign) + ); + assert_eq!( + pearson_correlation(&[1.0, 2.0], &[1.0], IndicatorKind::LogisticNormal), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + pearson_correlation(&[f64::NAN, 2.0], &[1.0, 2.0], IndicatorKind::LogisticNormal), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + pearson_correlation(&[1.0, 2.0], &[1.0, f64::NAN], IndicatorKind::LogisticNormal), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + pearson_correlation( + &[0.0, f64::MAX], + &[0.0, f64::MAX], + IndicatorKind::AdditiveLogRatio + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + pearson_correlation( + &[1.0, 2.0], + &[1.0, f64::NAN], + IndicatorKind::IsometricLogRatio + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + pearson_correlation( + &[f64::INFINITY, 2.0], + &[1.0, 3.0], + IndicatorKind::LogisticNormal + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + pearson_correlation(&[], &[], IndicatorKind::AdditiveLogRatio), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + pearson_correlation( + &[1.0, 2.0, 3.0], + &[1.0, 2.0], + IndicatorKind::AdditiveLogRatio + ), + Err(PsychometricError::InvalidNumericInput) + ); + } +} diff --git a/crates/psychometric_core/src/latent_mean.rs b/crates/psychometric_core/src/latent_mean.rs new file mode 100644 index 00000000..392ab28b --- /dev/null +++ b/crates/psychometric_core/src/latent_mean.rs @@ -0,0 +1,523 @@ +//! Two-group OLS strong/strict-gated latent-mean difference. +//! +//! Metric/weak invariance (equal loadings only) licenses shared *metric* +//! meaning. It does not license latent-mean comparison. Strong invariance +//! (equal loading and intercept) is required for means; strict additionally +//! equalizes residual variances. This is two-group OLS, not MGCFA. +//! +//! Wire names `configural` / `metric` / `scalar` match the unpublished +//! `measurement_invariance` crate (#84) without importing it. That crate's +//! `Metric` gate is not used here for latent means. `#84` `scalar` is the +//! strong/scalar status. Putnick and Bornstein (2016, PMC author manuscript +//! opened 2026-08-19T22:15Z) require scalar invariance before latent-mean +//! comparison; residual invariance is not a prerequisite for those means. +//! Two-observation series have no residual degrees of freedom +//! (`ordinary_least_squares_fit` returns residual variance `0`) and +//! therefore cannot be classified as strict. Meredith (1993) names +//! weak/strong/strict remain unread labels. + +use crate::error::PsychometricError; +use crate::indicator::{IndicatorKind, require_finite, require_valid_indicator}; +use crate::loading::ordinary_least_squares_fit; + +/// Two-group OLS invariance status for a mean comparison. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum MeanInvarianceStatus { + /// Same regression form only. Loadings need not match. + Configural, + /// Equal loadings. `#84` wire name `metric`. Does not license means. + Metric, + /// Equal loadings and intercepts. `#84` wire name `scalar`. + Strong, + /// Strong plus equal residual variances. + Strict, +} + +impl MeanInvarianceStatus { + /// Local status name (`strong` / `strict` keep Meredith's mean hierarchy). + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Configural => "configural", + Self::Metric => "metric", + Self::Strong => "strong", + Self::Strict => "strict", + } + } + + /// `#84` `measurement_invariance` wire name without importing that crate. + /// + /// `#84` defines only `configural`, `metric`, and `scalar`. Local + /// [`Self::Strict`] has no `#84` wire name; use [`Self::as_str`] for the + /// Meredith-style local label. + #[must_use] + pub const fn as_measurement_invariance_wire_name(self) -> Option<&'static str> { + match self { + Self::Configural => Some("configural"), + Self::Metric => Some("metric"), + Self::Strong => Some("scalar"), + Self::Strict => None, + } + } + + /// Return whether `#84` would license shared *metric* meaning. + #[must_use] + pub const fn licenses_shared_metric_meaning(self) -> bool { + matches!(self, Self::Metric | Self::Strong | Self::Strict) + } + + /// Return whether latent-mean comparison is licensed. + #[must_use] + pub const fn licenses_latent_mean_comparison(self) -> bool { + matches!(self, Self::Strong | Self::Strict) + } +} + +/// One group's factor-score and indicator series. +#[derive(Clone, Debug, PartialEq)] +pub struct GroupIndicatorSeries { + /// Factor scores for the group. + pub factor_scores: Vec, + /// Indicator coordinates for the group. + pub indicators: Vec, +} + +/// Two-group OLS measurement parameters and status. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TwoGroupMeasurement { + /// Reference-group intercept. + pub reference_intercept: f64, + /// Reference-group loading. + pub reference_loading: f64, + /// Comparison-group intercept. + pub comparison_intercept: f64, + /// Comparison-group loading. + pub comparison_loading: f64, + /// Reference residual variance. + pub reference_residual_variance: f64, + /// Comparison residual variance. + pub comparison_residual_variance: f64, + /// Classified invariance status. + pub status: MeanInvarianceStatus, +} + +/// Classify two-group OLS invariance from loadings, intercepts, and residuals. +/// +/// # Errors +/// +/// Returns indicator-kind or OLS errors from either group, and +/// [`PsychometricError::InvalidNumericInput`] when a tolerance is non-finite +/// or negative. +pub fn classify_two_group_ols_invariance( + reference: &GroupIndicatorSeries, + comparison: &GroupIndicatorSeries, + kind: IndicatorKind, + loading_tolerance: f64, + intercept_tolerance: f64, + residual_tolerance: f64, +) -> Result { + require_valid_indicator(kind)?; + if !loading_tolerance.is_finite() + || loading_tolerance < 0.0 + || !intercept_tolerance.is_finite() + || intercept_tolerance < 0.0 + || !residual_tolerance.is_finite() + || residual_tolerance < 0.0 + { + return Err(PsychometricError::InvalidNumericInput); + } + let reference_fit = + ordinary_least_squares_fit(&reference.factor_scores, &reference.indicators)?; + let comparison_fit = + ordinary_least_squares_fit(&comparison.factor_scores, &comparison.indicators)?; + let loading_gap = (reference_fit.slope - comparison_fit.slope).abs(); + let intercept_gap = (reference_fit.intercept - comparison_fit.intercept).abs(); + let residual_gap = (reference_fit.residual_variance - comparison_fit.residual_variance).abs(); + let residual_degrees_of_freedom = + reference.factor_scores.len() > 2 && comparison.factor_scores.len() > 2; + let status = if loading_gap > loading_tolerance { + MeanInvarianceStatus::Configural + } else if intercept_gap > intercept_tolerance { + MeanInvarianceStatus::Metric + } else if !residual_degrees_of_freedom || residual_gap > residual_tolerance { + MeanInvarianceStatus::Strong + } else { + MeanInvarianceStatus::Strict + }; + Ok(TwoGroupMeasurement { + reference_intercept: reference_fit.intercept, + reference_loading: reference_fit.slope, + comparison_intercept: comparison_fit.intercept, + comparison_loading: comparison_fit.slope, + reference_residual_variance: reference_fit.residual_variance, + comparison_residual_variance: comparison_fit.residual_variance, + status, + }) +} + +/// Recover `(ȳ_c − ȳ_r) / λ` only under strong or strict invariance. +/// +/// Metric/weak (equal loading, different intercept) fails closed. +/// +/// # Errors +/// +/// Returns [`PsychometricError::StrongInvarianceRequired`] when the classified +/// status is configural or metric, [`PsychometricError::SingularDesign`] when +/// the common loading is zero, and otherwise the classification errors. +pub fn recover_strong_gated_latent_mean_difference( + reference: &GroupIndicatorSeries, + comparison: &GroupIndicatorSeries, + kind: IndicatorKind, + loading_tolerance: f64, + intercept_tolerance: f64, + residual_tolerance: f64, +) -> Result { + let measurement = classify_two_group_ols_invariance( + reference, + comparison, + kind, + loading_tolerance, + intercept_tolerance, + residual_tolerance, + )?; + if !measurement.status.licenses_latent_mean_comparison() { + return Err(PsychometricError::StrongInvarianceRequired); + } + let loading = f64::midpoint( + measurement.reference_loading, + measurement.comparison_loading, + ); + if loading == 0.0 { + return Err(PsychometricError::SingularDesign); + } + let reference_mean = series_mean(&reference.indicators)?; + let comparison_mean = series_mean(&comparison.indicators)?; + require_finite((comparison_mean - reference_mean) / loading) +} + +fn series_mean(values: &[f64]) -> Result { + let mut sum = 0.0_f64; + for &value in values { + sum += value; + } + require_finite(sum / values.len() as f64) +} + +#[cfg(test)] +mod tests { + use super::{ + GroupIndicatorSeries, MeanInvarianceStatus, classify_two_group_ols_invariance, + recover_strong_gated_latent_mean_difference, + }; + use crate::error::PsychometricError; + use crate::indicator::IndicatorKind; + + fn series(factors: &[f64], intercept: f64, loading: f64) -> GroupIndicatorSeries { + GroupIndicatorSeries { + factor_scores: factors.to_vec(), + indicators: factors + .iter() + .map(|score| intercept + loading * score) + .collect(), + } + } + + #[test] + fn hash84_metric_wire_name_does_not_license_latent_means() { + assert_eq!( + MeanInvarianceStatus::Metric.as_measurement_invariance_wire_name(), + Some("metric") + ); + assert!(MeanInvarianceStatus::Metric.licenses_shared_metric_meaning()); + assert!(!MeanInvarianceStatus::Metric.licenses_latent_mean_comparison()); + assert_eq!(MeanInvarianceStatus::Metric.as_str(), "metric"); + assert_eq!(MeanInvarianceStatus::Configural.as_str(), "configural"); + assert_eq!(MeanInvarianceStatus::Strict.as_str(), "strict"); + assert_eq!( + MeanInvarianceStatus::Strict.as_measurement_invariance_wire_name(), + None + ); + } + + #[test] + fn hash84_scalar_wire_name_is_strong_and_licenses_means() { + assert_eq!( + MeanInvarianceStatus::Strong.as_measurement_invariance_wire_name(), + Some("scalar") + ); + assert!(MeanInvarianceStatus::Strong.licenses_shared_metric_meaning()); + assert!(MeanInvarianceStatus::Strong.licenses_latent_mean_comparison()); + assert_eq!(MeanInvarianceStatus::Strong.as_str(), "strong"); + assert!(MeanInvarianceStatus::Strict.licenses_latent_mean_comparison()); + assert_eq!( + MeanInvarianceStatus::Strict.as_measurement_invariance_wire_name(), + None + ); + assert!(!MeanInvarianceStatus::Configural.licenses_shared_metric_meaning()); + assert!(!MeanInvarianceStatus::Configural.licenses_latent_mean_comparison()); + assert_eq!( + MeanInvarianceStatus::Configural.as_measurement_invariance_wire_name(), + Some("configural") + ); + // `#84` InvarianceLevel::from_wire_name recognizes only these three. + let hash84_wire_names = ["configural", "metric", "scalar"]; + for status in [ + MeanInvarianceStatus::Configural, + MeanInvarianceStatus::Metric, + MeanInvarianceStatus::Strong, + ] { + let wire = status + .as_measurement_invariance_wire_name() + .expect("configural/metric/strong map onto #84"); + assert!(hash84_wire_names.contains(&wire)); + } + assert!(!hash84_wire_names.contains(&MeanInvarianceStatus::Strict.as_str())); + assert!(!hash84_wire_names.contains(&"strict")); + } + + #[test] + fn strong_invariance_recovers_latent_mean_difference() { + let reference = series(&[-1.0, 0.0, 1.0], 0.5, 1.2); + let comparison = series(&[1.0, 2.0, 3.0], 0.5, 1.2); + let difference = recover_strong_gated_latent_mean_difference( + &reference, + &comparison, + IndicatorKind::AdditiveLogRatio, + 1e-9, + 1e-9, + 1e-9, + ) + .expect("strong"); + // ȳ_r = 0.5, ȳ_c = 0.5+1.2*2 = 2.9, diff/λ = 2.4/1.2 = 2.0 + assert!((difference - 2.0).abs() < 1e-12); + let classified = classify_two_group_ols_invariance( + &reference, + &comparison, + IndicatorKind::AdditiveLogRatio, + 1e-9, + 1e-9, + 1e-9, + ) + .expect("class"); + assert_eq!(classified.status, MeanInvarianceStatus::Strict); + } + + #[test] + fn metric_only_and_configural_refuse_latent_means() { + let reference = series(&[-1.0, 0.0, 1.0], 0.5, 1.2); + let metric_only = series(&[1.0, 2.0, 3.0], 1.5, 1.2); + assert_eq!( + recover_strong_gated_latent_mean_difference( + &reference, + &metric_only, + IndicatorKind::AdditiveLogRatio, + 1e-9, + 1e-9, + 1e-9, + ), + Err(PsychometricError::StrongInvarianceRequired) + ); + let classified = classify_two_group_ols_invariance( + &reference, + &metric_only, + IndicatorKind::AdditiveLogRatio, + 1e-9, + 1e-9, + 1e-9, + ) + .expect("metric"); + assert_eq!(classified.status, MeanInvarianceStatus::Metric); + + let configural = series(&[1.0, 2.0, 3.0], 0.5, 0.4); + assert_eq!( + recover_strong_gated_latent_mean_difference( + &reference, + &configural, + IndicatorKind::AdditiveLogRatio, + 1e-9, + 1e-9, + 1e-9, + ), + Err(PsychometricError::StrongInvarianceRequired) + ); + let classified = classify_two_group_ols_invariance( + &reference, + &configural, + IndicatorKind::AdditiveLogRatio, + 1e-9, + 1e-9, + 1e-9, + ) + .expect("configural"); + assert_eq!(classified.status, MeanInvarianceStatus::Configural); + } + + #[test] + fn two_observation_series_cap_at_strong_and_still_license_means() { + let reference = series(&[-1.0, 1.0], 0.5, 1.2); + let comparison = series(&[0.0, 2.0], 0.5, 1.2); + let classified = classify_two_group_ols_invariance( + &reference, + &comparison, + IndicatorKind::AdditiveLogRatio, + 1e-9, + 1e-9, + 1e-9, + ) + .expect("two-obs"); + assert_eq!(classified.status, MeanInvarianceStatus::Strong); + assert_eq!( + classified.reference_residual_variance.to_bits(), + 0.0_f64.to_bits() + ); + assert_eq!( + classified.comparison_residual_variance.to_bits(), + 0.0_f64.to_bits() + ); + let difference = recover_strong_gated_latent_mean_difference( + &reference, + &comparison, + IndicatorKind::AdditiveLogRatio, + 1e-9, + 1e-9, + 1e-9, + ) + .expect("licensed"); + assert!((difference - 1.0).abs() < 1e-12); + } + + #[test] + fn strong_but_not_strict_still_licenses_means() { + let reference = GroupIndicatorSeries { + factor_scores: vec![-2.0, -1.0, 0.0, 1.0, 2.0], + indicators: vec![0.5 - 2.4, 0.5 - 1.2, 0.5, 0.5 + 1.2, 0.5 + 2.4], + }; + let comparison = GroupIndicatorSeries { + factor_scores: vec![-2.0, -1.0, 0.0, 1.0, 2.0], + indicators: vec![ + 0.5 - 2.4 + 0.2, + 0.5 - 1.2 - 0.4, + 0.5, + 0.5 + 1.2 + 0.4, + 0.5 + 2.4 - 0.2, + ], + }; + let classified = classify_two_group_ols_invariance( + &reference, + &comparison, + IndicatorKind::LogisticNormal, + 0.05, + 0.2, + 1e-12, + ) + .expect("strong"); + assert_eq!(classified.status, MeanInvarianceStatus::Strong); + let difference = recover_strong_gated_latent_mean_difference( + &reference, + &comparison, + IndicatorKind::LogisticNormal, + 0.05, + 0.2, + 1e-12, + ) + .expect("licensed"); + assert!(difference.is_finite()); + } + + #[test] + fn invalid_tolerance_raw_kind_and_zero_loading_fail() { + let reference = series(&[-1.0, 0.0, 1.0], 0.0, 1.0); + let comparison = series(&[0.0, 1.0, 2.0], 0.0, 1.0); + assert_eq!( + classify_two_group_ols_invariance( + &reference, + &comparison, + IndicatorKind::AdditiveLogRatio, + f64::NAN, + 1e-9, + 1e-9, + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + classify_two_group_ols_invariance( + &reference, + &comparison, + IndicatorKind::AdditiveLogRatio, + -0.1, + 1e-9, + 1e-9, + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + classify_two_group_ols_invariance( + &reference, + &comparison, + IndicatorKind::AdditiveLogRatio, + 1e-9, + f64::INFINITY, + 1e-9, + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + classify_two_group_ols_invariance( + &reference, + &comparison, + IndicatorKind::AdditiveLogRatio, + 1e-9, + -0.01, + 1e-9, + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + classify_two_group_ols_invariance( + &reference, + &comparison, + IndicatorKind::AdditiveLogRatio, + 1e-9, + 1e-9, + f64::NAN, + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + classify_two_group_ols_invariance( + &reference, + &comparison, + IndicatorKind::AdditiveLogRatio, + 1e-9, + 1e-9, + -1.0, + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_strong_gated_latent_mean_difference( + &reference, + &comparison, + IndicatorKind::RawProportion, + 1e-9, + 1e-9, + 1e-9, + ), + Err(PsychometricError::RawProportionForbidden) + ); + let zero = series(&[-1.0, 0.0, 1.0], 2.0, 0.0); + let other = series(&[0.0, 1.0, 2.0], 2.0, 0.0); + assert_eq!( + recover_strong_gated_latent_mean_difference( + &zero, + &other, + IndicatorKind::IsometricLogRatio, + 1e-9, + 1e-9, + 1e-9, + ), + Err(PsychometricError::SingularDesign) + ); + } +} diff --git a/crates/psychometric_core/src/lib.rs b/crates/psychometric_core/src/lib.rs new file mode 100644 index 00000000..10741bbf --- /dev/null +++ b/crates/psychometric_core/src/lib.rs @@ -0,0 +1,619 @@ +#![forbid(unsafe_code)] +#![deny(missing_docs)] +#![allow(clippy::cast_precision_loss)] +//! Posterior-aware psychometric input gates for ESEM/DSEM. +//! +//! Raw topic proportions are not unconstrained structural indicators. This +//! crate classifies constructs, admits mapped log-ratio/logistic-normal inputs, +//! distinguishes ALR from orthonormal ILR geometry, averages loading point +//! estimates across posterior draws on a CPU `f64` path without claiming Rubin +//! uncertainty pooling, combines draw-level OLS loadings with Rubin `T`, +//! decomposes cluster-mean within/between OLS and the CWC contextual effect, +//! maps event-time discrete lags through the exact scalar exponential, maps +//! already-centered irregular residuals without re-centering, remaps discrete +//! lags across unequal event intervals through that log-rate, recovers the +//! exact scalar discrete effect of a constant predictor, recovers the +//! first-order discrete effect of a time-varying predictor with matched +//! sampling and constancy intervals, recovers the exact scalar discrete +//! process noise of Driver et al. (2017, Eq. 3), recovers the lagged +//! latent covariance and unconditional latent variance licensed by +//! their Eq. 3–4, recovers the scalar stationary within-subject +//! variance (Driver et al., 2017, Eq. 4 as `Δt → ∞`; §4.3; p. 16 +//! `asymDIFFUSION`), recovers the Driver §4.3 trait-plus-state +//! variance and lagged covariance (`TRAITVAR` is not process noise +//! and not `asymDIFFUSION`), recovers the Driver Eq. 5 scalar +//! observed-indicator variance (`λ² Var(η) + θ` when +//! `MANIFESTTRAITVAR` is zero, else `λ² Var(η) + θ + ψ`; Table 2, +//! p. 12: `MANIFESTVAR` is `Θ`, not `Var(y)`; `MANIFESTTRAITVAR` is +//! not `MANIFESTVAR`; lagged observed covariance is +//! `λ² cov(η_t, η_{t-1}) + ψ` and does not include `Θ`; the +//! observed-indicator mean is `τ + λ μ` (`MANIFESTMEANS` is `τ`, +//! not `E(y)`; `CINT` is not `MANIFESTMEANS`; Equation 1 +//! is the SDE), recovers the Driver Eq. 3 expected-value latent +//! mean `exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ` (`T0MEANS` is not +//! `μ_t`; `CINT` is not the discrete increment), recovers the +//! Driver Eq. 5 of that evolved mean as `τ + λ μ_t` (the +//! first-occasion map `τ + λ μ_0` is not `E(y_t)`), recovers the +//! Driver Eq. 3 fourth-summand impulse `m x` (Table 2 `TDPREDEFFECT` +//! is `M`, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14), +//! recovers the Driver Eq. 5 of that contemporaneous impulse as +//! `τ + λ(μ_t + m x)` (`τ + λ μ_t` is not that observed mean; +//! `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when +//! `u ≠ t`), recovers the Driver Eq. 1–2 within-interval impulse carry +//! `e^{A(t−u)} M x` for `t0 < u < t` (not the contemporaneous Dirac, +//! not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; §7.2 +//! dissipation), recovers the Driver Eq. 5 of that carried latent +//! mean as `τ + λ(μ_t + e^{a(t−u)} m x)` (`τ + λ μ_t` is not that +//! observed mean), recovers the Driver Eq. 3 second-summand +//! time-independent predictor increment `A^{-1}[e^{A Δt} − I] B z` +//! (Table 2 `TIPREDEFFECT` is `B`, not `κ`, not `M`, and not Voelkle +//! Eq. 14; `B` is not that discrete increment), recovers the Driver +//! Eq. 5 of that increment as +//! `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` (`τ + λ μ_t` is not that +//! observed mean; `τ + λ(μ_t + m x)` is not that observed mean; +//! `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when +//! `u ≠ t`), recovers the Driver Table 3 first-occasion +//! `T0TIPREDEFFECT` shift `t0_b z` and its Eq. 3 first-summand carry +//! `e^{A Δt} t0_b z` (`T0TIPREDEFFECT` is not `TIPREDEFFECT` `B`; +//! `t0_b z` is not `A^{-1}[e^{A Δt} − I] B z`; `e^{A Δt} t0_b z` is +//! not `t0_b z`), recovers the Driver Eq. 5 of that first-occasion +//! carry as `τ + λ(μ_t + e^{a Δt} t0_b z)` (`τ + λ μ_t` is not that +//! observed mean; `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not +//! that observed mean; `τ + λ(μ_t + m x)` is not that observed mean; +//! `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when +//! `u ≠ t0`), recovers the Driver Table 3 first-occasion +//! `T0TDPREDEFFECT` shift `t0_m x0` and its Eq. 3 first-summand carry +//! `e^{A Δt} t0_m x0` (`T0TDPREDEFFECT` is not `TDPREDEFFECT` `M`; +//! `t0_m x0` is not `M x`; `e^{A Δt} t0_m x0` is not `t0_m x0`; +//! `e^{A Δt} t0_m x0` is not `e^{A(t−u)} M x` for `t0 < u < t`; +//! `t0_m x0` is not `t0_b z`; an impulse at `u ≤ t0` that used `M` +//! is already in `η(t0)` as `TDPREDEFFECT`, not as `T0TDPREDEFFECT`), +//! recovers the Driver Eq. 5 of that first-occasion TD carry as +//! `τ + λ(μ_t + e^{a Δt} t0_m x0)` (`τ + λ μ_t` is not that observed +//! mean; `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not that +//! observed mean; `τ + λ(μ_t + m x)` is not that observed mean; +//! `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when +//! `u ≠ t0`; `τ + λ(μ_t + e^{a Δt} t0_b z)` is not that observed +//! mean), +//! recovers the Driver §7.2 level-change `CINT` setting `κ = −a m x` +//! (`a < 0` so `−κ / a = m x`; not the dissipating Dirac `m x`, not +//! a free `CINT`, not `A^{-1}[e^{A Δt} − I] B z`, and not the extra +//! near-zero-drift latent process also named in §7.2), +//! recovers the Driver Eq. 3 increment of that setting as +//! `(1 − e^{a Δt}) m x` (`(1 − e^{a Δt}) m x` is not `m x`, not `κ`, +//! and not `A^{-1}[e^{A Δt} − I] B z`), +//! recovers the Driver §7.2 extra near-zero-drift latent process +//! contribution `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` (pp. 22–23; +//! identification `TDPREDEFFECT` on the extra process is 1; `ε < 0`; +//! printed extra `DRIFT` is `−0.000001`; not `κ = −a m x`, not +//! `(1 − e^{a Δt}) m x`, and not the dissipating Dirac `m x`), +//! recovers the Driver Eq. 5 of that extra-process contribution as +//! `τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a))` (Eq. 5, +//! p. 5; §7.2, pp. 22–23; JSS PDF re-opened 2026-08-21T06:12Z; the +//! extra process has `LAMBDA` 0 and is not an observed indicator; +//! `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not +//! that observed mean; the contribution is not `E(y_t)`; the +//! evolved-plus-contribution latent mean is not `E(y_t)`), +//! recovers the Driver §7.2 after-t0 extra-process `TDPREDEFFECT` +//! contribution as `a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a)` +//! for `t0 < u < t` (`T0TDPREDEFFECT` uses `Δt = t − t0` for both +//! the evolution and the extra drive; an impulse at `u = t0` is not +//! this map; an impulse at `u = t` has not yet driven the original +//! process; `e^{a(t−u)} m x` is a Dirac on the original process, not +//! this `DRIFT` drive), +//! recovers the Driver Eq. 5 of that after-t0 extra-process +//! contribution as +//! `τ + λ(μ_t + a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a))` +//! (JSS PDF re-opened 2026-08-21T06:32Z; the first-occasion +//! extra-process observed mean is not that observed mean when +//! `u ≠ t0`; `τ + λ μ_t` is not that observed mean; the impulse-carry +//! map is not that observed mean; the after-t0 contribution is not +//! `E(y_t)`; the evolved-plus-after-contribution latent mean is not +//! `E(y_t)`), +//! recovers the Driver §7.2 `asymTIPREDEFFECT` as `-B z / a` +//! (pp. 20–21; JSS PDF opened 2026-08-21T13:08Z; expected total +//! change in process means given a time-independent predictor; +//! `a < 0`; not the coefficient `B`, not +//! `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, and not `M x`), +//! recovers the Driver §7.2 `addedTIPREDVAR` as `(B / a)² v` +//! (pp. 20–21; stable between-subject variance accounted for by a +//! time-independent predictor with variance `v`; not `TRAITVAR`, +//! not `asymDIFFUSION`, and not `-B z / a`), +//! recovers the Driver Table 2 `asymCINT` as `-κ / a` +//! (p. 12; Eq. 3 as `Δt → ∞`; JSS PDF opened 2026-08-21T16:13Z; +//! expected change in process means for a unit intercept; `a < 0`; +//! not `κ`, not `A^{-1}[e^{A Δt} − I] κ`, not `T0MEANS`, and not +//! `-B z / a`; p. 16 `T0MEANS` stationarity includes TI predictors; +//! that composition is not this intercept-only map), +//! recovers the Driver p. 16 / §4.3 stationary `T0MEANS` as +//! `-κ / a + −B z / a` +//! (constrained first-occasion mean; form the intercept +//! contribution first, then include the TI extra effect, then add; +//! not free `T0MEANS`, not `asymCINT` alone, not +//! `asymTIPREDEFFECT` alone, and not the finite-interval discrete +//! latent mean), +//! recovers the Driver Eq. 5 of that constrained mean as +//! `τ + λ(−κ / a + −B z / a)` +//! (§4.3, pp. 9–10; Eq. 5, p. 5; JSS PDF re-opened 2026-08-21T20:07Z; +//! form the stationary latent mean first, then `τ + λ` of that mean; +//! `τ + λ μ_0` is not that observed mean; `τ + λ(−κ / a)` is not +//! that observed mean when `B z ≠ 0`; `τ + λ μ_t` is not that +//! observed mean; `MANIFESTMEANS` is not `E(y_0)`; the constrained +//! latent mean is not `E(y_0)`), +//! recovers the Driver §4.3 / p. 16 stationary `T0VAR` as +//! `trait + −q / (2 a) + (B / a)² v` +//! (JSS PDF re-opened 2026-08-22T03:07Z; constrained first-occasion +//! variance; form the within-subject contribution first, then +//! include the trait, then include the TI extra variance, then add; +//! not free `T0VAR`, not `asymDIFFUSION` alone, not `TRAITVAR` +//! alone, not `addedTIPREDVAR` alone, and not the finite-interval +//! discrete latent variance), +//! recovers the Driver Eq. 5 of that constrained variance as +//! `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` +//! (§4.3, pp. 9–10; Eq. 5, p. 5; Table 2, p. 12; JSS PDF re-opened +//! 2026-08-22T03:20Z; form the stationary latent variance first, +//! then `λ² p + θ + ψ`; `λ² p_0` is not that observed variance; +//! `λ²(−q / (2 a)) + θ` is not that observed variance when +//! `TRAITVAR` or `addedTIPREDVAR` is nonzero; `MANIFESTVAR` is not +//! `Var(y_0)`; the constrained latent variance is not `Var(y_0)`), +//! recovers the Driver Eq. 3–4 lagged covariance of that constrained +//! process as `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` +//! (JSS PDF re-opened 2026-08-22T19:13Z; form the lagged +//! within-subject covariance first, then include the trait, then +//! include the TI extra variance, then add; trait and +//! `addedTIPREDVAR` do not decay with `e^{a Δt}`; contemporaneous +//! `T0VAR` is not that lagged map; decaying the constrained total +//! as if it were all state is not that lagged map), +//! recovers the Driver Eq. 5 of that lagged covariance as +//! `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ` +//! (`Θ` does not enter; contemporaneous `Var(y_0)` is not that +//! lagged observed covariance; the lagged latent covariance is not +//! that observed covariance), +//! recovers the Driver Eq. 3–4 later-occasion variance of that +//! constrained process as +//! `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v` +//! (JSS PDF re-opened 2026-08-22T23:12Z; form the evolved +//! within-subject variance first, then include the trait, then +//! include the TI extra variance, then add; trait and +//! `addedTIPREDVAR` do not enter `Q_Δt`; under stationarity that +//! composition equals contemporaneous `T0VAR`; evolving the +//! constrained total as if it were all state is not that later +//! map; the lagged covariance omits `Q_Δt` and is not that later +//! map; `Q_Δt` is not that later map), +//! recovers the Driver Eq. 5 of that later-occasion variance as +//! `λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ` +//! (the lagged observed covariance omits `Q_Δt` and `θ`; +//! `MANIFESTVAR` is not that later observed variance; the +//! later-occasion latent variance is not that observed variance), +//! and refuses +//! latent-mean comparison below strong invariance. + +mod causality; +mod cluster_mean; +mod construct; +mod error; +mod event_time; +mod indicator; +mod latent_mean; +mod loading; +mod plausible; +mod rubin_total; + +/// A heuristic that is not causal identification. +pub use causality::CausalHeuristic; +/// Refuse a causal-effect claim from a non-identifying heuristic. +pub use causality::claim_causal_effect; +/// One clustered predictor–outcome pair. +pub use cluster_mean::ClusteredScore; +/// Recovered within-cluster, between-cluster, and contextual OLS slopes. +pub use cluster_mean::WithinBetweenSlopes; +/// Kish effective sample size on psychometric weights. +pub use cluster_mean::kish_effective_sample_size; +/// Cluster-mean within/between OLS after CWC, plus the contextual effect. +pub use cluster_mean::recover_cluster_mean_within_between_slopes; +/// Kish-weighted least-squares slope. +pub use cluster_mean::recover_kish_weighted_slope; +/// Higher-order construct class. +pub use construct::ConstructClass; +/// Typed invariance evidence required before a latent-mean comparison. +pub use construct::LatentMeanComparisonEvidence; +/// Permit latent-mean comparison only on strong/strict typed evidence. +pub use construct::compare_latent_means; +/// Refuse fit-driven reinterpretation as reflective. +pub use construct::interpret_as_reflective; +/// Fail-closed psychometric errors. +pub use error::PsychometricError; +/// One clustered event-time score. +pub use event_time::ClusteredEventScore; +/// Discrete lag-1 coefficient and local log-rate. +pub use event_time::DiscreteLagAndLogRate; +/// One event-time occasion. +pub use event_time::EventOccasion; +/// Clock on which a structural lag may be computed. +pub use event_time::LagClock; +/// Already-centered lagged residual pair with an irregular event interval. +pub use event_time::LaggedWithinResidual; +/// Map a discrete lag onto another event interval through the exact log-rate. +pub use event_time::map_discrete_lag_across_event_intervals; +/// Exact scalar Table 2 `asymCINT` `-κ / a`. +pub use event_time::recover_asymptotic_continuous_intercept; +/// Exact scalar §7.2 `asymTIPREDEFFECT` `-B z / a`. +pub use event_time::recover_asymptotic_time_independent_predictor_effect; +/// Exact scalar §7.2 `addedTIPREDVAR` `(B / a)² v`. +pub use event_time::recover_asymptotic_time_independent_predictor_variance; +/// Exact scalar discrete effect of a constant event-time predictor. +pub use event_time::recover_discrete_constant_predictor_effect; +/// Exact scalar discrete intercept increment `A^{-1}[e^{A Δt} − I] κ`. +pub use event_time::recover_discrete_continuous_intercept_effect; +/// Exact scalar forward map `φ = exp(a Δt)`. +pub use event_time::recover_discrete_lag_from_log_rate; +/// Noiseless scalar discrete lag `later / earlier`. +pub use event_time::recover_discrete_lag_one; +/// Exact scalar lagged latent covariance `A_Δt cov(η_{t-1})`. +pub use event_time::recover_discrete_lagged_latent_covariance; +/// Exact scalar discrete latent mean `exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ`. +pub use event_time::recover_discrete_latent_mean; +/// Exact scalar evolved latent mean plus a §7.2 extra-process contribution. +pub use event_time::recover_discrete_latent_mean_with_extra_process; +/// Exact scalar evolved latent mean plus a §7.2 extra-process contribution after t0. +pub use event_time::recover_discrete_latent_mean_with_extra_process_after; +/// Exact scalar evolved latent mean plus a contemporaneous impulse. +pub use event_time::recover_discrete_latent_mean_with_impulse; +/// Exact scalar evolved latent mean plus a within-interval impulse carry. +pub use event_time::recover_discrete_latent_mean_with_impulse_carry; +/// Exact scalar evolved latent mean plus a first-occasion TD predictor. +pub use event_time::recover_discrete_latent_mean_with_initial_time_dependent_predictor; +/// Exact scalar evolved latent mean plus a first-occasion TI predictor. +pub use event_time::recover_discrete_latent_mean_with_initial_time_independent_predictor; +/// Exact scalar evolved latent mean plus a time-independent predictor. +pub use event_time::recover_discrete_latent_mean_with_time_independent_predictor; +/// Exact scalar discrete latent variance `A_Δt P A_Δt⊤ + Q_Δt`. +pub use event_time::recover_discrete_latent_variance; +/// Exact scalar discrete observed mean `τ + λ μ_t` from Eq. 3 then Eq. 5. +pub use event_time::recover_discrete_observed_mean; +/// Exact scalar discrete observed mean of a §7.2 extra-process contribution. +pub use event_time::recover_discrete_observed_mean_with_extra_process; +/// Exact scalar discrete observed mean of a §7.2 extra-process contribution after t0. +pub use event_time::recover_discrete_observed_mean_with_extra_process_after; +/// Exact scalar discrete observed mean of a contemporaneous impulse. +pub use event_time::recover_discrete_observed_mean_with_impulse; +/// Exact scalar discrete observed mean of a within-interval impulse carry. +pub use event_time::recover_discrete_observed_mean_with_impulse_carry; +/// Exact scalar discrete observed mean of a first-occasion TD predictor. +pub use event_time::recover_discrete_observed_mean_with_initial_time_dependent_predictor; +/// Exact scalar discrete observed mean of a first-occasion TI predictor. +pub use event_time::recover_discrete_observed_mean_with_initial_time_independent_predictor; +/// Exact scalar discrete observed mean of a time-independent predictor. +pub use event_time::recover_discrete_observed_mean_with_time_independent_predictor; +/// Exact scalar discrete process noise `Q_Δt` on event time. +pub use event_time::recover_discrete_process_noise; +/// Exact scalar discrete `TIPREDEFFECT` increment `A^{-1}[e^{A Δt} − I] B z`. +pub use event_time::recover_discrete_time_independent_predictor_effect; +/// First-order discrete effect of a time-varying event-time predictor. +pub use event_time::recover_discrete_time_varying_predictor_effect; +/// Mean local log-rate on a sorted event-time series. +pub use event_time::recover_event_series_mean_log_rate; +/// Exact scalar pair `(φ, a)` on event time. +pub use event_time::recover_event_time_discrete_lag_and_log_rate; +/// Exact scalar carried first-occasion `T0TDPREDEFFECT` `e^{A Δt} t0_m x0`. +pub use event_time::recover_initial_time_dependent_predictor_carry; +/// Exact scalar first-occasion `T0TDPREDEFFECT` shift `t0_m x0`. +pub use event_time::recover_initial_time_dependent_predictor_effect; +/// Exact scalar carried first-occasion `T0TIPREDEFFECT` `e^{A Δt} t0_b z`. +pub use event_time::recover_initial_time_independent_predictor_carry; +/// Exact scalar first-occasion `T0TIPREDEFFECT` shift `t0_b z`. +pub use event_time::recover_initial_time_independent_predictor_effect; +/// Mean exact log-rate on already-centered irregular residuals. +pub use event_time::recover_irregular_centered_residual_log_rate; +/// Exact scalar §7.2 level-change `CINT` `κ = −a m x`. +pub use event_time::recover_level_change_continuous_intercept; +/// Exact scalar Eq. 3 increment of that `CINT` `(1 − e^{a Δt}) m x`. +pub use event_time::recover_level_change_discrete_increment; +/// Exact scalar §7.2 extra-process contribution `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)`. +pub use event_time::recover_level_change_extra_process_contribution; +/// Exact scalar §7.2 extra-process contribution after t0 on `t − u`. +pub use event_time::recover_level_change_extra_process_contribution_after; +/// Exact scalar inverse `a = ln(φ) / Δt`. +pub use event_time::recover_local_log_rate; +/// Exact scalar lagged observed-indicator covariance `λ² cov(η) + ψ`. +pub use event_time::recover_manifest_lagged_observed_covariance; +/// Exact scalar observed-indicator mean `τ + λ μ`. +pub use event_time::recover_manifest_observed_mean; +/// Exact scalar observed-indicator variance `λ² Var(η) + θ`. +pub use event_time::recover_manifest_observed_variance; +/// Exact scalar observed-indicator variance `λ² Var(η) + θ + ψ`. +pub use event_time::recover_manifest_trait_plus_state_observed_variance; +/// Exact scalar p. 16 stationary `T0MEANS` `-κ / a + −B z / a`. +pub use event_time::recover_stationary_initial_latent_mean; +/// Exact scalar §4.3 / p. 16 stationary `T0VAR` `trait + −q / (2 a) + (B / a)² v`. +pub use event_time::recover_stationary_initial_latent_variance; +/// Exact scalar Eq. 5 of §4.3 stationary `T0MEANS` `τ + λ(−κ / a + −B z / a)`. +pub use event_time::recover_stationary_initial_observed_mean; +/// Exact scalar Eq. 5 of §4.3 stationary `T0VAR` `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ`. +pub use event_time::recover_stationary_initial_observed_variance; +/// Exact scalar lagged covariance of §4.3 stationary `T0VAR` `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v`. +pub use event_time::recover_stationary_lagged_latent_covariance; +/// Exact scalar Eq. 5 of lagged §4.3 stationary `T0VAR` `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ`. +pub use event_time::recover_stationary_lagged_observed_covariance; +/// Exact scalar stationary within-subject variance `-q / (2 a)`. +pub use event_time::recover_stationary_latent_variance; +/// Exact scalar later-occasion variance of §4.3 stationary `T0VAR` `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v`. +pub use event_time::recover_stationary_later_latent_variance; +/// Exact scalar Eq. 5 of later-occasion §4.3 stationary `T0VAR` `λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ`. +pub use event_time::recover_stationary_later_observed_variance; +/// Exact scalar contemporaneous `TDPREDEFFECT` impulse `m x`. +pub use event_time::recover_time_dependent_predictor_impulse; +/// Exact scalar within-interval `TDPREDEFFECT` carry `e^{A(t−u)} M x`. +pub use event_time::recover_time_dependent_predictor_impulse_carry; +/// Exact scalar trait-plus-state lagged covariance. +pub use event_time::recover_trait_plus_state_lagged_covariance; +/// Exact scalar trait-plus-state latent variance. +pub use event_time::recover_trait_plus_state_latent_variance; +/// CWC-then-event-time local log-rate (not DSEM; not raw-process AR drift). +pub use event_time::recover_within_residual_event_time_log_rate; +/// Refuse treating the after-t0 extra-process contribution as `E(y_t)`. +pub use event_time::refuse_after_extra_process_contribution_as_observed_mean; +/// Refuse treating the evolved-plus-after-contribution latent mean as `E(y_t)`. +pub use event_time::refuse_after_extra_process_latent_mean_as_observed_mean; +/// Refuse treating Table 2 `asymCINT` as `asymTIPREDEFFECT`. +pub use event_time::refuse_asymptotic_continuous_intercept_as_asymptotic_time_independent_effect; +/// Refuse treating Table 2 `asymCINT` as `CINT`. +pub use event_time::refuse_asymptotic_continuous_intercept_as_continuous_intercept; +/// Refuse treating Table 2 `asymCINT` as the finite-interval discrete increment. +pub use event_time::refuse_asymptotic_continuous_intercept_as_discrete_increment; +/// Refuse treating Table 2 `asymCINT` as `T0MEANS`. +pub use event_time::refuse_asymptotic_continuous_intercept_as_initial_latent_mean; +/// Refuse treating `τ + λ(−κ / a)` as Eq. 5 of §4.3 stationary `T0MEANS`. +pub use event_time::refuse_asymptotic_continuous_intercept_observed_mean_as_stationary_initial_observed_mean; +/// Refuse treating §7.2 `asymTIPREDEFFECT` as `TIPREDEFFECT` `B`. +pub use event_time::refuse_asymptotic_time_independent_effect_as_coefficient; +/// Refuse treating §7.2 `asymTIPREDEFFECT` as `CINT`. +pub use event_time::refuse_asymptotic_time_independent_effect_as_continuous_intercept; +/// Refuse treating §7.2 `asymTIPREDEFFECT` as the finite-interval discrete increment. +pub use event_time::refuse_asymptotic_time_independent_effect_as_discrete_effect; +/// Refuse treating §7.2 `asymTIPREDEFFECT` as `M x`. +pub use event_time::refuse_asymptotic_time_independent_effect_as_time_dependent_impulse; +/// Refuse treating §7.2 `addedTIPREDVAR` as `asymTIPREDEFFECT`. +pub use event_time::refuse_asymptotic_time_independent_variance_as_asymptotic_effect; +/// Refuse treating §7.2 `addedTIPREDVAR` as `asymDIFFUSION`. +pub use event_time::refuse_asymptotic_time_independent_variance_as_stationary_within_subject; +/// Refuse treating §7.2 `addedTIPREDVAR` as `TRAITVAR`. +pub use event_time::refuse_asymptotic_time_independent_variance_as_trait_variance; +/// Refuse treating Driver Table 2 `CINT` as the discrete mean increment. +pub use event_time::refuse_continuous_intercept_as_discrete_mean_increment; +/// Refuse treating Driver Table 2 `CINT` as `T0MEANS`. +pub use event_time::refuse_continuous_intercept_as_initial_latent_mean; +/// Refuse treating Driver Table 2 `CINT` as `MANIFESTMEANS`. +pub use event_time::refuse_continuous_intercept_as_manifest_means; +/// Refuse the difference quotient as a continuous-time rate. +pub use event_time::refuse_difference_quotient_as_local_rate; +/// Refuse treating evolved `τ + λ μ_t` as the after-t0 extra-process observed mean. +pub use event_time::refuse_evolved_observed_mean_as_after_extra_process_observed_mean; +/// Refuse treating evolved `τ + λ μ_t` as the extra-process observed mean. +pub use event_time::refuse_evolved_observed_mean_as_extra_process_observed_mean; +/// Refuse treating evolved `τ + λ μ_t` as the impulse-carry observed mean. +pub use event_time::refuse_evolved_observed_mean_as_impulse_carry_observed_mean; +/// Refuse treating evolved `τ + λ μ_t` as the contemporaneous-impulse observed mean. +pub use event_time::refuse_evolved_observed_mean_as_impulse_observed_mean; +/// Refuse treating evolved `τ + λ μ_t` as the first-occasion TD-predictor observed mean. +pub use event_time::refuse_evolved_observed_mean_as_initial_time_dependent_observed_mean; +/// Refuse treating evolved `τ + λ μ_t` as the first-occasion TI-predictor observed mean. +pub use event_time::refuse_evolved_observed_mean_as_initial_time_independent_observed_mean; +/// Refuse treating evolved `τ + λ μ_t` as Eq. 5 of §4.3 stationary `T0MEANS`. +pub use event_time::refuse_evolved_observed_mean_as_stationary_initial_observed_mean; +/// Refuse treating evolved `τ + λ μ_t` as the time-independent-predictor observed mean. +pub use event_time::refuse_evolved_observed_mean_as_time_independent_observed_mean; +/// Refuse treating evolved `λ² Var(η_t) + θ` as Eq. 5 of §4.3 stationary `T0VAR`. +pub use event_time::refuse_evolved_observed_variance_as_stationary_initial_observed_variance; +/// Refuse treating the §7.2 extra-process contribution as `E(y_t)`. +pub use event_time::refuse_extra_process_contribution_as_observed_mean; +/// Refuse treating the evolved-plus-contribution latent mean as `E(y_t)`. +pub use event_time::refuse_extra_process_latent_mean_as_observed_mean; +/// Refuse treating the first-occasion extra-process observed mean as the after-t0 extra-process observed mean. +pub use event_time::refuse_extra_process_observed_mean_as_after_extra_process_observed_mean; +/// Refuse treating finite-interval `Q_Δt` as `asymDIFFUSION`. +pub use event_time::refuse_finite_interval_process_noise_as_stationary_variance; +/// Refuse treating impulse-carry `τ + λ(μ_t + e^{a(t−u)} m x)` as the after-t0 extra-process observed mean. +pub use event_time::refuse_impulse_carry_observed_mean_as_after_extra_process_observed_mean; +/// Refuse treating impulse-carry `τ + λ(μ_t + e^{a(t−u)} m x)` as the first-occasion TD-predictor observed mean. +pub use event_time::refuse_impulse_carry_observed_mean_as_initial_time_dependent_observed_mean; +/// Refuse treating impulse-carry `τ + λ(μ_t + e^{a(t−u)} m x)` as the first-occasion TI-predictor observed mean. +pub use event_time::refuse_impulse_carry_observed_mean_as_initial_time_independent_observed_mean; +/// Refuse treating impulse-carry `τ + λ(μ_t + e^{a(t−u)} m x)` as the time-independent-predictor observed mean. +pub use event_time::refuse_impulse_carry_observed_mean_as_time_independent_observed_mean; +/// Refuse treating contemporaneous `τ + λ(μ_t + m x)` as the extra-process observed mean. +pub use event_time::refuse_impulse_observed_mean_as_extra_process_observed_mean; +/// Refuse treating contemporaneous `τ + λ(μ_t + m x)` as the impulse-carry observed mean. +pub use event_time::refuse_impulse_observed_mean_as_impulse_carry_observed_mean; +/// Refuse treating contemporaneous `τ + λ(μ_t + m x)` as the first-occasion TD-predictor observed mean. +pub use event_time::refuse_impulse_observed_mean_as_initial_time_dependent_observed_mean; +/// Refuse treating contemporaneous `τ + λ(μ_t + m x)` as the first-occasion TI-predictor observed mean. +pub use event_time::refuse_impulse_observed_mean_as_initial_time_independent_observed_mean; +/// Refuse treating contemporaneous `τ + λ(μ_t + m x)` as the time-independent-predictor observed mean. +pub use event_time::refuse_impulse_observed_mean_as_time_independent_observed_mean; +/// Refuse treating Driver Table 2 `T0MEANS` as the evolved latent mean. +pub use event_time::refuse_initial_latent_mean_as_evolved_mean; +/// Refuse treating first-occasion `τ + λ μ_0` as `E(y_t)`. +pub use event_time::refuse_initial_observed_mean_as_evolved_observed_mean; +/// Refuse treating `τ + λ μ_0` as Eq. 5 of §4.3 stationary `T0MEANS`. +pub use event_time::refuse_initial_observed_mean_as_stationary_initial_observed_mean; +/// Refuse treating `λ² p_0 + θ` as Eq. 5 of §4.3 stationary `T0VAR`. +pub use event_time::refuse_initial_observed_variance_as_stationary_initial_observed_variance; +/// Refuse treating the Eq. 3 `T0TDPREDEFFECT` carry as the within-interval impulse carry. +pub use event_time::refuse_initial_time_dependent_carry_as_impulse_carry; +/// Refuse treating the Eq. 3 `T0TDPREDEFFECT` carry as the first-occasion shift. +pub use event_time::refuse_initial_time_dependent_carry_as_initial_effect; +/// Refuse treating Driver Table 3 `T0TDPREDEFFECT` as the first-occasion shift. +pub use event_time::refuse_initial_time_dependent_coefficient_as_initial_effect; +/// Refuse treating the Table 3 first-occasion TD shift as `M x`. +pub use event_time::refuse_initial_time_dependent_effect_as_contemporaneous_impulse; +/// Refuse treating the Table 3 first-occasion TD shift as `CINT`. +pub use event_time::refuse_initial_time_dependent_effect_as_continuous_intercept; +/// Refuse treating the Table 3 first-occasion TD shift as the Table 3 TI shift. +pub use event_time::refuse_initial_time_dependent_effect_as_initial_time_independent_effect; +/// Refuse treating the Table 3 first-occasion TD shift as the Eq. 3 process increment. +pub use event_time::refuse_initial_time_dependent_effect_as_process_increment; +/// Refuse treating the Eq. 3 `T0TIPREDEFFECT` carry as the first-occasion shift. +pub use event_time::refuse_initial_time_independent_carry_as_initial_effect; +/// Refuse treating Driver Table 3 `T0TIPREDEFFECT` as the first-occasion shift. +pub use event_time::refuse_initial_time_independent_coefficient_as_initial_effect; +/// Refuse treating the Table 3 first-occasion TI shift as `CINT`. +pub use event_time::refuse_initial_time_independent_effect_as_continuous_intercept; +/// Refuse treating the Table 3 first-occasion TI shift as the Eq. 3 process increment. +pub use event_time::refuse_initial_time_independent_effect_as_process_increment; +/// Refuse treating the Table 3 first-occasion TI shift as `M x`. +pub use event_time::refuse_initial_time_independent_effect_as_time_dependent_impulse; +/// Refuse treating first-occasion TI observed mean as the first-occasion TD observed mean. +pub use event_time::refuse_initial_time_independent_observed_mean_as_initial_time_dependent_observed_mean; +/// Refuse treating Driver Eq. 3–4 lagged latent covariance as `cov(y_t, y_{t-1})`. +pub use event_time::refuse_latent_lagged_covariance_as_observed_covariance; +/// Refuse treating Driver Eq. 5 latent mean as `E(y)`. +pub use event_time::refuse_latent_mean_as_observed_mean; +/// Refuse treating Driver Eq. 5 latent variance as `Var(y)`. +pub use event_time::refuse_latent_variance_as_observed_variance; +/// Refuse treating the §7.2 extra-process contribution as the contemporaneous Dirac. +pub use event_time::refuse_level_change_extra_process_as_impulse; +/// Refuse treating the §7.2 extra-process contribution as the Eq. 3 level-change increment. +pub use event_time::refuse_level_change_extra_process_as_increment; +/// Refuse treating the §7.2 extra-process contribution as the level-change `CINT`. +pub use event_time::refuse_level_change_extra_process_as_intercept; +/// Refuse treating the §7.2 level-change CINT increment as the contemporaneous Dirac. +pub use event_time::refuse_level_change_increment_as_impulse; +/// Refuse treating the §7.2 level-change CINT increment as `CINT`. +pub use event_time::refuse_level_change_increment_as_intercept; +/// Refuse treating the §7.2 level-change CINT increment as the Eq. 3 process increment. +pub use event_time::refuse_level_change_increment_as_process_increment; +/// Refuse treating Driver §7.2 level-change `CINT` as a free `CINT`. +pub use event_time::refuse_level_change_intercept_as_free_continuous_intercept; +/// Refuse treating Driver §7.2 level-change `CINT` as the contemporaneous Dirac. +pub use event_time::refuse_level_change_intercept_as_impulse; +/// Refuse treating Driver §7.2 level-change `CINT` as the Eq. 3 process increment. +pub use event_time::refuse_level_change_intercept_as_process_increment; +/// Refuse treating Driver Eq. 5 `MANIFESTMEANS` as `E(y)`. +pub use event_time::refuse_manifest_means_as_observed_mean; +/// Refuse treating Driver Eq. 5 `MANIFESTTRAITVAR` as `MANIFESTVAR`. +pub use event_time::refuse_manifest_trait_variance_as_measurement_error; +/// Refuse treating Driver Eq. 5 measurement error as lagged observed covariance. +pub use event_time::refuse_measurement_error_as_lagged_observed_covariance; +/// Refuse treating Driver Eq. 5 measurement error as `Var(y)`. +pub use event_time::refuse_measurement_error_as_observed_variance; +/// Refuse treating `MANIFESTVAR` as Eq. 5 of lagged §4.3 stationary `T0VAR`. +pub use event_time::refuse_measurement_error_as_stationary_lagged_observed_covariance; +/// Refuse treating `MANIFESTVAR` as Eq. 5 of later-occasion §4.3 stationary `T0VAR`. +pub use event_time::refuse_measurement_error_as_stationary_later_observed_variance; +/// Refuse pooling discrete lags from unequal event intervals. +pub use event_time::refuse_pooled_discrete_lag_across_unequal_intervals; +/// Refuse treating Driver Eq. 3 process noise as the unconditional variance. +pub use event_time::refuse_process_noise_as_unconditional_variance; +/// Refuse treating p. 16 stationary `T0MEANS` as `asymCINT`. +pub use event_time::refuse_stationary_initial_latent_mean_as_asymptotic_continuous_intercept; +/// Refuse treating p. 16 stationary `T0MEANS` as `asymTIPREDEFFECT`. +pub use event_time::refuse_stationary_initial_latent_mean_as_asymptotic_time_independent_effect; +/// Refuse treating p. 16 stationary `T0MEANS` as a finite-interval discrete mean. +pub use event_time::refuse_stationary_initial_latent_mean_as_discrete_mean; +/// Refuse treating p. 16 stationary `T0MEANS` as free `T0MEANS`. +pub use event_time::refuse_stationary_initial_latent_mean_as_initial_latent_mean; +/// Refuse treating §4.3 stationary `T0MEANS` as `E(y_0)`. +pub use event_time::refuse_stationary_initial_latent_mean_as_observed_mean; +/// Refuse treating §4.3 / p. 16 stationary `T0VAR` as `addedTIPREDVAR`. +pub use event_time::refuse_stationary_initial_latent_variance_as_asymptotic_time_independent_variance; +/// Refuse treating §4.3 / p. 16 stationary `T0VAR` as a finite-interval discrete variance. +pub use event_time::refuse_stationary_initial_latent_variance_as_discrete_variance; +/// Refuse treating §4.3 / p. 16 stationary `T0VAR` as free `T0VAR`. +pub use event_time::refuse_stationary_initial_latent_variance_as_initial_latent_variance; +/// Refuse treating §4.3 stationary `T0VAR` as `Var(y_0)`. +pub use event_time::refuse_stationary_initial_latent_variance_as_observed_variance; +/// Refuse treating §4.3 / p. 16 stationary `T0VAR` as `asymDIFFUSION`. +pub use event_time::refuse_stationary_initial_latent_variance_as_stationary_within_subject; +/// Refuse treating §4.3 / p. 16 stationary `T0VAR` as `TRAITVAR`. +pub use event_time::refuse_stationary_initial_latent_variance_as_trait_variance; +/// Refuse treating Eq. 5 of §4.3 stationary `T0MEANS` as `MANIFESTMEANS`. +pub use event_time::refuse_stationary_initial_observed_mean_as_manifest_means; +/// Refuse treating Eq. 5 of §4.3 stationary `T0VAR` as `MANIFESTVAR`. +pub use event_time::refuse_stationary_initial_observed_variance_as_measurement_error; +/// Refuse treating Eq. 5 of contemporaneous §4.3 stationary `T0VAR` as lagged observed covariance. +pub use event_time::refuse_stationary_initial_observed_variance_as_stationary_lagged_observed_covariance; +/// Refuse treating lagged §4.3 stationary `T0VAR` as decayed total stationary variance. +pub use event_time::refuse_stationary_lagged_latent_covariance_as_decayed_stationary_variance; +/// Refuse treating lagged §4.3 stationary `T0VAR` as lagged observed covariance. +pub use event_time::refuse_stationary_lagged_latent_covariance_as_observed_covariance; +/// Refuse treating lagged §4.3 stationary `T0VAR` as contemporaneous stationary `T0VAR`. +pub use event_time::refuse_stationary_lagged_latent_covariance_as_stationary_initial_latent_variance; +/// Refuse treating Eq. 5 of lagged §4.3 stationary `T0VAR` as later-occasion observed variance. +pub use event_time::refuse_stationary_lagged_observed_covariance_as_stationary_later_observed_variance; +/// Refuse treating later-occasion §4.3 stationary `T0VAR` as the free discrete evolution of the constrained total. +pub use event_time::refuse_stationary_later_latent_variance_as_discrete_variance; +/// Refuse treating later-occasion §4.3 stationary `T0VAR` as lagged covariance. +pub use event_time::refuse_stationary_later_latent_variance_as_lagged_covariance; +/// Refuse treating later-occasion §4.3 stationary `T0VAR` as later-occasion observed variance. +pub use event_time::refuse_stationary_later_latent_variance_as_observed_variance; +/// Refuse treating later-occasion §4.3 stationary `T0VAR` as finite-interval process noise. +pub use event_time::refuse_stationary_later_latent_variance_as_process_noise; +/// Refuse treating Eq. 5 of `asymDIFFUSION` as Eq. 5 of §4.3 stationary `T0VAR`. +pub use event_time::refuse_stationary_within_subject_observed_variance_as_stationary_initial_observed_variance; +/// Refuse treating Driver Eq. 3 `TDPREDEFFECT` impulse as `CINT`. +pub use event_time::refuse_time_dependent_impulse_as_continuous_intercept; +/// Refuse treating Driver Eq. 3 impulse as `TIPREDEFFECT`. +pub use event_time::refuse_time_dependent_impulse_as_time_independent_effect; +/// Refuse treating Driver Eq. 3 impulse as Voelkle Eq. 14. +pub use event_time::refuse_time_dependent_impulse_as_time_varying_discrete_effect; +/// Refuse treating Driver Eq. 1–2 impulse carry as the contemporaneous Dirac. +pub use event_time::refuse_time_dependent_impulse_carry_as_contemporaneous_impulse; +/// Refuse treating Driver Eq. 1–2 impulse carry as `CINT`. +pub use event_time::refuse_time_dependent_impulse_carry_as_continuous_intercept; +/// Refuse treating Driver Eq. 1–2 impulse carry as `TIPREDEFFECT`. +pub use event_time::refuse_time_dependent_impulse_carry_as_time_independent_effect; +/// Refuse treating Driver Eq. 1–2 impulse carry as Voelkle Eq. 14. +pub use event_time::refuse_time_dependent_impulse_carry_as_time_varying_discrete_effect; +/// Refuse treating Driver Table 2 `TIPREDEFFECT` as the discrete increment. +pub use event_time::refuse_time_independent_coefficient_as_discrete_effect; +/// Refuse treating Driver Eq. 3 `TIPREDEFFECT` increment as `CINT`. +pub use event_time::refuse_time_independent_effect_as_continuous_intercept; +/// Refuse treating Driver Eq. 3 `TIPREDEFFECT` increment as `M x`. +pub use event_time::refuse_time_independent_effect_as_time_dependent_impulse; +/// Refuse treating Driver Eq. 3 `TIPREDEFFECT` increment as Voelkle Eq. 14. +pub use event_time::refuse_time_independent_effect_as_time_varying_discrete_effect; +/// Refuse treating process-increment `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` as the first-occasion TD-predictor observed mean. +pub use event_time::refuse_time_independent_observed_mean_as_initial_time_dependent_observed_mean; +/// Refuse treating process-increment `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` as the first-occasion TI-predictor observed mean. +pub use event_time::refuse_time_independent_observed_mean_as_initial_time_independent_observed_mean; +/// Refuse treating §4.3 trait-plus-state lagged covariance as lagged stationary `T0VAR`. +pub use event_time::refuse_trait_plus_state_lagged_covariance_as_stationary_lagged_latent_covariance; +/// Refuse treating Driver §4.3 trait variance as process noise. +pub use event_time::refuse_trait_variance_as_process_noise; +/// Refuse treating Driver §4.3 trait variance as `asymDIFFUSION`. +pub use event_time::refuse_trait_variance_as_stationary_within_subject; +/// Refuse a time-varying predictor whose sampling and constancy intervals differ. +pub use event_time::refuse_unmatched_time_varying_predictor_interval; +/// Indicator coordinate kind. +pub use indicator::IndicatorKind; +/// Pearson correlation on valid coordinates. +pub use indicator::pearson_correlation; +/// Refuse raw topic proportions as psychometric indicators. +pub use indicator::require_valid_indicator; +/// One group's factor-score and indicator series. +pub use latent_mean::GroupIndicatorSeries; +/// Two-group OLS invariance status for a mean comparison. +pub use latent_mean::MeanInvarianceStatus; +/// Two-group OLS measurement parameters and status. +pub use latent_mean::TwoGroupMeasurement; +/// Classify two-group OLS invariance. +pub use latent_mean::classify_two_group_ols_invariance; +/// Strong/strict-gated latent-mean difference. +pub use latent_mean::recover_strong_gated_latent_mean_difference; +/// Ordinary least-squares intercept, slope, and residual variance. +pub use loading::OrdinaryLeastSquaresFit; +/// Ordinary least-squares intercept and slope with residual variance. +pub use loading::ordinary_least_squares_fit; +/// Ordinary least-squares slope. +pub use loading::ordinary_least_squares_slope; +/// Recover one reflective loading. +pub use loading::recover_reflective_loading; +/// Arithmetic mean of posterior-draw point estimates. +pub use plausible::posterior_draw_point_estimate_mean; +/// Average OLS loading point estimates across posterior indicator draws. +pub use plausible::recover_loading_point_estimate_mean; +/// Rubin-combined OLS loading and total variance. +pub use rubin_total::RubinCombinedLoading; +/// Combine OLS loadings across draws with Rubin `T`. +pub use rubin_total::combine_draw_level_ols_loadings; diff --git a/crates/psychometric_core/src/loading.rs b/crates/psychometric_core/src/loading.rs new file mode 100644 index 00000000..9ba67f05 --- /dev/null +++ b/crates/psychometric_core/src/loading.rs @@ -0,0 +1,139 @@ +//! CPU `f64` ordinary-least-squares loading recovery. + +use crate::error::PsychometricError; +use crate::indicator::{IndicatorKind, centered_pairs, require_finite, require_valid_indicator}; + +/// Ordinary least-squares intercept, slope, and residual variance. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OrdinaryLeastSquaresFit { + /// Intercept `ν = ȳ − λ x̄`. + pub intercept: f64, + /// Slope `λ`. + pub slope: f64, + /// Residual variance `SSE / df` with `df = n − 2` when `n > 2`, else `0`. + pub residual_variance: f64, + /// Predictor sum of squared deviations `Σ (x − x̄)²`. + pub predictor_sum_of_squares: f64, +} + +/// Ordinary least-squares slope of `outcome` on `predictor`. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidNumericInput`] for empty, singleton, +/// unequal-length, or non-finite vectors and +/// [`PsychometricError::SingularDesign`] when the predictor has zero variance. +pub fn ordinary_least_squares_slope( + predictor: &[f64], + outcome: &[f64], +) -> Result { + Ok(ordinary_least_squares_fit(predictor, outcome)?.slope) +} + +/// Ordinary least-squares intercept and slope with residual variance. +/// +/// Two-point lines have residual variance `0` because they fit exactly. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidNumericInput`] for empty, singleton, +/// unequal-length, or non-finite vectors and +/// [`PsychometricError::SingularDesign`] when the predictor has zero variance. +pub fn ordinary_least_squares_fit( + predictor: &[f64], + outcome: &[f64], +) -> Result { + let (pred_dev, out_dev, n) = centered_pairs(predictor, outcome)?; + let mut cross = 0.0_f64; + let mut pred_ss = 0.0_f64; + for (pred, out) in pred_dev.iter().zip(&out_dev) { + cross += pred * out; + pred_ss += pred * pred; + } + if pred_ss <= 0.0 { + return Err(PsychometricError::SingularDesign); + } + let slope = require_finite(cross / pred_ss)?; + let mut outcome_sum = 0.0_f64; + let mut predictor_sum = 0.0_f64; + for (&pred, &out) in predictor.iter().zip(outcome) { + predictor_sum += pred; + outcome_sum += out; + } + let intercept = require_finite(outcome_sum / n - slope * (predictor_sum / n))?; + let mut sse = 0.0_f64; + for (&pred, &out) in pred_dev.iter().zip(&out_dev) { + let residual = out - slope * pred; + sse += residual * residual; + } + let residual_variance = if n > 2.0 { + require_finite(sse / (n - 2.0))? + } else { + 0.0 + }; + Ok(OrdinaryLeastSquaresFit { + intercept, + slope, + residual_variance, + predictor_sum_of_squares: pred_ss, + }) +} + +/// Recover a single reflective loading from factor scores and an indicator. +/// +/// # Errors +/// +/// Returns the indicator-kind or OLS errors from +/// [`require_valid_indicator`] and [`ordinary_least_squares_slope`]. +pub fn recover_reflective_loading( + factor_scores: &[f64], + indicators: &[f64], + kind: IndicatorKind, +) -> Result { + require_valid_indicator(kind)?; + ordinary_least_squares_slope(factor_scores, indicators) +} + +#[cfg(test)] +mod tests { + use super::{ + ordinary_least_squares_fit, ordinary_least_squares_slope, recover_reflective_loading, + }; + use crate::error::PsychometricError; + use crate::indicator::IndicatorKind; + + #[test] + fn unit_slope_recovers_and_empty_or_overflow_input_fails() { + let slope = ordinary_least_squares_slope(&[0.0, 1.0], &[0.0, 1.0]).expect("unit"); + assert!((slope - 1.0).abs() < 1e-15); + let fit = ordinary_least_squares_fit(&[0.0, 1.0, 2.0], &[1.0, 3.0, 5.0]).expect("line"); + assert!((fit.slope - 2.0).abs() < 1e-12); + assert!((fit.intercept - 1.0).abs() < 1e-12); + assert!(fit.residual_variance.abs() < 1e-12); + assert!(fit.predictor_sum_of_squares > 0.0); + assert_eq!( + recover_reflective_loading(&[], &[], IndicatorKind::AdditiveLogRatio), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + ordinary_least_squares_slope(&[0.0, f64::MAX], &[0.0, f64::MAX]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + ordinary_least_squares_slope(&[1.0, 2.0], &[1.0]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + ordinary_least_squares_slope(&[f64::NAN, 2.0], &[1.0, 2.0]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + ordinary_least_squares_slope(&[1.0, 2.0], &[1.0, f64::NAN]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + ordinary_least_squares_fit(&[1.0, 1.0], &[2.0, 3.0]), + Err(PsychometricError::SingularDesign) + ); + } +} diff --git a/crates/psychometric_core/src/plausible.rs b/crates/psychometric_core/src/plausible.rs new file mode 100644 index 00000000..d24fae41 --- /dev/null +++ b/crates/psychometric_core/src/plausible.rs @@ -0,0 +1,92 @@ +//! Point-estimate aggregation across posterior structural draws. + +use crate::error::PsychometricError; +use crate::indicator::{IndicatorKind, require_finite, require_valid_indicator}; +use crate::loading::recover_reflective_loading; + +/// Arithmetic mean of finite posterior-draw point estimates. +/// +/// This helper does not pool within-draw and between-draw uncertainty and must +/// not be described as Rubin multiple-imputation variance pooling. +/// +/// Scaling by the largest absolute draw prevents valid finite posterior values +/// from overflowing during aggregation. Compensated accumulation preserves +/// cancellation when draws span very different magnitudes. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidNumericInput`] when `draws` is empty or +/// contains a non-finite value. +pub fn posterior_draw_point_estimate_mean(draws: &[f64]) -> Result { + if draws.is_empty() { + return Err(PsychometricError::InvalidNumericInput); + } + let mut scale = 0.0_f64; + for &value in draws { + if !value.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + scale = scale.max(value.abs()); + } + if scale == 0.0 { + return Ok(0.0); + } + + let mut normalized_sum = 0.0_f64; + let mut compensation = 0.0_f64; + for &value in draws { + let adjusted = value / scale - compensation; + let next = normalized_sum + adjusted; + compensation = (next - normalized_sum) - adjusted; + normalized_sum = next; + } + require_finite((normalized_sum / draws.len() as f64) * scale) +} + +/// Recover a reflective loading point estimate by averaging OLS slopes across +/// posterior indicator draws. +/// +/// The result is a point-estimate summary only. It does not estimate within-draw +/// variance, between-draw variance, total variance, degrees of freedom, or a +/// confidence interval, and therefore is not Rubin-style uncertainty pooling. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidNumericInput`] when no draws are +/// supplied, and otherwise the first indicator-kind or OLS error from a draw. +pub fn recover_loading_point_estimate_mean( + factor_scores: &[f64], + indicator_draws: &[Vec], + kind: IndicatorKind, +) -> Result { + require_valid_indicator(kind)?; + if indicator_draws.is_empty() { + return Err(PsychometricError::InvalidNumericInput); + } + let mut recovered = Vec::with_capacity(indicator_draws.len()); + for draw in indicator_draws { + recovered.push(recover_reflective_loading(factor_scores, draw, kind)?); + } + posterior_draw_point_estimate_mean(&recovered) +} + +#[cfg(test)] +mod tests { + use super::{posterior_draw_point_estimate_mean, recover_loading_point_estimate_mean}; + use crate::error::PsychometricError; + use crate::indicator::IndicatorKind; + + #[test] + fn mean_of_two_point_estimates_and_nonfinite_mean_fail_closed() { + let mean = posterior_draw_point_estimate_mean(&[1.0, 3.0]).expect("mean"); + assert!((mean - 2.0).abs() < 1e-15); + assert_eq!( + recover_loading_point_estimate_mean( + &[0.0, 1.0], + &[vec![0.0, f64::NAN]], + IndicatorKind::AdditiveLogRatio + ), + Err(PsychometricError::InvalidNumericInput) + ); + } +} diff --git a/crates/psychometric_core/src/rubin_total.rs b/crates/psychometric_core/src/rubin_total.rs new file mode 100644 index 00000000..95aa38e7 --- /dev/null +++ b/crates/psychometric_core/src/rubin_total.rs @@ -0,0 +1,145 @@ +//! Rubin total variance for draw-level OLS loadings. +//! +//! Rubin (1996, p. 473), restating Rubin (1987): +//! `T_m = Ū_m + (1 + 1/m) B_m`, where `Ū_m` is the mean complete-data +//! sampling variance and `B_m` is the between-draw variance of the point +//! estimates. This combines complete-data OLS loadings. It is not Mislevy +//! plausible-value draws. + +use crate::error::PsychometricError; +use crate::indicator::{IndicatorKind, require_finite, require_valid_indicator}; +use crate::loading::ordinary_least_squares_fit; + +/// Rubin-combined OLS loading and total variance. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RubinCombinedLoading { + /// Mean complete-data loading `Q̄`. + pub mean_loading: f64, + /// Mean complete-data sampling variance `Ū`. + pub within_variance: f64, + /// Between-draw variance `B`. + pub between_variance: f64, + /// Total variance `T = Ū + (1 + 1/m) B`. + pub total_variance: f64, + /// Number of complete-data draws `m`. + pub draw_count: usize, +} + +/// Combine OLS loadings across posterior indicator draws with Rubin `T`. +/// +/// Each draw contributes `Q̂_ℓ = λ_ℓ` and +/// `U_ℓ = σ̂²_ℓ / Σ (f − f̄)²`. The helper does not treat the draws as +/// Mislevy person-level plausible values. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidNumericInput`] when a draw is empty or +/// non-finite, [`PsychometricError::InsufficientDraws`] when fewer than two +/// draws are supplied, and indicator-kind or OLS errors from a draw. +pub fn combine_draw_level_ols_loadings( + factor_scores: &[f64], + indicator_draws: &[Vec], + kind: IndicatorKind, +) -> Result { + require_valid_indicator(kind)?; + if indicator_draws.len() < 2 { + return Err(PsychometricError::InsufficientDraws); + } + let draw_count = indicator_draws.len(); + let mut loadings = Vec::with_capacity(draw_count); + let mut within = Vec::with_capacity(draw_count); + for draw in indicator_draws { + let fit = ordinary_least_squares_fit(factor_scores, draw)?; + let sampling_variance = + require_finite(fit.residual_variance / fit.predictor_sum_of_squares)?; + loadings.push(fit.slope); + within.push(sampling_variance); + } + let count = draw_count as f64; + let mut loading_sum = 0.0_f64; + let mut within_sum = 0.0_f64; + for index in 0..draw_count { + loading_sum += loadings[index]; + within_sum += within[index]; + } + let mean_loading = require_finite(loading_sum / count)?; + let within_variance = require_finite(within_sum / count)?; + let mut between_ss = 0.0_f64; + for loading in &loadings { + let deviation = loading - mean_loading; + between_ss += deviation * deviation; + } + let between_variance = require_finite(between_ss / (count - 1.0))?; + let total_variance = require_finite(within_variance + (1.0 + 1.0 / count) * between_variance)?; + Ok(RubinCombinedLoading { + mean_loading, + within_variance, + between_variance, + total_variance, + draw_count, + }) +} + +#[cfg(test)] +mod tests { + use super::combine_draw_level_ols_loadings; + use crate::error::PsychometricError; + use crate::indicator::IndicatorKind; + + #[test] + fn rubin_t_matches_mean_plus_inflated_between() { + let factors = [-1.0_f64, 0.0, 1.0]; + let draws = [vec![-0.7, 0.0, 0.7], vec![-0.9, 0.0, 0.9]]; + let combined = + combine_draw_level_ols_loadings(&factors, &draws, IndicatorKind::AdditiveLogRatio) + .expect("rubin"); + assert!((combined.mean_loading - 0.8).abs() < 1e-12); + assert_eq!(combined.draw_count, 2); + let expected_total = + combined.within_variance + (1.0 + 1.0 / 2.0) * combined.between_variance; + assert!((combined.total_variance - expected_total).abs() < 1e-15); + assert!(combined.between_variance > 0.0); + assert!(combined.within_variance.abs() < 1e-12); + } + + #[test] + fn raw_proportion_single_draw_and_bad_numeric_fail() { + let factors = [0.0_f64, 1.0, 2.0]; + assert_eq!( + combine_draw_level_ols_loadings( + &factors, + &[vec![0.0, 1.0, 2.0]], + IndicatorKind::AdditiveLogRatio + ), + Err(PsychometricError::InsufficientDraws) + ); + assert_eq!( + combine_draw_level_ols_loadings(&factors, &[], IndicatorKind::AdditiveLogRatio), + Err(PsychometricError::InsufficientDraws) + ); + assert_eq!( + combine_draw_level_ols_loadings( + &factors, + &[vec![0.0, 1.0, 2.0], vec![0.0, 1.0, 2.0]], + IndicatorKind::RawProportion + ), + Err(PsychometricError::RawProportionForbidden) + ); + assert_eq!( + combine_draw_level_ols_loadings( + &factors, + &[vec![0.0, f64::NAN, 2.0], vec![0.0, 1.0, 2.0]], + IndicatorKind::IsometricLogRatio + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + combine_draw_level_ols_loadings( + &[1.0, 1.0, 1.0], + &[vec![0.0, 1.0, 2.0], vec![0.0, 1.0, 2.0]], + IndicatorKind::LogisticNormal + ), + Err(PsychometricError::SingularDesign) + ); + } +} diff --git a/crates/psychometric_core/tests/crate_contract.rs b/crates/psychometric_core/tests/crate_contract.rs new file mode 100644 index 00000000..4ae8137a --- /dev/null +++ b/crates/psychometric_core/tests/crate_contract.rs @@ -0,0 +1,23 @@ +//! Integration contract for the `psychometric_core` package identity. + +use psychometric_core::LagClock; + +#[test] +fn package_identity_is_stable() { + let observed = std::hint::black_box(env!("CARGO_PKG_NAME")); + assert_eq!(observed, "psychometric_core"); +} + +#[test] +fn lag_clock_wire_names_are_stable() { + for (clock, name) in [ + (LagClock::EventTime, "event_time"), + (LagClock::SystemTime, "system_time"), + (LagClock::AssertionTime, "assertion_time"), + (LagClock::DocumentTime, "document_time"), + (LagClock::AvailabilityTime, "availability_time"), + (LagClock::KnowledgeCutoff, "knowledge_cutoff"), + ] { + assert_eq!(std::hint::black_box(clock).as_str(), name); + } +} diff --git a/crates/psychometric_core/tests/esem_input_recovery_contract.rs b/crates/psychometric_core/tests/esem_input_recovery_contract.rs new file mode 100644 index 00000000..b4654b61 --- /dev/null +++ b/crates/psychometric_core/tests/esem_input_recovery_contract.rs @@ -0,0 +1,338 @@ +//! True-parameter recovery and fail-closed ESEM/DSEM input gates. +#![allow(clippy::cast_precision_loss)] + +use psychometric_core::{ + CausalHeuristic, ConstructClass, IndicatorKind, LatentMeanComparisonEvidence, + MeanInvarianceStatus, PsychometricError, claim_causal_effect, compare_latent_means, + interpret_as_reflective, ordinary_least_squares_slope, pearson_correlation, + posterior_draw_point_estimate_mean, recover_loading_point_estimate_mean, + recover_reflective_loading, require_valid_indicator, +}; + +fn rmse(truth: &[f64], recovered: &[f64]) -> f64 { + let n = truth.len() as f64; + let sum_sq: f64 = truth + .iter() + .zip(recovered) + .map(|(left, right)| { + let residual = left - right; + residual * residual + }) + .sum(); + (sum_sq / n).sqrt() +} + +fn centered_scores(count: usize) -> Vec { + let mean = (count as f64 - 1.0) / 2.0; + (0..count).map(|index| index as f64 - mean).collect() +} + +#[test] +fn known_loading_recovers_through_ols_with_computed_rmse() { + let true_loading = 0.8_f64; + let factor_scores = centered_scores(16); + let indicators: Vec = factor_scores + .iter() + .map(|score| true_loading * score) + .collect(); + + let recovered = + recover_reflective_loading(&factor_scores, &indicators, IndicatorKind::AdditiveLogRatio) + .expect("noiseless reflective loading"); + let error = rmse(&[true_loading], &[recovered]); + assert!( + error < 1e-12, + "noiseless OLS RMSE {error} exceeded machine-scale bound" + ); +} + +#[test] +fn posterior_draw_point_estimate_mean_recovers_true_loading_under_symmetric_draw_noise() { + let true_loading = 0.8_f64; + let factor_scores = centered_scores(16); + let mut indicator_draws = Vec::with_capacity(5); + for draw in 0..5 { + let draw_loading = true_loading + 0.01 * (f64::from(draw) - 2.0); + indicator_draws.push( + factor_scores + .iter() + .map(|score| draw_loading * score) + .collect::>(), + ); + } + + let pooled = recover_loading_point_estimate_mean( + &factor_scores, + &indicator_draws, + IndicatorKind::LogisticNormal, + ) + .expect("posterior-draw point-estimate loading"); + let pooled_error = rmse(&[true_loading], &[pooled]); + assert!( + pooled_error < 1e-12, + "symmetric posterior-draw point-estimate RMSE {pooled_error} should cancel" + ); + + let single = recover_reflective_loading( + &factor_scores, + &indicator_draws[0], + IndicatorKind::IsometricLogRatio, + ) + .expect("single draw"); + let single_error = rmse(&[true_loading], &[single]); + assert!( + single_error > pooled_error, + "single-draw RMSE {single_error} should exceed pooled RMSE {pooled_error}" + ); +} + +#[test] +fn raw_proportions_and_invalid_numeric_inputs_fail_closed() { + assert_eq!( + require_valid_indicator(IndicatorKind::RawProportion), + Err(PsychometricError::RawProportionForbidden) + ); + assert_eq!( + pearson_correlation(&[0.2, 0.3], &[0.8, 0.7], IndicatorKind::RawProportion), + Err(PsychometricError::RawProportionForbidden) + ); + assert_eq!( + recover_reflective_loading(&[1.0, 2.0], &[0.5, 0.5], IndicatorKind::RawProportion), + Err(PsychometricError::RawProportionForbidden) + ); + assert_eq!( + recover_loading_point_estimate_mean( + &[1.0, 2.0], + &[vec![0.5, 0.5]], + IndicatorKind::RawProportion + ), + Err(PsychometricError::RawProportionForbidden) + ); + + assert_eq!( + pearson_correlation(&[1.0], &[1.0], IndicatorKind::AdditiveLogRatio), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + pearson_correlation(&[1.0, 2.0], &[1.0], IndicatorKind::AdditiveLogRatio), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + pearson_correlation( + &[1.0, f64::NAN], + &[1.0, 2.0], + IndicatorKind::AdditiveLogRatio + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + ordinary_least_squares_slope(&[1.0, 1.0], &[2.0, 3.0]), + Err(PsychometricError::SingularDesign) + ); + assert_eq!( + ordinary_least_squares_slope(&[0.0, f64::MAX], &[0.0, f64::MAX]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + pearson_correlation(&[1.0, 1.0], &[2.0, 3.0], IndicatorKind::AdditiveLogRatio), + Err(PsychometricError::SingularDesign) + ); + assert_eq!( + pearson_correlation(&[1.0, 2.0], &[3.0, 3.0], IndicatorKind::AdditiveLogRatio), + Err(PsychometricError::SingularDesign) + ); + assert_eq!( + pearson_correlation( + &[1.0, 2.0], + &[1.0, f64::NAN], + IndicatorKind::AdditiveLogRatio + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + posterior_draw_point_estimate_mean(&[]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + posterior_draw_point_estimate_mean(&[1.0, f64::INFINITY]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_loading_point_estimate_mean(&[1.0, 2.0], &[], IndicatorKind::AdditiveLogRatio), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_loading_point_estimate_mean( + &[1.0, 2.0], + &[vec![1.0]], + IndicatorKind::AdditiveLogRatio + ), + Err(PsychometricError::InvalidNumericInput) + ); +} + +#[test] +fn construct_class_and_causal_heuristics_refuse_overclaim() { + assert!(ConstructClass::Reflective.admits_reflective_esem()); + assert!(!ConstructClass::Formative.admits_reflective_esem()); + assert!(!ConstructClass::Network.admits_reflective_esem()); + assert!(!ConstructClass::Unresolved.admits_reflective_esem()); + assert_eq!(ConstructClass::Reflective.as_str(), "reflective"); + assert_eq!(ConstructClass::Formative.as_str(), "formative"); + assert_eq!(ConstructClass::Network.as_str(), "network"); + assert_eq!(ConstructClass::Unresolved.as_str(), "unresolved"); + + assert_eq!( + interpret_as_reflective(ConstructClass::Reflective, false).expect("reflective"), + ConstructClass::Reflective + ); + assert_eq!( + interpret_as_reflective(ConstructClass::Reflective, true).expect("fit unused"), + ConstructClass::Reflective + ); + assert_eq!( + interpret_as_reflective(ConstructClass::Formative, true), + Err(PsychometricError::FormativeReinterpretationForbidden) + ); + assert_eq!( + interpret_as_reflective(ConstructClass::Formative, false), + Err(PsychometricError::FormativeReinterpretationForbidden) + ); + assert_eq!( + interpret_as_reflective(ConstructClass::Network, true), + Err(PsychometricError::FormativeReinterpretationForbidden) + ); + assert_eq!( + interpret_as_reflective(ConstructClass::Unresolved, true), + Err(PsychometricError::UnresolvedConstruct) + ); + assert_eq!( + interpret_as_reflective(ConstructClass::Unresolved, false), + Err(PsychometricError::UnresolvedConstruct) + ); + + let licensed = LatentMeanComparisonEvidence { + status: MeanInvarianceStatus::Strong, + comparison_scope: String::from("construct mean across two groups"), + model_version: String::from("esem-input-contract-v1"), + }; + compare_latent_means(&licensed).expect("strong invariance met"); + let unlicensed = LatentMeanComparisonEvidence { + status: MeanInvarianceStatus::Configural, + comparison_scope: String::from("construct mean across two groups"), + model_version: String::from("esem-input-contract-v1"), + }; + assert_eq!( + compare_latent_means(&unlicensed), + Err(PsychometricError::StrongInvarianceRequired) + ); + + for heuristic in [ + CausalHeuristic::TemporalPrecedence, + CausalHeuristic::DocumentLinkage, + CausalHeuristic::EventTracking, + CausalHeuristic::ModelPrediction, + ] { + assert_eq!( + claim_causal_effect(heuristic), + Err(PsychometricError::CausalUnderidentified) + ); + assert!(!heuristic.as_str().is_empty()); + } + + assert!(IndicatorKind::AdditiveLogRatio.is_valid_structural_input()); + assert!(IndicatorKind::IsometricLogRatio.is_valid_structural_input()); + assert!(IndicatorKind::LogisticNormal.is_valid_structural_input()); + assert!(!IndicatorKind::RawProportion.is_valid_structural_input()); + assert_eq!(IndicatorKind::AdditiveLogRatio.as_str(), "alr"); + assert_eq!(IndicatorKind::IsometricLogRatio.as_str(), "ilr"); + assert_eq!(IndicatorKind::LogisticNormal.as_str(), "logistic_normal"); + assert_eq!(IndicatorKind::RawProportion.as_str(), "raw_proportion"); +} + +#[test] +fn finite_alr_correlation_and_error_messages_are_stable() { + let left = [0.0_f64, 1.0, 2.0]; + let right = [0.0_f64, 2.0, 4.0]; + let correlation = pearson_correlation(&left, &right, IndicatorKind::AdditiveLogRatio) + .expect("perfect positive"); + assert!((correlation - 1.0).abs() < 1e-12); + + let slope = ordinary_least_squares_slope(&left, &right).expect("slope"); + assert!((slope - 2.0).abs() < 1e-12); + let mean = posterior_draw_point_estimate_mean(&[0.7, 0.8, 0.9]).expect("mean"); + assert!((mean - 0.8).abs() < 1e-15); + + assert_eq!( + PsychometricError::RawProportionForbidden.to_string(), + "raw topic proportions are forbidden psychometric indicators" + ); + assert_eq!( + PsychometricError::InvalidNumericInput.to_string(), + "invalid psychometric numeric input" + ); + assert_eq!( + PsychometricError::SingularDesign.to_string(), + "singular psychometric design matrix" + ); + assert_eq!( + PsychometricError::FormativeReinterpretationForbidden.to_string(), + "formative or network constructs cannot be reinterpreted as reflective" + ); + assert_eq!( + PsychometricError::CausalUnderidentified.to_string(), + "temporal precedence is not causal identification" + ); + assert_eq!( + PsychometricError::UnresolvedConstruct.to_string(), + "construct class is unresolved" + ); + assert_eq!( + PsychometricError::MalformedInvarianceEvidence.to_string(), + "invariance evidence requires a non-empty comparison scope and model version" + ); + assert_eq!( + PsychometricError::EventTimeRequired.to_string(), + "discrete lag and local log-rate require event time, not another clock" + ); + assert_eq!( + PsychometricError::DifferenceQuotientForbidden.to_string(), + "the difference quotient is not the local continuous-time rate" + ); + assert_eq!( + PsychometricError::UnequalIntervalPoolingForbidden.to_string(), + "discrete lags from unequal event intervals are not one coefficient" + ); + assert_eq!( + PsychometricError::InsufficientClusters.to_string(), + "within/between recovery requires at least two clusters" + ); + assert_eq!( + PsychometricError::InvalidWeight.to_string(), + "invalid non-negative finite psychometric weight" + ); + assert_eq!( + PsychometricError::NonPositiveInterval.to_string(), + "event-time interval must be strictly positive" + ); + assert_eq!( + PsychometricError::InsufficientDraws.to_string(), + "Rubin total variance requires at least two complete-data draws" + ); + assert_eq!( + PsychometricError::StrongInvarianceRequired.to_string(), + "latent-mean comparison requires strong or strict invariance; metric/weak is not enough" + ); + assert_eq!( + PsychometricError::ProcessNoiseIsConditionalVariance.to_string(), + "discrete process noise is the conditional residual variance, not the unconditional latent variance" + ); + assert_eq!( + PsychometricError::StationaryVarianceRequiresStableDrift.to_string(), + "stationary within-subject variance requires a stable negative drift" + ); + assert_eq!( + PsychometricError::FiniteIntervalProcessNoiseIsNotStationary.to_string(), + "finite-interval process noise is not the asymptotic within-subject variance" + ); +} diff --git a/crates/psychometric_core/tests/multilevel_event_time_recovery_contract.rs b/crates/psychometric_core/tests/multilevel_event_time_recovery_contract.rs new file mode 100644 index 00000000..0522220c --- /dev/null +++ b/crates/psychometric_core/tests/multilevel_event_time_recovery_contract.rs @@ -0,0 +1,5850 @@ +//! True-parameter recovery for multilevel OLS, event-time log-rate, and CWC lags. +#![allow(clippy::cast_precision_loss)] + +use psychometric_core::{ + ClusteredEventScore, ClusteredScore, EventOccasion, IndicatorKind, LagClock, + LaggedWithinResidual, PsychometricError, map_discrete_lag_across_event_intervals, + ordinary_least_squares_slope, recover_asymptotic_continuous_intercept, + recover_asymptotic_time_independent_predictor_effect, + recover_asymptotic_time_independent_predictor_variance, + recover_cluster_mean_within_between_slopes, recover_discrete_constant_predictor_effect, + recover_discrete_continuous_intercept_effect, recover_discrete_lag_from_log_rate, + recover_discrete_lagged_latent_covariance, recover_discrete_latent_mean, + recover_discrete_latent_mean_with_extra_process, + recover_discrete_latent_mean_with_extra_process_after, + recover_discrete_latent_mean_with_impulse, recover_discrete_latent_mean_with_impulse_carry, + recover_discrete_latent_mean_with_initial_time_dependent_predictor, + recover_discrete_latent_mean_with_initial_time_independent_predictor, + recover_discrete_latent_mean_with_time_independent_predictor, recover_discrete_latent_variance, + recover_discrete_observed_mean, recover_discrete_observed_mean_with_extra_process, + recover_discrete_observed_mean_with_extra_process_after, + recover_discrete_observed_mean_with_impulse, recover_discrete_observed_mean_with_impulse_carry, + recover_discrete_observed_mean_with_initial_time_dependent_predictor, + recover_discrete_observed_mean_with_initial_time_independent_predictor, + recover_discrete_observed_mean_with_time_independent_predictor, recover_discrete_process_noise, + recover_discrete_time_independent_predictor_effect, + recover_discrete_time_varying_predictor_effect, recover_event_series_mean_log_rate, + recover_event_time_discrete_lag_and_log_rate, recover_initial_time_dependent_predictor_carry, + recover_initial_time_dependent_predictor_effect, + recover_initial_time_independent_predictor_carry, + recover_initial_time_independent_predictor_effect, + recover_irregular_centered_residual_log_rate, recover_kish_weighted_slope, + recover_level_change_continuous_intercept, recover_level_change_discrete_increment, + recover_level_change_extra_process_contribution, + recover_level_change_extra_process_contribution_after, + recover_manifest_lagged_observed_covariance, recover_manifest_observed_mean, + recover_manifest_observed_variance, recover_manifest_trait_plus_state_observed_variance, + recover_stationary_initial_latent_mean, recover_stationary_initial_latent_variance, + recover_stationary_initial_observed_mean, recover_stationary_initial_observed_variance, + recover_stationary_lagged_latent_covariance, recover_stationary_lagged_observed_covariance, + recover_stationary_latent_variance, recover_stationary_later_latent_variance, + recover_stationary_later_observed_variance, recover_time_dependent_predictor_impulse, + recover_time_dependent_predictor_impulse_carry, recover_trait_plus_state_lagged_covariance, + recover_trait_plus_state_latent_variance, recover_within_residual_event_time_log_rate, + refuse_after_extra_process_contribution_as_observed_mean, + refuse_after_extra_process_latent_mean_as_observed_mean, + refuse_asymptotic_continuous_intercept_as_asymptotic_time_independent_effect, + refuse_asymptotic_continuous_intercept_as_continuous_intercept, + refuse_asymptotic_continuous_intercept_as_discrete_increment, + refuse_asymptotic_continuous_intercept_as_initial_latent_mean, + refuse_asymptotic_continuous_intercept_observed_mean_as_stationary_initial_observed_mean, + refuse_asymptotic_time_independent_effect_as_coefficient, + refuse_asymptotic_time_independent_effect_as_continuous_intercept, + refuse_asymptotic_time_independent_effect_as_discrete_effect, + refuse_asymptotic_time_independent_effect_as_time_dependent_impulse, + refuse_asymptotic_time_independent_variance_as_asymptotic_effect, + refuse_asymptotic_time_independent_variance_as_stationary_within_subject, + refuse_asymptotic_time_independent_variance_as_trait_variance, + refuse_continuous_intercept_as_discrete_mean_increment, + refuse_continuous_intercept_as_initial_latent_mean, + refuse_continuous_intercept_as_manifest_means, refuse_difference_quotient_as_local_rate, + refuse_evolved_observed_mean_as_after_extra_process_observed_mean, + refuse_evolved_observed_mean_as_extra_process_observed_mean, + refuse_evolved_observed_mean_as_impulse_carry_observed_mean, + refuse_evolved_observed_mean_as_impulse_observed_mean, + refuse_evolved_observed_mean_as_initial_time_dependent_observed_mean, + refuse_evolved_observed_mean_as_initial_time_independent_observed_mean, + refuse_evolved_observed_mean_as_stationary_initial_observed_mean, + refuse_evolved_observed_mean_as_time_independent_observed_mean, + refuse_evolved_observed_variance_as_stationary_initial_observed_variance, + refuse_extra_process_contribution_as_observed_mean, + refuse_extra_process_latent_mean_as_observed_mean, + refuse_extra_process_observed_mean_as_after_extra_process_observed_mean, + refuse_finite_interval_process_noise_as_stationary_variance, + refuse_impulse_carry_observed_mean_as_after_extra_process_observed_mean, + refuse_impulse_carry_observed_mean_as_initial_time_dependent_observed_mean, + refuse_impulse_carry_observed_mean_as_initial_time_independent_observed_mean, + refuse_impulse_carry_observed_mean_as_time_independent_observed_mean, + refuse_impulse_observed_mean_as_extra_process_observed_mean, + refuse_impulse_observed_mean_as_impulse_carry_observed_mean, + refuse_impulse_observed_mean_as_initial_time_dependent_observed_mean, + refuse_impulse_observed_mean_as_initial_time_independent_observed_mean, + refuse_impulse_observed_mean_as_time_independent_observed_mean, + refuse_initial_latent_mean_as_evolved_mean, + refuse_initial_observed_mean_as_evolved_observed_mean, + refuse_initial_observed_mean_as_stationary_initial_observed_mean, + refuse_initial_observed_variance_as_stationary_initial_observed_variance, + refuse_initial_time_dependent_carry_as_impulse_carry, + refuse_initial_time_dependent_carry_as_initial_effect, + refuse_initial_time_dependent_coefficient_as_initial_effect, + refuse_initial_time_dependent_effect_as_contemporaneous_impulse, + refuse_initial_time_dependent_effect_as_continuous_intercept, + refuse_initial_time_dependent_effect_as_initial_time_independent_effect, + refuse_initial_time_dependent_effect_as_process_increment, + refuse_initial_time_independent_carry_as_initial_effect, + refuse_initial_time_independent_coefficient_as_initial_effect, + refuse_initial_time_independent_effect_as_continuous_intercept, + refuse_initial_time_independent_effect_as_process_increment, + refuse_initial_time_independent_effect_as_time_dependent_impulse, + refuse_initial_time_independent_observed_mean_as_initial_time_dependent_observed_mean, + refuse_latent_lagged_covariance_as_observed_covariance, refuse_latent_mean_as_observed_mean, + refuse_latent_variance_as_observed_variance, refuse_level_change_extra_process_as_impulse, + refuse_level_change_extra_process_as_increment, refuse_level_change_extra_process_as_intercept, + refuse_level_change_increment_as_impulse, refuse_level_change_increment_as_intercept, + refuse_level_change_increment_as_process_increment, + refuse_level_change_intercept_as_free_continuous_intercept, + refuse_level_change_intercept_as_impulse, refuse_level_change_intercept_as_process_increment, + refuse_manifest_means_as_observed_mean, refuse_manifest_trait_variance_as_measurement_error, + refuse_measurement_error_as_lagged_observed_covariance, + refuse_measurement_error_as_observed_variance, + refuse_measurement_error_as_stationary_lagged_observed_covariance, + refuse_measurement_error_as_stationary_later_observed_variance, + refuse_pooled_discrete_lag_across_unequal_intervals, + refuse_process_noise_as_unconditional_variance, + refuse_stationary_initial_latent_mean_as_asymptotic_continuous_intercept, + refuse_stationary_initial_latent_mean_as_asymptotic_time_independent_effect, + refuse_stationary_initial_latent_mean_as_discrete_mean, + refuse_stationary_initial_latent_mean_as_initial_latent_mean, + refuse_stationary_initial_latent_mean_as_observed_mean, + refuse_stationary_initial_latent_variance_as_asymptotic_time_independent_variance, + refuse_stationary_initial_latent_variance_as_discrete_variance, + refuse_stationary_initial_latent_variance_as_initial_latent_variance, + refuse_stationary_initial_latent_variance_as_observed_variance, + refuse_stationary_initial_latent_variance_as_stationary_within_subject, + refuse_stationary_initial_latent_variance_as_trait_variance, + refuse_stationary_initial_observed_mean_as_manifest_means, + refuse_stationary_initial_observed_variance_as_measurement_error, + refuse_stationary_initial_observed_variance_as_stationary_lagged_observed_covariance, + refuse_stationary_lagged_latent_covariance_as_decayed_stationary_variance, + refuse_stationary_lagged_latent_covariance_as_observed_covariance, + refuse_stationary_lagged_latent_covariance_as_stationary_initial_latent_variance, + refuse_stationary_lagged_observed_covariance_as_stationary_later_observed_variance, + refuse_stationary_later_latent_variance_as_discrete_variance, + refuse_stationary_later_latent_variance_as_lagged_covariance, + refuse_stationary_later_latent_variance_as_observed_variance, + refuse_stationary_later_latent_variance_as_process_noise, + refuse_stationary_within_subject_observed_variance_as_stationary_initial_observed_variance, + refuse_time_dependent_impulse_as_continuous_intercept, + refuse_time_dependent_impulse_as_time_independent_effect, + refuse_time_dependent_impulse_as_time_varying_discrete_effect, + refuse_time_dependent_impulse_carry_as_contemporaneous_impulse, + refuse_time_dependent_impulse_carry_as_continuous_intercept, + refuse_time_dependent_impulse_carry_as_time_independent_effect, + refuse_time_dependent_impulse_carry_as_time_varying_discrete_effect, + refuse_time_independent_coefficient_as_discrete_effect, + refuse_time_independent_effect_as_continuous_intercept, + refuse_time_independent_effect_as_time_dependent_impulse, + refuse_time_independent_effect_as_time_varying_discrete_effect, + refuse_time_independent_observed_mean_as_initial_time_dependent_observed_mean, + refuse_time_independent_observed_mean_as_initial_time_independent_observed_mean, + refuse_trait_plus_state_lagged_covariance_as_stationary_lagged_latent_covariance, + refuse_trait_variance_as_process_noise, refuse_trait_variance_as_stationary_within_subject, + refuse_unmatched_time_varying_predictor_interval, +}; + +fn rmse(truth: &[f64], recovered: &[f64]) -> f64 { + let n = truth.len() as f64; + let sum_sq: f64 = truth + .iter() + .zip(recovered) + .map(|(left, right)| { + let residual = left - right; + residual * residual + }) + .sum(); + (sum_sq / n).sqrt() +} + +#[test] +fn cluster_mean_cwc_recovers_known_within_between_and_contextual() { + let true_within = 0.5_f64; + let true_between = 2.0_f64; + let true_contextual = true_between - true_within; + let mut rows = Vec::new(); + for cluster in 0..6_u64 { + let cluster_mean = f64::from(u32::try_from(cluster).expect("tiny")) * 2.0; + for occasion in 0..4 { + let within = f64::from(occasion) - 1.5; + let predictor = cluster_mean + within; + let outcome = true_between * cluster_mean + true_within * within; + rows.push(ClusteredScore { + cluster_key: cluster, + predictor, + outcome, + }); + } + } + let recovered = recover_cluster_mean_within_between_slopes(&rows).expect("cwc"); + let within_error = rmse(&[true_within], &[recovered.within_slope]); + let between_error = rmse(&[true_between], &[recovered.between_slope]); + let contextual_error = rmse(&[true_contextual], &[recovered.contextual_effect]); + assert!(within_error < 1e-12, "within RMSE {within_error}"); + assert!(between_error < 1e-12, "between RMSE {between_error}"); + assert!( + contextual_error < 1e-12, + "contextual RMSE {contextual_error}" + ); + + let predictors: Vec = rows.iter().map(|row| row.predictor).collect(); + let outcomes: Vec = rows.iter().map(|row| row.outcome).collect(); + let pooled = ordinary_least_squares_slope(&predictors, &outcomes).expect("pooled"); + let pooled_within_error = rmse(&[true_within], &[pooled]); + let pooled_between_error = rmse(&[true_between], &[pooled]); + let pooled_contextual_error = rmse(&[true_contextual], &[pooled]); + assert!( + pooled_within_error > within_error, + "pooled RMSE {pooled_within_error} should exceed CWC within {within_error}" + ); + assert!( + pooled_between_error > between_error, + "pooled RMSE {pooled_between_error} should exceed CWC between {between_error}" + ); + assert!( + pooled_contextual_error > contextual_error, + "pooled RMSE {pooled_contextual_error} should exceed CWC contextual {contextual_error}" + ); + assert!( + (recovered.contextual_effect - recovered.between_slope).abs() > 1e-9, + "Enders & Tofighi (2007, Table 2): CWC contextual must not equal the between-cluster slope" + ); +} + +#[test] +fn kish_weighted_slope_recovers_known_loading() { + let true_slope = 0.75_f64; + let predictor = [0.0_f64, 1.0, 2.0, 3.0]; + let outcome = [0.0, true_slope, 2.0 * true_slope, 3.0 * true_slope]; + let weights = [1.0_f64, 2.0, 1.0, 0.5]; + let recovered = recover_kish_weighted_slope(&predictor, &outcome, &weights).expect("wls"); + let error = rmse(&[true_slope], &[recovered]); + assert!(error < 1e-12, "Kish WLS RMSE {error}"); +} + +#[test] +fn event_time_log_rate_recovers_known_drift_and_refuses_quotient() { + let true_drift = -0.4_f64; + let earlier = 1.25_f64; + let delta = 1.5_f64; + let later = earlier * (true_drift * delta).exp(); + let recovered = + recover_event_time_discrete_lag_and_log_rate(earlier, later, delta, LagClock::EventTime) + .expect("exact map"); + let error = rmse(&[true_drift], &[recovered.log_rate]); + assert!(error < 1e-12, "log-rate RMSE {error}"); + assert_eq!( + refuse_difference_quotient_as_local_rate(earlier, later, delta), + Err(PsychometricError::DifferenceQuotientForbidden) + ); + assert_eq!( + recover_event_time_discrete_lag_and_log_rate(earlier, later, delta, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); +} + +#[test] +fn discrete_lag_remaps_across_unequal_event_intervals() { + let true_drift = -0.35_f64; + let month = 1.0_f64; + let two_months = 2.0_f64; + let month_lag = + recover_discrete_lag_from_log_rate(true_drift, month, LagClock::EventTime).expect("φ(1)"); + let two_month_truth = (true_drift * two_months).exp(); + let remapped = + map_discrete_lag_across_event_intervals(month_lag, month, two_months, LagClock::EventTime) + .expect("φ(2)"); + let error = rmse(&[two_month_truth], &[remapped]); + assert!(error < 1e-12, "interval-remap RMSE {error}"); + let pooled_error = rmse(&[two_month_truth], &[month_lag]); + assert!( + pooled_error > error, + "Voelkle: pooling φ(1) as φ(2) RMSE {pooled_error} must exceed remap {error}" + ); + assert_eq!( + refuse_pooled_discrete_lag_across_unequal_intervals(month, two_months), + Err(PsychometricError::UnequalIntervalPoolingForbidden) + ); +} + +#[test] +fn forward_map_underflow_to_zero_fails_closed() { + assert_eq!( + recover_discrete_lag_from_log_rate(-800.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + let source_lag = + recover_discrete_lag_from_log_rate(-0.7, 1.0, LagClock::EventTime).expect("source φ"); + assert!(source_lag > 0.0); + assert_eq!( + map_discrete_lag_across_event_intervals(source_lag, 1.0, 2000.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); +} + +#[test] +fn constant_predictor_discrete_effect_recovers_equation_twelve() { + let outcome_on_predictor = 0.2_f64; + let predictor_log_rate = -0.5_f64; + let delta = 2.0_f64; + let recovered = recover_discrete_constant_predictor_effect( + outcome_on_predictor, + predictor_log_rate, + delta, + LagClock::EventTime, + ) + .expect("eq 12"); + let expected = + (outcome_on_predictor / predictor_log_rate) * (predictor_log_rate * delta).exp_m1(); + let error = rmse(&[expected], &[recovered]); + assert!(error < 1e-15, "Eq. 12 RMSE {error}"); + let first_order = outcome_on_predictor * delta; + let first_order_error = rmse(&[expected], &[first_order]); + assert!( + first_order_error > error, + "Voelkle Eq. 12: first-order a_yx Δt RMSE {first_order_error} must exceed exact {error}" + ); + assert_eq!( + recover_discrete_constant_predictor_effect( + outcome_on_predictor, + 0.0, + delta, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + let underflowed = + recover_discrete_constant_predictor_effect(1e308, 1e-308, 1e-308, LagClock::EventTime) + .expect("eq 12 underflow limit"); + let underflow_error = rmse(&[1.0], &[underflowed]); + assert!( + underflow_error < 1e-15, + "Eq. 12 binary64 underflow limit RMSE {underflow_error}" + ); + // a_yx Δt overflows; Eq. 12 remains finite. + let product_overflow = + recover_discrete_constant_predictor_effect(1e308, -100.0, 10.0, LagClock::EventTime) + .expect("eq 12 finite after a_yx Δt overflow"); + let product_overflow_truth = (1e308 / -100.0) * (-100.0_f64 * 10.0).exp_m1(); + let product_overflow_error = rmse(&[product_overflow_truth], &[product_overflow]); + assert!( + product_overflow_error / 1e306 < 1e-12, + "Eq. 12 a_yx Δt overflow RMSE {product_overflow_error}" + ); + assert!(!(1e308_f64 * 10.0).is_finite()); + // z → -∞: expm1(z)/z * Δt is +0; Eq. 12 → -a_yx/a_xx. + let increment_argument = -1e308_f64 * 2.0; + assert!(increment_argument.is_infinite() && increment_argument.is_sign_negative()); + assert_eq!( + (increment_argument.exp_m1() / increment_argument * 2.0).to_bits(), + 0.0_f64.to_bits() + ); + let negative_overflow = + recover_discrete_constant_predictor_effect(1.0, -1e308, 2.0, LagClock::EventTime) + .expect("eq 12 equilibrium increment"); + let negative_overflow_truth = -(1.0 / -1e308); + let negative_overflow_error = rmse(&[negative_overflow_truth], &[negative_overflow]); + assert!( + negative_overflow_error / 1e-308 < 1e-12, + "Eq. 12 z→-∞ equilibrium RMSE {negative_overflow_error}" + ); + assert!(negative_overflow > 0.0); + // expm1(800) is +∞; (1e-308/800)(exp(800)−1) remains finite. + assert!(!800.0_f64.exp_m1().is_finite()); + let overflowed = + recover_discrete_constant_predictor_effect(1e-308, 800.0, 1.0, LagClock::EventTime) + .expect("eq 12 expm1 overflow"); + let overflowed_truth = (1e-308_f64.ln() + 800.0 - 800.0_f64.ln()).exp() - 1e-308 / 800.0; + let overflowed_error = rmse(&[overflowed_truth], &[overflowed]); + assert!( + overflowed_error / overflowed_truth < 1e-12, + "Eq. 12 expm1-overflow RMSE {overflowed_error}" + ); + assert_eq!( + recover_discrete_constant_predictor_effect(0.0, 800.0, 1.0, LagClock::EventTime), + Ok(0.0) + ); + assert_eq!( + recover_discrete_constant_predictor_effect(0.0, 1e308, 2.0, LagClock::EventTime), + Ok(0.0) + ); +} + +#[test] +fn time_varying_predictor_discrete_effect_recovers_equation_fourteen() { + let outcome_on_predictor = 0.2_f64; + let delta = 2.0_f64; + let recovered = recover_discrete_time_varying_predictor_effect( + outcome_on_predictor, + delta, + delta, + delta, + LagClock::EventTime, + ) + .expect("eq 14"); + let expected = outcome_on_predictor * delta; + let error = rmse(&[expected], &[recovered]); + assert!(error < 1e-15, "Eq. 14 RMSE {error}"); + let constant = recover_discrete_constant_predictor_effect( + outcome_on_predictor, + -0.5, + delta, + LagClock::EventTime, + ) + .expect("eq 12"); + let crossed_error = rmse(&[constant], &[recovered]); + assert!( + crossed_error > error, + "Voelkle Eq. 14 is not Eq. 12: crossed RMSE {crossed_error} must exceed {error}" + ); + assert_eq!( + recover_discrete_time_varying_predictor_effect( + outcome_on_predictor, + 1.0, + 2.0, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::UnmatchedTimeVaryingInterval) + ); + assert_eq!( + refuse_unmatched_time_varying_predictor_interval(1.0, 2.0), + Err(PsychometricError::UnmatchedTimeVaryingInterval) + ); +} + +#[test] +fn time_varying_predictor_equation_fourteen_intervals_fail_closed() { + let outcome_on_predictor = 0.2_f64; + assert_eq!( + recover_discrete_time_varying_predictor_effect( + outcome_on_predictor, + 1.0, + 1.0, + 1.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_time_varying_predictor_effect( + outcome_on_predictor, + f64::NAN, + 1.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_time_varying_predictor_effect( + outcome_on_predictor, + 0.0, + 1.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_time_varying_predictor_effect( + outcome_on_predictor, + 1.0, + f64::NAN, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_time_varying_predictor_effect( + outcome_on_predictor, + 1.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_time_varying_predictor_effect( + outcome_on_predictor, + 1.0, + 1.0, + f64::NAN, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_time_varying_predictor_effect( + outcome_on_predictor, + 1.0, + 1.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_time_varying_predictor_effect( + outcome_on_predictor, + 2.0, + 2.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::UnmatchedTimeVaryingInterval) + ); +} + +#[test] +fn time_varying_predictor_equation_fourteen_numeric_inputs_fail_closed() { + assert_eq!( + recover_discrete_time_varying_predictor_effect( + f64::NAN, + 1.0, + 1.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_time_varying_predictor_effect( + 1e308, + 10.0, + 10.0, + 10.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); +} + +#[test] +fn discrete_process_noise_recovers_driver_equation_three() { + let diffusion = 0.4_f64; + let drift = -0.5_f64; + let delta = 1.0_f64; + let recovered = + recover_discrete_process_noise(diffusion, drift, delta, LagClock::EventTime).expect("q_dt"); + let expected = diffusion * ((2.0 * drift * delta).exp() - 1.0) / (2.0 * drift); + let error = rmse(&[expected], &[recovered]); + assert!(error < 1e-15, "Driver Eq. 3 Q_Δt RMSE {error}"); + let collapsed = rmse(&[expected], &[diffusion]); + assert!( + collapsed > error, + "continuous diffusion is not discrete process noise: collapsed RMSE {collapsed} must exceed {error}" + ); + assert_eq!( + recover_discrete_process_noise(diffusion, 0.0, 2.5, LagClock::EventTime), + Ok(diffusion * 2.5) + ); + let underflowed = recover_discrete_process_noise(1.0, 1e-308, 1e-308, LagClock::EventTime) + .expect("z underflow"); + assert!(rmse(&[1e-308], &[underflowed]) < 1e-320); + let equilibrium = + recover_discrete_process_noise(0.4, -1e300, 2.0, LagClock::EventTime).expect("eq var"); + assert!(rmse(&[0.4 / (2.0 * 1e300)], &[equilibrium]) < 1e-315); + let overflowed = + recover_discrete_process_noise(1e-308, 400.0, 1.0, LagClock::EventTime).expect("rewrite"); + let rewrite_scale = 1e-308 / 800.0; + let rewrite = ((1e-308_f64).ln() + 800.0 - 800.0_f64.ln()).exp() - rewrite_scale; + assert!(rmse(&[rewrite], &[overflowed]) / rewrite.abs() < 1e-12); + assert_eq!( + recover_discrete_process_noise(0.0, 800.0, 1.0, LagClock::EventTime), + Ok(0.0) + ); + let twice_rate_overflow = + recover_discrete_process_noise(1.0, 1e308, 1e-308, LagClock::EventTime) + .expect("2a overflow"); + let expected_twice_rate = 0.5 * 2.0_f64.exp_m1() / 1e308; + assert!(rmse(&[expected_twice_rate], &[twice_rate_overflow]) / expected_twice_rate < 1e-12); + let overflowed_equilibrium = + recover_discrete_process_noise(1e308, -1e308, 2.0, LagClock::EventTime).expect("2a eq var"); + assert!(rmse(&[0.5], &[overflowed_equilibrium]) < 1e-15); + assert_eq!( + recover_discrete_process_noise(1.0, 800.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_process_noise(1e308, 0.1, 4000.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_process_noise(1.0, 1e308, 2.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_process_noise(0.4, -0.5, 0.0, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_process_noise(0.4, -0.5, -1.0, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_process_noise(0.4, -0.5, f64::NAN, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_process_noise(-0.1, -0.5, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_process_noise(f64::NAN, -0.5, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_process_noise(0.4, f64::NAN, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_process_noise(0.4, -0.5, 1.0, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); +} + +#[test] +fn within_residual_event_time_log_rate_beats_pooled_levels() { + let true_drift = -0.3_f64; + let mut rows = Vec::new(); + for (cluster, person_mean, start) in [(1_u64, 8.0_f64, 1.0_f64), (2, 5.0, 1.4)] { + for step in 0..6 { + let time = f64::from(step); + rows.push(ClusteredEventScore { + cluster_key: cluster, + event_time: time, + score: person_mean + start * (true_drift * time).exp(), + }); + } + } + let recovered = + recover_within_residual_event_time_log_rate(&rows, LagClock::EventTime).expect("cwc lag"); + let within_error = rmse(&[true_drift], &[recovered]); + + let mut pooled = Vec::new(); + for (cluster, person_mean, start) in [(1_u64, 8.0_f64, 1.0_f64), (2, 1.0, 1.4)] { + let time = cluster as f64; + pooled.push(EventOccasion { + event_time: time, + score: person_mean + start, + }); + } + let pooled_rate = recover_event_series_mean_log_rate(&pooled, LagClock::EventTime) + .expect("pooled positive lag"); + let pooled_error = rmse(&[true_drift], &[pooled_rate]); + assert!( + within_error < pooled_error, + "CWC lag RMSE {within_error} should beat pooled {pooled_error}" + ); + assert!(within_error < 0.25, "CWC lag RMSE {within_error} too large"); +} + +#[test] +fn irregular_centered_residuals_recover_known_drift_better_than_cwc_of_raw_ar() { + let true_drift = -0.35_f64; + let pairs = [ + LaggedWithinResidual { + earlier_residual: 1.4, + later_residual: 1.4 * (true_drift * 0.4).exp(), + event_delta: 0.4, + }, + LaggedWithinResidual { + earlier_residual: 0.9, + later_residual: 0.9 * (true_drift * 1.6).exp(), + event_delta: 1.6, + }, + LaggedWithinResidual { + earlier_residual: -0.7, + later_residual: -0.7 * (true_drift * 2.2).exp(), + event_delta: 2.2, + }, + ]; + let centered = recover_irregular_centered_residual_log_rate(&pairs, LagClock::EventTime) + .expect("centered residual"); + let centered_error = rmse(&[true_drift], &[centered]); + assert!( + centered_error < 1e-12, + "already-centered irregular RMSE {centered_error}" + ); + + let mut raw_ar = Vec::new(); + for (cluster, person_mean, start) in [(1_u64, 7.5_f64, 1.1_f64), (2, -4.0, 0.8)] { + for step in 0..6 { + let time = f64::from(step); + raw_ar.push(ClusteredEventScore { + cluster_key: cluster, + event_time: time, + score: person_mean + start * (true_drift * time).exp(), + }); + } + } + let cwc = + recover_within_residual_event_time_log_rate(&raw_ar, LagClock::EventTime).expect("cwc ar"); + let cwc_error = rmse(&[true_drift], &[cwc]); + assert!( + cwc_error > centered_error, + "Curran & Bauer: CWC of raw AR RMSE {cwc_error} must exceed already-centered {centered_error}" + ); +} + +#[test] +fn discrete_latent_variance_recovers_driver_equations_three_and_four() { + let prior = 2.0_f64; + let diffusion = 0.4_f64; + let drift = -0.5_f64; + let delta = 1.0_f64; + let process_noise = + recover_discrete_process_noise(diffusion, drift, delta, LagClock::EventTime).expect("q_dt"); + let lagged = + recover_discrete_lagged_latent_covariance(prior, drift, delta, LagClock::EventTime) + .expect("lagged"); + let latent = + recover_discrete_latent_variance(prior, diffusion, drift, delta, LagClock::EventTime) + .expect("var"); + let expected_lagged = (drift * delta).exp() * prior; + let expected_var = (2.0 * drift * delta).exp() * prior + process_noise; + let lagged_error = rmse(&[expected_lagged], &[lagged]); + let var_error = rmse(&[expected_var], &[latent]); + assert!( + lagged_error < 1e-15, + "Driver Eq. 3-4 lagged RMSE {lagged_error}" + ); + assert!( + var_error < 1e-15, + "Driver Eq. 3-4 variance RMSE {var_error}" + ); + let collapsed = rmse(&[expected_var], &[process_noise]); + assert!( + collapsed > var_error, + "Q_Δt is not Var(η_t): collapsed RMSE {collapsed} must exceed {var_error}" + ); + assert_eq!( + refuse_process_noise_as_unconditional_variance(process_noise, prior), + Err(PsychometricError::ProcessNoiseIsConditionalVariance) + ); + assert_eq!( + recover_discrete_lagged_latent_covariance(0.0, 800.0, 1.0, LagClock::EventTime), + Ok(0.0) + ); + let rewritten = + recover_discrete_lagged_latent_covariance(1e-308, 800.0, 1.0, LagClock::EventTime) + .expect("rewrite"); + assert!(rewritten.is_finite()); + assert!(rewritten > 0.0); + assert_eq!( + recover_discrete_lagged_latent_covariance(2.0, 1e308, 2.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_variance(-1.0, diffusion, drift, delta, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_lagged_latent_covariance(2.0, drift, 0.0, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_lagged_latent_covariance(2.0, drift, 1.0, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_lagged_latent_covariance(1e308, 700.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_variance(1e308, 1e308, 0.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + // Zero diffusion skips process-noise z→+∞. exp(2 a Δt) p is then + // non-finite (Driver Eq. 3–4). + assert_eq!( + recover_discrete_latent_variance(2.0, 0.0, 1e308, 2.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); +} + +#[test] +fn stationary_variance_recovers_driver_equation_four_asymptote() { + let diffusion = 0.4_f64; + let drift = -0.5_f64; + let stationary = + recover_stationary_latent_variance(diffusion, drift, LagClock::EventTime).expect("asym"); + let expected = (diffusion / drift) * -0.5; + let error = rmse(&[expected], &[stationary]); + assert!(error < 1e-15, "Driver Eq. 4 asymDIFFUSION RMSE {error}"); + for delta in [0.5_f64, 1.0, 2.0, 10.0] { + let evolved = recover_discrete_latent_variance( + stationary, + diffusion, + drift, + delta, + LagClock::EventTime, + ) + .expect("invariant"); + let evolved_error = rmse(&[stationary], &[evolved]); + assert!( + evolved_error < 1e-12, + "stationary variance must be invariant at Δt={delta}: RMSE {evolved_error}" + ); + } + let finite_noise = + recover_discrete_process_noise(diffusion, drift, 1.0, LagClock::EventTime).expect("q_dt"); + let collapsed = rmse(&[stationary], &[finite_noise]); + assert!( + collapsed > error, + "finite-Δt Q_Δt is not asymDIFFUSION: collapsed RMSE {collapsed} must exceed {error}" + ); + assert_eq!( + refuse_finite_interval_process_noise_as_stationary_variance(finite_noise, 1.0), + Err(PsychometricError::FiniteIntervalProcessNoiseIsNotStationary) + ); + assert_eq!( + recover_stationary_latent_variance(diffusion, 0.0, LagClock::EventTime), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_stationary_latent_variance(diffusion, 0.5, LagClock::EventTime), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_stationary_latent_variance(1e308, -1e308, LagClock::EventTime), + Ok(0.5) + ); + let min_subnormal = f64::from_bits(1); + assert_eq!( + recover_stationary_latent_variance(min_subnormal, -min_subnormal, LagClock::EventTime), + Ok(0.5) + ); + assert!(!(f64::MAX / -0.75_f64).is_finite()); + assert_eq!( + recover_stationary_latent_variance(f64::MAX, -0.75, LagClock::EventTime) + .expect("q/a overflow") + .to_bits(), + (f64::MAX / 1.5).to_bits() + ); + assert_eq!( + recover_stationary_latent_variance(diffusion, drift, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_stationary_latent_variance(1e308, -1e-10, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); +} + +#[test] +fn trait_plus_state_recovers_driver_section_four_point_three() { + let trait_variance = 1.5_f64; + let diffusion = 0.4_f64; + let drift = -0.5_f64; + let delta = 1.0_f64; + let state = + recover_stationary_latent_variance(diffusion, drift, LagClock::EventTime).expect("state"); + let total = recover_trait_plus_state_latent_variance(trait_variance, state).expect("sum"); + let expected_total = trait_variance + state; + let error = rmse(&[expected_total], &[total]); + assert!(error < 1e-15, "Driver §4.3 trait+state RMSE {error}"); + let lagged = recover_trait_plus_state_lagged_covariance( + trait_variance, + state, + drift, + delta, + LagClock::EventTime, + ) + .expect("lagged"); + let state_lagged = + recover_discrete_lagged_latent_covariance(state, drift, delta, LagClock::EventTime) + .expect("state lagged"); + let lagged_error = rmse(&[trait_variance + state_lagged], &[lagged]); + assert!( + lagged_error < 1e-15, + "Driver §4.3 trait+state lagged RMSE {lagged_error}" + ); + let evolved_as_state = + recover_discrete_latent_variance(total, diffusion, drift, delta, LagClock::EventTime) + .expect("wrong"); + let evolved_state = + recover_discrete_latent_variance(state, diffusion, drift, delta, LagClock::EventTime) + .expect("state evolved"); + let evolved_right = + recover_trait_plus_state_latent_variance(trait_variance, evolved_state).expect("right"); + let right_error = rmse(&[total], &[evolved_right]); + assert!( + right_error < 1e-12, + "trait + stationary state must stay invariant: RMSE {right_error}" + ); + let collapsed = rmse(&[evolved_right], &[evolved_as_state]); + assert!( + collapsed > error, + "evolving trait+state as all-state is not Driver §4.3: collapsed RMSE {collapsed} must exceed {error}" + ); + let process_noise = + recover_discrete_process_noise(diffusion, drift, delta, LagClock::EventTime).expect("q_dt"); + assert_eq!( + refuse_trait_variance_as_process_noise(trait_variance, process_noise), + Err(PsychometricError::TraitVarianceIsNotProcessNoise) + ); + assert_eq!( + refuse_trait_variance_as_stationary_within_subject(trait_variance, state), + Err(PsychometricError::TraitVarianceIsNotStationaryWithinSubject) + ); + assert_eq!( + recover_trait_plus_state_latent_variance(0.0, state), + Ok(state) + ); + assert_eq!( + recover_trait_plus_state_latent_variance(trait_variance, 0.0), + Ok(trait_variance) + ); + assert_eq!( + recover_trait_plus_state_lagged_covariance(1e308, 1e308, 0.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_trait_plus_state_lagged_covariance(0.4, 0.4, -0.5, 1.0, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); +} + +#[test] +fn manifest_observed_variance_recovers_driver_equation_five() { + let loading = 2.0_f64; + let latent = 0.4_f64; + let measurement_error = 0.1_f64; + let observed = + recover_manifest_observed_variance(loading, latent, measurement_error).expect("eq5"); + let expected = (loading * latent) * loading + measurement_error; + let error = rmse(&[expected], &[observed]); + assert!(error < 1e-15, "Driver Eq. 5 Var(y) RMSE {error}"); + let collapsed_error = rmse(&[expected], &[measurement_error]); + let latent_error = rmse(&[expected], &[latent]); + assert!( + collapsed_error > error, + "MANIFESTVAR is not Var(y): collapsed RMSE {collapsed_error} must exceed {error}" + ); + assert!( + latent_error > error, + "Var(η) is not Var(y): latent RMSE {latent_error} must exceed {error}" + ); + assert_eq!( + refuse_measurement_error_as_observed_variance(measurement_error, observed), + Err(PsychometricError::MeasurementErrorIsNotObservedVariance) + ); + assert_eq!( + refuse_latent_variance_as_observed_variance(latent, observed), + Err(PsychometricError::LatentVarianceIsNotObservedVariance) + ); + assert_eq!( + recover_manifest_observed_variance(0.0, latent, measurement_error), + Ok(measurement_error) + ); + let scaled = recover_manifest_observed_variance(1e308, 1e-308, 0.0).expect("scale"); + assert!( + (scaled - 1e308).abs() / 1e308 < 1e-15, + "Driver Eq. 5 (λ p)λ must keep λ=1e308, p=1e-308: got {scaled}" + ); + assert_eq!( + recover_manifest_observed_variance(1e308, 1.0, 0.0), + Err(PsychometricError::InvalidNumericInput) + ); +} + +#[test] +fn manifest_trait_plus_state_observed_variance_recovers_driver_equation_five() { + let loading = 2.0_f64; + let latent = 0.4_f64; + let measurement_error = 0.1_f64; + let manifest_trait = 0.5_f64; + let observed = recover_manifest_trait_plus_state_observed_variance( + loading, + latent, + measurement_error, + manifest_trait, + ) + .expect("eq5-trait"); + let expected = (loading * latent) * loading + measurement_error + manifest_trait; + let error = rmse(&[expected], &[observed]); + assert!(error < 1e-15, "Driver Eq. 5 λ²p+θ+ψ RMSE {error}"); + let dropped_trait = + recover_manifest_observed_variance(loading, latent, measurement_error).expect("psi0"); + let dropped_error = rmse(&[expected], &[dropped_trait]); + assert!( + dropped_error > error, + "MANIFESTTRAITVAR is not dropped: RMSE {dropped_error} must exceed {error}" + ); + let stuffed = + recover_manifest_observed_variance(loading, latent, manifest_trait).expect("psi-as-theta"); + let stuffed_error = rmse(&[expected], &[stuffed]); + assert!( + stuffed_error > error, + "MANIFESTTRAITVAR is not MANIFESTVAR: stuffed RMSE {stuffed_error} must exceed {error}" + ); + let latent_trait = + recover_manifest_observed_variance(loading, latent + manifest_trait, measurement_error) + .expect("traitvar"); + let latent_trait_error = rmse(&[expected], &[latent_trait]); + assert!( + latent_trait_error > error, + "TRAITVAR is not MANIFESTTRAITVAR: scaled RMSE {latent_trait_error} must exceed {error}" + ); + assert_eq!( + refuse_manifest_trait_variance_as_measurement_error(manifest_trait, measurement_error), + Err(PsychometricError::ManifestTraitVarianceIsNotMeasurementError) + ); + assert_eq!( + recover_manifest_trait_plus_state_observed_variance( + 0.0, + latent, + measurement_error, + manifest_trait + ), + Ok(measurement_error + manifest_trait) + ); + let scaled = recover_manifest_trait_plus_state_observed_variance(1e308, 1e-308, 0.0, 1.0) + .expect("scale"); + assert!( + (scaled - 1e308).abs() / 1e308 < 1e-15, + "Driver Eq. 5 (λ p)λ + ψ must keep λ=1e308, p=1e-308: got {scaled}" + ); + assert_eq!( + recover_manifest_trait_plus_state_observed_variance(1e308, 1e-308, 1e308, 1e308), + Err(PsychometricError::InvalidNumericInput) + ); +} + +#[test] +fn manifest_lagged_observed_covariance_recovers_driver_equation_five() { + let loading = 2.0_f64; + let lagged = 0.4_f64; + let manifest_trait = 0.5_f64; + let observed = recover_manifest_lagged_observed_covariance(loading, lagged, manifest_trait) + .expect("eq5-lag"); + let expected = (loading * lagged) * loading + manifest_trait; + let error = rmse(&[expected], &[observed]); + assert!(error < 1e-15, "Driver Eq. 5 lagged cov RMSE {error}"); + let latent_error = rmse(&[expected], &[lagged]); + assert!( + latent_error > error, + "lagged Var(η) path is not cov(y): RMSE {latent_error} must exceed {error}" + ); + let without_trait = + recover_manifest_lagged_observed_covariance(loading, lagged, 0.0).expect("psi0"); + let dropped_error = rmse(&[expected], &[without_trait]); + assert!( + dropped_error > error, + "MANIFESTTRAITVAR is not dropped from lagged cov: RMSE {dropped_error} must exceed {error}" + ); + assert_eq!( + refuse_latent_lagged_covariance_as_observed_covariance(lagged, observed), + Err(PsychometricError::LatentLaggedCovarianceIsNotObservedCovariance) + ); + assert_eq!( + refuse_measurement_error_as_lagged_observed_covariance(0.1, observed), + Err(PsychometricError::MeasurementErrorIsNotLaggedObservedCovariance) + ); + let scaled = recover_manifest_lagged_observed_covariance(1e308, 1e-308, 0.0).expect("scale"); + assert!( + (scaled - 1e308).abs() / 1e308 < 1e-15, + "Driver Eq. 5 (λ c)λ must keep λ=1e308, c=1e-308: got {scaled}" + ); + assert_eq!( + recover_manifest_lagged_observed_covariance(1e308, 1.0, 0.0), + Err(PsychometricError::InvalidNumericInput) + ); +} + +#[test] +fn manifest_observed_mean_recovers_driver_equation_five() { + let loading = 2.0_f64; + let latent_mean = 0.4_f64; + let manifest_mean = 0.5_f64; + let observed = + recover_manifest_observed_mean(loading, latent_mean, manifest_mean).expect("eq5-mean"); + let expected = loading * latent_mean + manifest_mean; + let error = rmse(&[expected], &[observed]); + assert!(error < 1e-15, "Driver Eq. 5 observed mean RMSE {error}"); + let intercept_error = rmse(&[expected], &[manifest_mean]); + assert!( + intercept_error > error, + "MANIFESTMEANS is not E(y): RMSE {intercept_error} must exceed {error}" + ); + let latent_error = rmse(&[expected], &[latent_mean]); + assert!( + latent_error > error, + "E(η) is not E(y): RMSE {latent_error} must exceed {error}" + ); + let without_loading = + recover_manifest_observed_mean(0.0, latent_mean, manifest_mean).expect("lambda0"); + let dropped_error = rmse(&[expected], &[without_loading]); + assert!( + dropped_error > error, + "zero loading is τ, not τ + λμ: RMSE {dropped_error} must exceed {error}" + ); + assert_eq!( + refuse_manifest_means_as_observed_mean(manifest_mean, observed), + Err(PsychometricError::ManifestMeansIsNotObservedMean) + ); + assert_eq!( + refuse_latent_mean_as_observed_mean(latent_mean, observed), + Err(PsychometricError::LatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_continuous_intercept_as_manifest_means(0.3, manifest_mean), + Err(PsychometricError::ContinuousInterceptIsNotManifestMeans) + ); + let scaled = recover_manifest_observed_mean(1e308, 1e-308, 0.0).expect("scale"); + assert!( + (scaled - 1.0).abs() < 1e-15, + "Driver Eq. 5 λμ must keep λ=1e308, μ=1e-308: got {scaled}" + ); + let finite_loaded = recover_manifest_observed_mean(1e308, 1.0, 0.0).expect("lambda-mu"); + assert!( + (finite_loaded - 1e308).abs() / 1e308 < 1e-15, + "Driver Eq. 5 mean is λμ, not λ²: got {finite_loaded}" + ); + assert_eq!( + recover_manifest_observed_mean(1e308, 2.0, 0.0), + Err(PsychometricError::InvalidNumericInput) + ); +} + +#[test] +fn discrete_latent_mean_recovers_driver_equation_three() { + let drift = -0.5_f64; + let delta = 2.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let observed = + recover_discrete_latent_mean(initial, drift, intercept, delta, LagClock::EventTime) + .expect("eq3-mean"); + let expected = (drift * delta).exp() * initial + intercept * ((drift * delta).exp_m1() / drift); + let error = rmse(&[expected], &[observed]); + assert!(error < 1e-15, "Driver Eq. 3 latent mean RMSE {error}"); + let initial_error = rmse(&[expected], &[initial]); + assert!( + initial_error > error, + "T0MEANS is not μ_t: RMSE {initial_error} must exceed {error}" + ); + let intercept_error = rmse(&[expected], &[intercept]); + assert!( + intercept_error > error, + "CINT is not μ_t: RMSE {intercept_error} must exceed {error}" + ); + let increment = + recover_discrete_continuous_intercept_effect(intercept, drift, delta, LagClock::EventTime) + .expect("cint"); + let increment_error = rmse(&[increment], &[intercept]); + assert!( + increment_error > 1e-3, + "CINT is not the discrete increment: RMSE {increment_error}" + ); + assert_eq!( + refuse_initial_latent_mean_as_evolved_mean(initial, observed), + Err(PsychometricError::InitialLatentMeanIsNotEvolvedMean) + ); + assert_eq!( + refuse_continuous_intercept_as_discrete_mean_increment(intercept, increment), + Err(PsychometricError::ContinuousInterceptIsNotDiscreteMeanIncrement) + ); + assert_eq!( + refuse_continuous_intercept_as_initial_latent_mean(intercept, initial), + Err(PsychometricError::ContinuousInterceptIsNotInitialLatentMean) + ); + let integrator = + recover_discrete_latent_mean(initial, 0.0, intercept, delta, LagClock::EventTime) + .expect("a0"); + assert!( + (integrator - (initial + intercept * delta)).abs() < 1e-15, + "Driver Eq. 3 A=0 integral is κ Δt: got {integrator}" + ); + let equilibrium = recover_discrete_latent_mean(initial, -1e308, 1.0, 2.0, LagClock::EventTime) + .expect("eq3-equilibrium"); + let equilibrium_expected = -(1.0 / -1e308); + assert!( + (equilibrium - equilibrium_expected).abs() / 1e-308 < 1e-12, + "Driver Eq. 3 z→-∞ drops T0MEANS and keeps -κ/a: got {equilibrium}" + ); + assert_eq!( + recover_discrete_latent_mean(1e308, 1.0, 0.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); +} + +#[test] +fn discrete_observed_mean_recovers_driver_equations_three_and_five() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let evolved = + recover_discrete_latent_mean(initial, drift, intercept, delta, LagClock::EventTime) + .expect("mu-t"); + let expected = manifest_mean + loading * evolved; + let error = rmse(&[expected], &[observed]); + assert!( + error < 1e-15, + "Driver Eq. 5 of Eq. 3 evolved mean RMSE {error}" + ); + let first_occasion = + recover_manifest_observed_mean(loading, initial, manifest_mean).expect("t0"); + let first_error = rmse(&[expected], &[first_occasion]); + assert!( + first_error > error, + "τ + λ μ_0 is not E(y_t): RMSE {first_error} must exceed {error}" + ); + let intercept_error = rmse(&[expected], &[manifest_mean]); + assert!( + intercept_error > error, + "MANIFESTMEANS is not E(y_t): RMSE {intercept_error} must exceed {error}" + ); + let latent_error = rmse(&[expected], &[evolved]); + assert!( + latent_error > error, + "μ_t is not E(y_t): RMSE {latent_error} must exceed {error}" + ); + let zero_loading = recover_discrete_observed_mean( + 0.0, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("lambda0"); + let dropped_error = rmse(&[expected], &[zero_loading]); + assert!( + dropped_error > error, + "zero loading is τ, not τ + λ μ_t: RMSE {dropped_error} must exceed {error}" + ); +} + +#[test] +fn discrete_observed_mean_refuses_first_occasion_and_overflow() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let evolved = + recover_discrete_latent_mean(initial, drift, intercept, delta, LagClock::EventTime) + .expect("mu-t"); + let first_occasion = + recover_manifest_observed_mean(loading, initial, manifest_mean).expect("t0"); + assert_eq!( + refuse_initial_observed_mean_as_evolved_observed_mean(first_occasion, observed), + Err(PsychometricError::InitialObservedMeanIsNotEvolvedObservedMean) + ); + assert_eq!( + refuse_latent_mean_as_observed_mean(evolved, observed), + Err(PsychometricError::LatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_manifest_means_as_observed_mean(manifest_mean, observed), + Err(PsychometricError::ManifestMeansIsNotObservedMean) + ); + let integrator = recover_discrete_observed_mean( + loading, + initial, + 0.0, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("a0"); + assert!( + (integrator - (manifest_mean + loading * (initial + intercept * delta))).abs() < 1e-15, + "Driver Eq. 5 of A=0 mean is τ + λ(μ_0 + κ Δt): got {integrator}" + ); + let equilibrium = recover_discrete_observed_mean( + loading, + initial, + -1e308, + 1.0, + manifest_mean, + 2.0, + LagClock::EventTime, + ) + .expect("eq3-eq5-equilibrium"); + let equilibrium_expected = manifest_mean + loading * (-(1.0 / -1e308)); + assert!( + (equilibrium - equilibrium_expected).abs() < 1e-15, + "Driver Eq. 5 of z→-∞ mean keeps τ + λ(-κ/a): got {equilibrium}" + ); + let scaled = + recover_discrete_observed_mean(1e308, 1e-308, 0.0, 0.0, 0.0, 1.0, LagClock::EventTime) + .expect("scale"); + assert!( + (scaled - 1.0).abs() < 1e-15, + "Driver Eq. 5 of Eq. 3 mean must keep λ=1e308, μ_t=1e-308: got {scaled}" + ); + assert_eq!( + recover_discrete_observed_mean(1e308, 2.0, 0.0, 0.0, 0.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); +} + +#[test] +fn time_dependent_impulse_recovers_driver_equation_three_fourth_summand() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("tdpred"); + let error = rmse(&[1.2], &[impulse]); + assert!( + error < 1e-15, + "Driver Eq. 3 fourth summand RMSE {error}: got {impulse}" + ); + let drift = -0.5_f64; + let delta = 2.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let composed = recover_discrete_latent_mean_with_impulse( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-impulse"); + let evolved = + recover_discrete_latent_mean(initial, drift, intercept, delta, LagClock::EventTime) + .expect("mu-t"); + let composed_error = rmse(&[evolved + impulse], &[composed]); + assert!( + composed_error < 1e-15, + "Driver Eq. 3 μ_t + m x RMSE {composed_error}: got {composed}" + ); + let intercept_effect = + recover_discrete_continuous_intercept_effect(effect, drift, delta, LagClock::EventTime) + .expect("cint"); + let equation_fourteen = recover_discrete_time_varying_predictor_effect( + effect, + delta, + delta, + delta, + LagClock::EventTime, + ) + .expect("eq14"); + assert!((impulse - intercept_effect).abs() > 1e-3); + assert!((impulse - equation_fourteen).abs() > 1e-3); + assert_eq!( + refuse_time_dependent_impulse_as_continuous_intercept(impulse, effect), + Err(PsychometricError::TimeDependentImpulseIsNotContinuousIntercept) + ); + assert_eq!( + refuse_time_dependent_impulse_as_time_independent_effect(impulse, intercept_effect), + Err(PsychometricError::TimeDependentImpulseIsNotTimeIndependentEffect) + ); + assert_eq!( + refuse_time_dependent_impulse_as_time_varying_discrete_effect(impulse, equation_fourteen), + Err(PsychometricError::TimeDependentImpulseIsNotTimeVaryingDiscreteEffect) + ); +} + +#[test] +fn time_dependent_impulse_refuses_overflow_and_non_event_clocks() { + assert_eq!( + recover_time_dependent_predictor_impulse(1e308, 2.0), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_mean_with_impulse( + 1.0, + -0.5, + 0.3, + 0.4, + 2.0, + 2.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_latent_mean_with_impulse( + 1.0, + -0.5, + 0.3, + 1e308, + 2.0, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_mean_with_impulse( + 1e308, + 0.0, + 0.0, + 1e308, + 1.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); +} + +#[test] +fn discrete_observed_mean_with_impulse_recovers_driver_equation_five() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + let composed = recover_discrete_latent_mean_with_impulse( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("mx"); + let expected = manifest_mean + loading * composed; + let error = rmse(&[expected], &[observed]); + assert!( + error < 1e-15, + "Driver Eq. 5 of Eq. 3 contemporaneous impulse RMSE {error}" + ); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let evolved_error = rmse(&[expected], &[evolved_observed]); + assert!( + evolved_error > error, + "τ + λ μ_t is not contemporaneous-impulse E(y_t): RMSE {evolved_error} must exceed {error}" + ); + let intercept_error = rmse(&[expected], &[manifest_mean]); + assert!( + intercept_error > error, + "MANIFESTMEANS is not contemporaneous-impulse E(y_t): RMSE {intercept_error} must exceed {error}" + ); + let latent_error = rmse(&[expected], &[composed]); + assert!( + latent_error > error, + "evolved-plus-impulse latent mean is not E(y_t): RMSE {latent_error} must exceed {error}" + ); +} + +#[test] +fn discrete_observed_mean_with_impulse_refuses_evolved_mean_and_carry() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + let composed = recover_discrete_latent_mean_with_impulse( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("mx"); + let expected = manifest_mean + loading * composed; + let error = rmse(&[expected], &[observed]); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let carried_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + 1.0, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + let carry_error = rmse(&[expected], &[carried_observed]); + assert!( + carry_error > error, + "τ + λ(μ_t + carry) is not contemporaneous-impulse E(y_t): RMSE {carry_error} must exceed {error}" + ); + let zero_loading = recover_discrete_observed_mean_with_impulse( + 0.0, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("lambda0"); + let dropped_error = rmse(&[expected], &[zero_loading]); + assert!( + dropped_error > error, + "zero loading is τ, not τ + λ(μ_t + m x): RMSE {dropped_error} must exceed {error}" + ); + assert_eq!( + refuse_evolved_observed_mean_as_impulse_observed_mean(evolved_observed, observed), + Err(PsychometricError::EvolvedObservedMeanIsNotImpulseObservedMean) + ); + assert_eq!( + refuse_impulse_observed_mean_as_impulse_carry_observed_mean(observed, carried_observed), + Err(PsychometricError::ImpulseObservedMeanIsNotImpulseCarryObservedMean) + ); + assert_eq!( + refuse_latent_mean_as_observed_mean(composed, observed), + Err(PsychometricError::LatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_manifest_means_as_observed_mean(manifest_mean, observed), + Err(PsychometricError::ManifestMeansIsNotObservedMean) + ); +} + +#[test] +fn discrete_observed_mean_with_impulse_refuses_overflow_and_non_event_clocks() { + assert_eq!( + recover_discrete_observed_mean_with_impulse( + 1e308, + 2.0, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_observed_mean_with_impulse( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_observed_mean_with_impulse( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_observed_mean_with_impulse( + 1e308, + 0.0, + 0.0, + 0.0, + 1e308, + 1.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + let scaled = recover_discrete_observed_mean_with_impulse( + 1e308, + 1e-308, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + LagClock::EventTime, + ) + .expect("scale"); + assert!( + (scaled - 1.0).abs() < 1e-15, + "Driver Eq. 5 of Eq. 3 impulse must keep λ=1e308, μ=1e-308: got {scaled}" + ); +} + +#[test] +fn time_independent_predictor_recovers_driver_equation_three_second_summand() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let increment = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("tipred"); + let expected = + recover_discrete_constant_predictor_effect(1.2, drift, delta, LagClock::EventTime) + .expect("bz-map"); + let error = rmse(&[expected], &[increment]); + assert!( + error < 1e-15, + "Driver Eq. 3 TIPREDEFFECT RMSE {error}: got {increment}" + ); + let intercept_effect = + recover_discrete_continuous_intercept_effect(effect, drift, delta, LagClock::EventTime) + .expect("cint"); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("tdpred"); + let equation_fourteen = recover_discrete_time_varying_predictor_effect( + effect, + delta, + delta, + delta, + LagClock::EventTime, + ) + .expect("eq14"); + assert!(rmse(&[increment], &[intercept_effect]) > rmse(&[expected], &[increment])); + assert!(rmse(&[increment], &[impulse]) > rmse(&[expected], &[increment])); + assert!(rmse(&[increment], &[equation_fourteen]) > rmse(&[expected], &[increment])); + assert!(rmse(&[increment], &[effect]) > rmse(&[expected], &[increment])); + let composed = recover_discrete_latent_mean_with_time_independent_predictor( + 1.0, + drift, + 0.3, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-tipred"); + let evolved = + recover_discrete_latent_mean(1.0, drift, 0.3, delta, LagClock::EventTime).expect("mu-t"); + let composed_error = rmse(&[evolved + increment], &[composed]); + assert!( + composed_error < 1e-15, + "Driver Eq. 3 μ_t + A^{{-1}}[e^{{A Δt}} − I] B z RMSE {composed_error}: got {composed}" + ); + assert_eq!( + refuse_time_independent_effect_as_continuous_intercept(increment, effect), + Err(PsychometricError::TimeIndependentEffectIsNotContinuousIntercept) + ); + assert_eq!( + refuse_time_independent_effect_as_time_dependent_impulse(increment, impulse), + Err(PsychometricError::TimeIndependentEffectIsNotTimeDependentImpulse) + ); + assert_eq!( + refuse_time_independent_effect_as_time_varying_discrete_effect( + increment, + equation_fourteen + ), + Err(PsychometricError::TimeIndependentEffectIsNotTimeVaryingDiscreteEffect) + ); + assert_eq!( + refuse_time_independent_coefficient_as_discrete_effect(effect, increment), + Err(PsychometricError::TimeIndependentCoefficientIsNotDiscreteEffect) + ); +} + +#[test] +fn time_independent_predictor_refuses_overflow_and_non_event_clocks() { + assert_eq!( + recover_discrete_time_independent_predictor_effect( + 1e308, + 2.0, + -0.5, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_time_independent_predictor_effect( + 0.4, + 3.0, + f64::NAN, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_mean_with_time_independent_predictor( + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 2.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_latent_mean_with_time_independent_predictor( + 1e308, + 0.0, + 0.0, + 1e308, + 1.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_latent_mean_with_time_independent_predictor( + 1.0, + -0.5, + 0.3, + 1e308, + 2.0, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); +} + +#[test] +fn initial_time_independent_predictor_recovers_driver_table_three_t0_shift() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let shift = + recover_initial_time_independent_predictor_effect(effect, predictor).expect("t0-tipred"); + let error = rmse(&[1.2], &[shift]); + assert!( + error < 1e-15, + "Driver Table 3 T0TIPREDEFFECT RMSE {error}: got {shift}" + ); + let carry = recover_initial_time_independent_predictor_carry( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("t0-carry"); + let expected_carry = 1.2 * (drift * delta).exp(); + let carry_error = rmse(&[expected_carry], &[carry]); + assert!( + carry_error < 1e-15, + "Driver Eq. 3 first-summand T0TIPREDEFFECT carry RMSE {carry_error}: got {carry}" + ); + let increment = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("tipred"); + let intercept_effect = + recover_discrete_continuous_intercept_effect(effect, drift, delta, LagClock::EventTime) + .expect("cint"); + assert!(rmse(&[carry], &[shift]) > rmse(&[expected_carry], &[carry])); + assert!(rmse(&[carry], &[increment]) > rmse(&[expected_carry], &[carry])); + assert!(rmse(&[shift], &[increment]) > rmse(&[1.2], &[shift])); + assert!(rmse(&[shift], &[intercept_effect]) > rmse(&[1.2], &[shift])); + assert!(rmse(&[shift], &[effect]) > rmse(&[1.2], &[shift])); + let composed = recover_discrete_latent_mean_with_initial_time_independent_predictor( + 1.0, + drift, + 0.3, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-t0tipred"); + let evolved = + recover_discrete_latent_mean(1.0, drift, 0.3, delta, LagClock::EventTime).expect("mu-t"); + let composed_error = rmse(&[evolved + carry], &[composed]); + assert!( + composed_error < 1e-15, + "Driver Eq. 3 μ_t + e^{{A Δt}} t0_b z RMSE {composed_error}: got {composed}" + ); + assert_eq!( + refuse_initial_time_independent_effect_as_process_increment(shift, increment), + Err(PsychometricError::InitialTimeIndependentEffectIsNotProcessIncrement) + ); + assert_eq!( + refuse_initial_time_independent_carry_as_initial_effect(carry, shift), + Err(PsychometricError::InitialTimeIndependentCarryIsNotInitialEffect) + ); + assert_eq!( + refuse_initial_time_independent_effect_as_continuous_intercept(shift, effect), + Err(PsychometricError::InitialTimeIndependentEffectIsNotContinuousIntercept) + ); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("tdpred"); + assert_eq!( + refuse_initial_time_independent_effect_as_time_dependent_impulse(shift, impulse), + Err(PsychometricError::InitialTimeIndependentEffectIsNotTimeDependentImpulse) + ); + assert_eq!( + refuse_initial_time_independent_coefficient_as_initial_effect(effect, shift), + Err(PsychometricError::InitialTimeIndependentCoefficientIsNotInitialEffect) + ); +} + +#[test] +fn initial_time_independent_predictor_refuses_overflow_and_non_event_clocks() { + assert_eq!( + recover_initial_time_independent_predictor_effect(1e308, 2.0), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_initial_time_independent_predictor_carry(0.4, 3.0, -0.5, 2.0, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_initial_time_independent_predictor_carry(0.4, 3.0, 1e308, 2.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_initial_time_independent_predictor_carry(2.0, 0.5, 710.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + let finite_rewrite = recover_initial_time_independent_predictor_carry( + 1e-308, + 1.0, + 700.0, + 1.0, + LagClock::EventTime, + ) + .expect("t0-ti-log-rewrite"); + let expected_rewrite = (1e-308_f64.ln() + 700.0).exp(); + assert!((finite_rewrite - expected_rewrite).abs() / expected_rewrite < 1e-12); + assert_eq!( + recover_discrete_latent_mean_with_initial_time_independent_predictor( + 1e308, + 0.0, + 0.0, + 1e308, + 1.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn initial_time_dependent_predictor_recovers_driver_table_three_t0_shift() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let shift = + recover_initial_time_dependent_predictor_effect(effect, predictor).expect("t0-tdpred"); + let error = rmse(&[1.2], &[shift]); + assert!( + error < 1e-15, + "Driver Table 3 T0TDPREDEFFECT RMSE {error}: got {shift}" + ); + let carry = recover_initial_time_dependent_predictor_carry( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("t0-td-carry"); + let expected_carry = 1.2 * (drift * delta).exp(); + let carry_error = rmse(&[expected_carry], &[carry]); + assert!( + carry_error < 1e-15, + "Driver Eq. 3 first-summand T0TDPREDEFFECT carry RMSE {carry_error}: got {carry}" + ); + let increment = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("tipred"); + let intercept_effect = + recover_discrete_continuous_intercept_effect(effect, drift, delta, LagClock::EventTime) + .expect("cint"); + let impulse_carry = recover_time_dependent_predictor_impulse_carry( + effect, + predictor, + drift, + delta, + 1.0, + LagClock::EventTime, + ) + .expect("td-carry"); + assert!(rmse(&[carry], &[shift]) > rmse(&[expected_carry], &[carry])); + assert!(rmse(&[carry], &[increment]) > rmse(&[expected_carry], &[carry])); + assert!(rmse(&[shift], &[increment]) > rmse(&[1.2], &[shift])); + assert!(rmse(&[shift], &[intercept_effect]) > rmse(&[1.2], &[shift])); + assert!(rmse(&[shift], &[effect]) > rmse(&[1.2], &[shift])); + assert!(rmse(&[carry], &[impulse_carry]) > rmse(&[expected_carry], &[carry])); + let composed = recover_discrete_latent_mean_with_initial_time_dependent_predictor( + 1.0, + drift, + 0.3, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-t0tdpred"); + let evolved = + recover_discrete_latent_mean(1.0, drift, 0.3, delta, LagClock::EventTime).expect("mu-t"); + let composed_error = rmse(&[evolved + carry], &[composed]); + assert!( + composed_error < 1e-15, + "Driver Eq. 3 μ_t + e^{{A Δt}} t0_m x0 RMSE {composed_error}: got {composed}" + ); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("tdpred"); + let tipred_shift = + recover_initial_time_independent_predictor_effect(effect, predictor).expect("t0-tipred"); + assert_eq!( + refuse_initial_time_dependent_effect_as_contemporaneous_impulse(shift, impulse), + Err(PsychometricError::InitialTimeDependentEffectIsNotContemporaneousImpulse) + ); + assert_eq!( + refuse_initial_time_dependent_carry_as_initial_effect(carry, shift), + Err(PsychometricError::InitialTimeDependentCarryIsNotInitialEffect) + ); + assert_eq!( + refuse_initial_time_dependent_effect_as_continuous_intercept(shift, effect), + Err(PsychometricError::InitialTimeDependentEffectIsNotContinuousIntercept) + ); + assert_eq!( + refuse_initial_time_dependent_effect_as_process_increment(shift, increment), + Err(PsychometricError::InitialTimeDependentEffectIsNotProcessIncrement) + ); + assert_eq!( + refuse_initial_time_dependent_effect_as_initial_time_independent_effect( + shift, + tipred_shift + ), + Err(PsychometricError::InitialTimeDependentEffectIsNotInitialTimeIndependentEffect) + ); + assert_eq!( + refuse_initial_time_dependent_coefficient_as_initial_effect(effect, shift), + Err(PsychometricError::InitialTimeDependentCoefficientIsNotInitialEffect) + ); + assert_eq!( + refuse_initial_time_dependent_carry_as_impulse_carry(carry, impulse_carry), + Err(PsychometricError::InitialTimeDependentCarryIsNotImpulseCarry) + ); +} + +#[test] +fn initial_time_dependent_predictor_refuses_overflow_and_non_event_clocks() { + assert_eq!( + recover_initial_time_dependent_predictor_effect(1e308, 2.0), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_initial_time_dependent_predictor_carry(0.4, 3.0, -0.5, 2.0, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_initial_time_dependent_predictor_carry(0.4, 3.0, 1e308, 2.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_initial_time_dependent_predictor_carry(2.0, 0.5, 710.0, 1.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + let finite_rewrite = recover_initial_time_dependent_predictor_carry( + 1e-308, + 1.0, + 700.0, + 1.0, + LagClock::EventTime, + ) + .expect("t0-td-log-rewrite"); + let expected_rewrite = (1e-308_f64.ln() + 700.0).exp(); + assert!((finite_rewrite - expected_rewrite).abs() / expected_rewrite < 1e-12); + assert_eq!( + recover_discrete_latent_mean_with_initial_time_dependent_predictor( + 1e308, + 0.0, + 0.0, + 1e308, + 1.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); +} + +#[test] +fn discrete_observed_mean_with_initial_time_independent_predictor_recovers_driver_equation_five() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean_with_initial_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-t0tipred-mean"); + let composed = recover_discrete_latent_mean_with_initial_time_independent_predictor( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-t0tipred"); + let expected = manifest_mean + loading * composed; + let error = rmse(&[expected], &[observed]); + assert!( + error < 1e-15, + "Driver Eq. 5 of Table 3 T0TIPREDEFFECT RMSE {error}" + ); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let evolved_error = rmse(&[expected], &[evolved_observed]); + assert!( + evolved_error > error, + "τ + λ μ_t is not T0TIPREDEFFECT E(y_t): RMSE {evolved_error} must exceed {error}" + ); + let process_observed = recover_discrete_observed_mean_with_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-tipred-mean"); + let process_error = rmse(&[expected], &[process_observed]); + assert!( + process_error > error, + "τ + λ(μ_t + increment) is not T0TIPREDEFFECT E(y_t): RMSE {process_error} must exceed {error}" + ); +} + +#[test] +fn discrete_observed_mean_with_initial_time_independent_predictor_is_not_impulse_or_carry() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean_with_initial_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-t0tipred-mean"); + let composed = recover_discrete_latent_mean_with_initial_time_independent_predictor( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-t0tipred"); + let expected = manifest_mean + loading * composed; + let error = rmse(&[expected], &[observed]); + let impulse_observed = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + let impulse_error = rmse(&[expected], &[impulse_observed]); + assert!( + impulse_error > error, + "τ + λ(μ_t + m x) is not T0TIPREDEFFECT E(y_t): RMSE {impulse_error} must exceed {error}" + ); + let carried_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + 1.0, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + let carry_error = rmse(&[expected], &[carried_observed]); + assert!( + carry_error > error, + "τ + λ(μ_t + carry) is not T0TIPREDEFFECT E(y_t): RMSE {carry_error} must exceed {error}" + ); + let intercept_error = rmse(&[expected], &[manifest_mean]); + assert!( + intercept_error > error, + "MANIFESTMEANS is not T0TIPREDEFFECT E(y_t): RMSE {intercept_error} must exceed {error}" + ); + let latent_error = rmse(&[expected], &[composed]); + assert!( + latent_error > error, + "evolved-plus-T0TIPRED latent mean is not E(y_t): RMSE {latent_error} must exceed {error}" + ); +} + +#[test] +fn discrete_observed_mean_with_initial_time_independent_predictor_refuses_evolved_process_impulse_and_carry() + { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean_with_initial_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-t0tipred-mean"); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let process_observed = recover_discrete_observed_mean_with_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-tipred-mean"); + let impulse_observed = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + let carried_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + 1.0, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + assert_eq!( + refuse_evolved_observed_mean_as_initial_time_independent_observed_mean( + evolved_observed, + observed + ), + Err(PsychometricError::EvolvedObservedMeanIsNotInitialTimeIndependentObservedMean) + ); + assert_eq!( + refuse_time_independent_observed_mean_as_initial_time_independent_observed_mean( + process_observed, + observed + ), + Err(PsychometricError::TimeIndependentObservedMeanIsNotInitialTimeIndependentObservedMean) + ); + assert_eq!( + refuse_impulse_observed_mean_as_initial_time_independent_observed_mean( + impulse_observed, + observed + ), + Err(PsychometricError::ImpulseObservedMeanIsNotInitialTimeIndependentObservedMean) + ); + assert_eq!( + refuse_impulse_carry_observed_mean_as_initial_time_independent_observed_mean( + carried_observed, + observed + ), + Err(PsychometricError::ImpulseCarryObservedMeanIsNotInitialTimeIndependentObservedMean) + ); +} + +#[test] +fn discrete_observed_mean_with_initial_time_independent_predictor_zero_loading_is_manifest_mean() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean_with_initial_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-t0tipred-mean"); + let composed = recover_discrete_latent_mean_with_initial_time_independent_predictor( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-t0tipred"); + assert_eq!( + refuse_latent_mean_as_observed_mean(composed, observed), + Err(PsychometricError::LatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_manifest_means_as_observed_mean(manifest_mean, observed), + Err(PsychometricError::ManifestMeansIsNotObservedMean) + ); + let zero_loading = recover_discrete_observed_mean_with_initial_time_independent_predictor( + 0.0, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("lambda0"); + assert!((zero_loading - manifest_mean).abs() < 1e-15); +} + +#[test] +fn discrete_observed_mean_with_initial_time_independent_predictor_refuses_overflow_and_non_event_clocks() + { + assert_eq!( + recover_discrete_observed_mean_with_initial_time_independent_predictor( + 1e308, + 2.0, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_observed_mean_with_initial_time_independent_predictor( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_observed_mean_with_initial_time_independent_predictor( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + let scaled = recover_discrete_observed_mean_with_initial_time_independent_predictor( + 1e308, + 1e-308, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + LagClock::EventTime, + ) + .expect("scale"); + assert!( + (scaled - 1.0).abs() < 1e-15, + "Driver Eq. 5 of Table 3 T0TIPREDEFFECT must keep λ=1e308, μ=1e-308: got {scaled}" + ); +} + +#[test] +fn discrete_observed_mean_with_time_independent_predictor_recovers_driver_equation_five() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean_with_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-tipred-mean"); + let composed = recover_discrete_latent_mean_with_time_independent_predictor( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-tipred"); + let expected = manifest_mean + loading * composed; + let error = rmse(&[expected], &[observed]); + assert!( + error < 1e-15, + "Driver Eq. 5 of Eq. 3 TIPREDEFFECT RMSE {error}" + ); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let evolved_error = rmse(&[expected], &[evolved_observed]); + assert!( + evolved_error > error, + "τ + λ μ_t is not TIPREDEFFECT E(y_t): RMSE {evolved_error} must exceed {error}" + ); + let intercept_error = rmse(&[expected], &[manifest_mean]); + assert!( + intercept_error > error, + "MANIFESTMEANS is not TIPREDEFFECT E(y_t): RMSE {intercept_error} must exceed {error}" + ); + let latent_error = rmse(&[expected], &[composed]); + assert!( + latent_error > error, + "evolved-plus-increment latent mean is not E(y_t): RMSE {latent_error} must exceed {error}" + ); + let impulse_observed = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + let impulse_error = rmse(&[expected], &[impulse_observed]); + assert!( + impulse_error > error, + "τ + λ(μ_t + m x) is not TIPREDEFFECT E(y_t): RMSE {impulse_error} must exceed {error}" + ); + let carried_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + 1.0, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + let carry_error = rmse(&[expected], &[carried_observed]); + assert!( + carry_error > error, + "τ + λ(μ_t + carry) is not TIPREDEFFECT E(y_t): RMSE {carry_error} must exceed {error}" + ); +} + +#[test] +fn discrete_observed_mean_with_time_independent_predictor_refuses_evolved_mean_impulse_and_carry() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean_with_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-tipred-mean"); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let impulse_observed = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + let carried_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + 1.0, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + assert_eq!( + refuse_evolved_observed_mean_as_time_independent_observed_mean(evolved_observed, observed), + Err(PsychometricError::EvolvedObservedMeanIsNotTimeIndependentObservedMean) + ); + assert_eq!( + refuse_impulse_observed_mean_as_time_independent_observed_mean(impulse_observed, observed), + Err(PsychometricError::ImpulseObservedMeanIsNotTimeIndependentObservedMean) + ); + assert_eq!( + refuse_impulse_carry_observed_mean_as_time_independent_observed_mean( + carried_observed, + observed + ), + Err(PsychometricError::ImpulseCarryObservedMeanIsNotTimeIndependentObservedMean) + ); +} + +#[test] +fn discrete_observed_mean_with_time_independent_predictor_zero_loading_is_manifest_mean() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean_with_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-tipred-mean"); + let composed = recover_discrete_latent_mean_with_time_independent_predictor( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-tipred"); + let expected = manifest_mean + loading * composed; + let error = rmse(&[expected], &[observed]); + let zero_loading = recover_discrete_observed_mean_with_time_independent_predictor( + 0.0, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("lambda0"); + let dropped_error = rmse(&[expected], &[zero_loading]); + assert!( + dropped_error > error, + "zero loading is τ, not τ + λ(μ_t + increment): RMSE {dropped_error} must exceed {error}" + ); + assert_eq!( + refuse_latent_mean_as_observed_mean(composed, observed), + Err(PsychometricError::LatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_manifest_means_as_observed_mean(manifest_mean, observed), + Err(PsychometricError::ManifestMeansIsNotObservedMean) + ); +} + +#[test] +fn discrete_observed_mean_with_time_independent_predictor_refuses_overflow_and_non_event_clocks() { + assert_eq!( + recover_discrete_observed_mean_with_time_independent_predictor( + 1e308, + 2.0, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_observed_mean_with_time_independent_predictor( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_observed_mean_with_time_independent_predictor( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_observed_mean_with_time_independent_predictor( + 1e308, + 0.0, + 0.0, + 0.0, + 1e308, + 1.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + let scaled = recover_discrete_observed_mean_with_time_independent_predictor( + 1e308, + 1e-308, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + LagClock::EventTime, + ) + .expect("scale"); + assert!( + (scaled - 1.0).abs() < 1e-15, + "Driver Eq. 5 of Eq. 3 TIPREDEFFECT must keep λ=1e308, μ=1e-308: got {scaled}" + ); +} + +#[test] +fn time_dependent_impulse_carry_recovers_driver_equation_one_two_dissipation() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let elapsed = 1.0_f64; + let carry = recover_time_dependent_predictor_impulse_carry( + effect, + predictor, + drift, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("tdpred-carry"); + let expected = (-0.5_f64).exp() * 1.2; + let error = rmse(&[expected], &[carry]); + assert!( + error < 1e-15, + "Driver Eq. 1–2 impulse carry RMSE {error}: got {carry}" + ); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("tdpred"); + let intercept_effect = + recover_discrete_continuous_intercept_effect(effect, drift, delta, LagClock::EventTime) + .expect("cint"); + let time_independent = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("tipred"); + let equation_fourteen = recover_discrete_time_varying_predictor_effect( + effect, + delta, + delta, + delta, + LagClock::EventTime, + ) + .expect("eq14"); + assert!(rmse(&[carry], &[impulse]) > rmse(&[expected], &[carry])); + assert!(rmse(&[carry], &[intercept_effect]) > rmse(&[expected], &[carry])); + assert!(rmse(&[carry], &[time_independent]) > rmse(&[expected], &[carry])); + assert!(rmse(&[carry], &[equation_fourteen]) > rmse(&[expected], &[carry])); + let composed = recover_discrete_latent_mean_with_impulse_carry( + 1.0, + drift, + 0.3, + effect, + predictor, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("eq3-carry"); + let evolved = + recover_discrete_latent_mean(1.0, drift, 0.3, delta, LagClock::EventTime).expect("mu-t"); + let composed_error = rmse(&[evolved + carry], &[composed]); + assert!( + composed_error < 1e-15, + "Driver Eq. 1–2 μ_t + e^{{A(t−u)}} M x RMSE {composed_error}: got {composed}" + ); + assert_eq!( + refuse_time_dependent_impulse_carry_as_contemporaneous_impulse(carry, impulse), + Err(PsychometricError::TimeDependentImpulseCarryIsNotContemporaneousImpulse) + ); + assert_eq!( + refuse_time_dependent_impulse_carry_as_continuous_intercept(carry, effect), + Err(PsychometricError::TimeDependentImpulseCarryIsNotContinuousIntercept) + ); + assert_eq!( + refuse_time_dependent_impulse_carry_as_time_independent_effect(carry, time_independent), + Err(PsychometricError::TimeDependentImpulseCarryIsNotTimeIndependentEffect) + ); + assert_eq!( + refuse_time_dependent_impulse_carry_as_time_varying_discrete_effect( + carry, + equation_fourteen + ), + Err(PsychometricError::TimeDependentImpulseCarryIsNotTimeVaryingDiscreteEffect) + ); +} + +#[test] +fn time_dependent_impulse_carry_refuses_overflow_and_non_event_clocks() { + assert_eq!( + recover_time_dependent_predictor_impulse_carry( + 1e308, + 2.0, + -0.5, + 2.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_time_dependent_predictor_impulse_carry( + 0.4, + 3.0, + -0.5, + 2.0, + 1.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_time_dependent_predictor_impulse_carry( + 0.4, + 3.0, + -0.5, + 2.0, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_latent_mean_with_impulse_carry( + 1e308, + 0.0, + 0.0, + 1e308, + 1.0, + 2.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_time_dependent_predictor_impulse_carry( + 0.4, + 3.0, + 1e308, + 3.0, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); +} + +#[test] +fn discrete_observed_mean_with_impulse_carry_recovers_driver_equation_five() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let elapsed = 1.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + let carried = recover_discrete_latent_mean_with_impulse_carry( + initial, + drift, + intercept, + effect, + predictor, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("carried"); + let expected = manifest_mean + loading * carried; + let error = rmse(&[expected], &[observed]); + assert!( + error < 1e-15, + "Driver Eq. 5 of Eq. 1–2 impulse carry RMSE {error}" + ); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let evolved_error = rmse(&[expected], &[evolved_observed]); + assert!( + evolved_error > error, + "τ + λ μ_t is not impulse-carry E(y_t): RMSE {evolved_error} must exceed {error}" + ); + let intercept_error = rmse(&[expected], &[manifest_mean]); + assert!( + intercept_error > error, + "MANIFESTMEANS is not impulse-carry E(y_t): RMSE {intercept_error} must exceed {error}" + ); + let latent_error = rmse(&[expected], &[carried]); + assert!( + latent_error > error, + "carried latent mean is not E(y_t): RMSE {latent_error} must exceed {error}" + ); +} + +#[test] +fn discrete_observed_mean_with_impulse_carry_refuses_evolved_mean_and_contemporaneous() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let elapsed = 1.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + let carried = recover_discrete_latent_mean_with_impulse_carry( + initial, + drift, + intercept, + effect, + predictor, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("carried"); + let expected = manifest_mean + loading * carried; + let error = rmse(&[expected], &[observed]); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let contemporaneous = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-mx"); + let contemporaneous_error = rmse(&[expected], &[contemporaneous]); + assert!( + contemporaneous_error > error, + "τ + λ(μ_t + m x) is not impulse-carry E(y_t): RMSE {contemporaneous_error} must exceed {error}" + ); + let zero_loading = recover_discrete_observed_mean_with_impulse_carry( + 0.0, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("lambda0"); + let dropped_error = rmse(&[expected], &[zero_loading]); + assert!( + dropped_error > error, + "zero loading is τ, not τ + λ(μ_t + carry): RMSE {dropped_error} must exceed {error}" + ); + assert_eq!( + refuse_evolved_observed_mean_as_impulse_carry_observed_mean(evolved_observed, observed), + Err(PsychometricError::EvolvedObservedMeanIsNotImpulseCarryObservedMean) + ); + assert_eq!( + refuse_impulse_observed_mean_as_impulse_carry_observed_mean(contemporaneous, observed), + Err(PsychometricError::ImpulseObservedMeanIsNotImpulseCarryObservedMean) + ); + assert_eq!( + refuse_latent_mean_as_observed_mean(carried, observed), + Err(PsychometricError::LatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_manifest_means_as_observed_mean(manifest_mean, observed), + Err(PsychometricError::ManifestMeansIsNotObservedMean) + ); +} + +#[test] +fn discrete_observed_mean_with_impulse_carry_refuses_overflow_and_non_event_clocks() { + assert_eq!( + recover_discrete_observed_mean_with_impulse_carry( + 1e308, + 2.0, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 2.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_observed_mean_with_impulse_carry( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + 1.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_observed_mean_with_impulse_carry( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_observed_mean_with_impulse_carry( + 1e308, + 0.0, + 0.0, + 0.0, + 1e308, + 1.0, + 0.0, + 2.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + let scaled = recover_discrete_observed_mean_with_impulse_carry( + 1e308, + 1e-308, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 2.0, + 1.0, + LagClock::EventTime, + ) + .expect("scale"); + assert!( + (scaled - 1.0).abs() < 1e-15, + "Driver Eq. 5 of Eq. 1–2 carry must keep λ=1e308, μ=1e-308: got {scaled}" + ); +} + +#[test] +fn admitted_coordinates_still_required_for_multilevel_weights() { + assert_eq!( + recover_kish_weighted_slope(&[0.0, 1.0], &[0.2, 0.3], &[1.0, 1.0]), + ordinary_least_squares_slope(&[0.0, 1.0], &[0.2, 0.3]) + ); + let _ = IndicatorKind::AdditiveLogRatio; +} + +#[test] +fn discrete_observed_mean_with_initial_time_dependent_predictor_recovers_driver_equation_five() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean_with_initial_time_dependent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-t0tdpred-mean"); + let composed = recover_discrete_latent_mean_with_initial_time_dependent_predictor( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-t0tdpred"); + let expected = manifest_mean + loading * composed; + let error = rmse(&[expected], &[observed]); + assert!( + error < 1e-15, + "Driver Eq. 5 of Table 3 T0TDPREDEFFECT RMSE {error}" + ); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let evolved_error = rmse(&[expected], &[evolved_observed]); + assert!( + evolved_error > error, + "τ + λ μ_t is not T0TDPREDEFFECT E(y_t): RMSE {evolved_error} must exceed {error}" + ); + let process_observed = recover_discrete_observed_mean_with_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-tipred-mean"); + let process_error = rmse(&[expected], &[process_observed]); + assert!( + process_error > error, + "τ + λ(μ_t + increment) is not T0TDPREDEFFECT E(y_t): RMSE {process_error} must exceed {error}" + ); +} + +#[test] +fn discrete_observed_mean_with_initial_time_dependent_predictor_is_not_impulse_or_carry() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean_with_initial_time_dependent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-t0tdpred-mean"); + let composed = recover_discrete_latent_mean_with_initial_time_dependent_predictor( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-t0tdpred"); + let expected = manifest_mean + loading * composed; + let error = rmse(&[expected], &[observed]); + let impulse_observed = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + let impulse_error = rmse(&[expected], &[impulse_observed]); + assert!( + impulse_error > error, + "τ + λ(μ_t + m x) is not T0TDPREDEFFECT E(y_t): RMSE {impulse_error} must exceed {error}" + ); + let carried_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + 1.0, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + let carry_error = rmse(&[expected], &[carried_observed]); + assert!( + carry_error > error, + "τ + λ(μ_t + carry) is not T0TDPREDEFFECT E(y_t): RMSE {carry_error} must exceed {error}" + ); + let intercept_error = rmse(&[expected], &[manifest_mean]); + assert!( + intercept_error > error, + "MANIFESTMEANS is not T0TDPREDEFFECT E(y_t): RMSE {intercept_error} must exceed {error}" + ); + let latent_error = rmse(&[expected], &[composed]); + assert!( + latent_error > error, + "evolved-plus-T0TDPRED latent mean is not E(y_t): RMSE {latent_error} must exceed {error}" + ); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn discrete_observed_mean_with_initial_time_dependent_predictor_refuses_evolved_process_impulse_and_carry() + { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean_with_initial_time_dependent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-t0tdpred-mean"); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let process_observed = recover_discrete_observed_mean_with_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-tipred-mean"); + let impulse_observed = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + let carried_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + 1.0, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + let tipred_observed = recover_discrete_observed_mean_with_initial_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-t0tipred-mean"); + assert_eq!( + refuse_evolved_observed_mean_as_initial_time_dependent_observed_mean( + evolved_observed, + observed + ), + Err(PsychometricError::EvolvedObservedMeanIsNotInitialTimeDependentObservedMean) + ); + assert_eq!( + refuse_time_independent_observed_mean_as_initial_time_dependent_observed_mean( + process_observed, + observed + ), + Err(PsychometricError::TimeIndependentObservedMeanIsNotInitialTimeDependentObservedMean) + ); + assert_eq!( + refuse_impulse_observed_mean_as_initial_time_dependent_observed_mean( + impulse_observed, + observed + ), + Err(PsychometricError::ImpulseObservedMeanIsNotInitialTimeDependentObservedMean) + ); + assert_eq!( + refuse_impulse_carry_observed_mean_as_initial_time_dependent_observed_mean( + carried_observed, + observed + ), + Err(PsychometricError::ImpulseCarryObservedMeanIsNotInitialTimeDependentObservedMean) + ); + assert_eq!( + refuse_initial_time_independent_observed_mean_as_initial_time_dependent_observed_mean( + tipred_observed, + observed + ), + Err(PsychometricError::InitialTimeIndependentObservedMeanIsNotInitialTimeDependentObservedMean) + ); +} + +#[test] +fn discrete_observed_mean_with_initial_time_dependent_predictor_zero_loading_is_manifest_mean() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean_with_initial_time_dependent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-t0tdpred-mean"); + let composed = recover_discrete_latent_mean_with_initial_time_dependent_predictor( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-t0tdpred"); + assert_eq!( + refuse_latent_mean_as_observed_mean(composed, observed), + Err(PsychometricError::LatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_manifest_means_as_observed_mean(manifest_mean, observed), + Err(PsychometricError::ManifestMeansIsNotObservedMean) + ); + let zero_loading = recover_discrete_observed_mean_with_initial_time_dependent_predictor( + 0.0, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("lambda0"); + assert!((zero_loading - manifest_mean).abs() < 1e-15); +} + +#[test] +fn discrete_observed_mean_with_initial_time_dependent_predictor_refuses_overflow_and_non_event_clocks() + { + assert_eq!( + recover_discrete_observed_mean_with_initial_time_dependent_predictor( + 1e308, + 2.0, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_observed_mean_with_initial_time_dependent_predictor( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 2.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_observed_mean_with_initial_time_dependent_predictor( + 2.0, + 1.0, + -0.5, + 0.3, + 0.4, + 3.0, + 0.5, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + let scaled = recover_discrete_observed_mean_with_initial_time_dependent_predictor( + 1e308, + 1e-308, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + LagClock::EventTime, + ) + .expect("scale"); + assert!( + (scaled - 1.0).abs() < 1e-15, + "Driver Eq. 5 of Table 3 T0TDPREDEFFECT must keep λ=1e308, μ=1e-308: got {scaled}" + ); +} + +#[test] +fn level_change_continuous_intercept_recovers_driver_section_seven_point_two() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let intercept = + recover_level_change_continuous_intercept(effect, predictor, drift).expect("level-change"); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("impulse"); + let error = rmse(&[0.6], &[intercept]); + assert!( + error < 1e-15, + "Driver §7.2 level-change CINT RMSE {error}: got {intercept}" + ); + let equilibrium_error = rmse(&[impulse], &[intercept / (-drift)]); + assert!( + equilibrium_error < 1e-15, + "Driver §7.2 −κ/a must recover m x: RMSE {equilibrium_error}" + ); + assert!(rmse(&[intercept], &[impulse]) > error); + let increment = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + drift, + 2.0, + LagClock::EventTime, + ) + .expect("tipred"); + assert_eq!( + refuse_level_change_intercept_as_impulse(intercept, impulse), + Err(PsychometricError::LevelChangeInterceptIsNotImpulse) + ); + assert_eq!( + refuse_level_change_intercept_as_free_continuous_intercept(intercept, 0.3), + Err(PsychometricError::LevelChangeInterceptIsNotFreeContinuousIntercept) + ); + assert_eq!( + refuse_level_change_intercept_as_process_increment(intercept, increment), + Err(PsychometricError::LevelChangeInterceptIsNotProcessIncrement) + ); +} + +#[test] +fn level_change_continuous_intercept_refuses_unstable_drift_and_overflow() { + assert_eq!( + recover_level_change_continuous_intercept(0.4, 3.0, 0.0), + Err(PsychometricError::LevelChangeRequiresStableDrift) + ); + assert_eq!( + recover_level_change_continuous_intercept(0.4, 3.0, 0.5), + Err(PsychometricError::LevelChangeRequiresStableDrift) + ); + assert_eq!( + recover_level_change_continuous_intercept(1e308, 2.0, -0.5), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_level_change_continuous_intercept(1.0, 2.0, -1e308), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_level_change_continuous_intercept(0.0, 3.0, 0.0), + Ok(0.0) + ); +} + +#[test] +fn level_change_discrete_increment_recovers_driver_equation_three_of_section_seven_point_two() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let increment = recover_level_change_discrete_increment( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("level-change-increment"); + let intercept = + recover_level_change_continuous_intercept(effect, predictor, drift).expect("level-change"); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("impulse"); + let expected = (1.0 - (drift * delta).exp()) * impulse; + let error = rmse(&[expected], &[increment]); + assert!( + error < 1e-15, + "Driver §7.2 Eq. 3 level-change increment RMSE {error}: got {increment}" + ); + assert!(rmse(&[increment], &[impulse]) > error); + assert!(rmse(&[increment], &[intercept]) > error); + let tipred = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("tipred"); + assert_eq!( + refuse_level_change_increment_as_impulse(increment, impulse), + Err(PsychometricError::LevelChangeIncrementIsNotImpulse) + ); + assert_eq!( + refuse_level_change_increment_as_intercept(increment, intercept), + Err(PsychometricError::LevelChangeIncrementIsNotIntercept) + ); + assert_eq!( + refuse_level_change_increment_as_process_increment(increment, tipred), + Err(PsychometricError::LevelChangeIncrementIsNotProcessIncrement) + ); + let equilibrated = recover_level_change_discrete_increment( + effect, + predictor, + -800.0, + 1.0, + LagClock::EventTime, + ) + .expect("underflow"); + assert!( + rmse(&[impulse], &[equilibrated]) < 1e-15, + "underflow of e^{{aΔt}} must keep m x: got {equilibrated}" + ); +} + +#[test] +fn level_change_discrete_increment_refuses_unstable_drift_clock_and_overflow() { + assert_eq!( + recover_level_change_discrete_increment(0.4, 3.0, 0.0, 2.0, LagClock::EventTime), + Err(PsychometricError::LevelChangeRequiresStableDrift) + ); + assert_eq!( + recover_level_change_discrete_increment(0.4, 3.0, 0.5, 2.0, LagClock::EventTime), + Err(PsychometricError::LevelChangeRequiresStableDrift) + ); + assert_eq!( + recover_level_change_discrete_increment(0.4, 3.0, -0.5, 2.0, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_level_change_discrete_increment(0.4, 3.0, -0.5, 0.0, LagClock::EventTime), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_level_change_discrete_increment(1e308, 2.0, -0.5, 2.0, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_level_change_discrete_increment(0.0, 3.0, 0.0, 2.0, LagClock::EventTime), + Ok(0.0) + ); +} + +#[test] +fn extra_process_contribution_recovers_driver_section_seven_point_two() { + let coupling = 0.569_907_f64; + let predictor = 1.0_f64; + let original = -0.1393_f64; + let extra = -0.000_001_f64; + let delta = 1.0_f64; + let recovered = recover_level_change_extra_process_contribution( + coupling, + predictor, + original, + extra, + delta, + LagClock::EventTime, + ) + .expect("extra-process"); + let expected = coupling * predictor * ((extra * delta).exp() - (original * delta).exp()) + / (extra - original); + assert!( + rmse(&[expected], &[recovered]) < 1e-15, + "Driver et al. (2017, §7.2 pp. 22–23) extra-process map: expected {expected}, got {recovered}" + ); + let intercept = + recover_level_change_continuous_intercept(coupling, predictor, original).expect("cint"); + let increment = recover_level_change_discrete_increment( + coupling, + predictor, + original, + delta, + LagClock::EventTime, + ) + .expect("increment"); + let impulse = recover_time_dependent_predictor_impulse(coupling, predictor).expect("impulse"); + let distinction = recover_level_change_extra_process_contribution( + 0.4, + 3.0, + -0.5, + -0.05, + 2.0, + LagClock::EventTime, + ) + .expect("distinction"); + let distinct_intercept = + recover_level_change_continuous_intercept(0.4, 3.0, -0.5).expect("distinct-cint"); + let distinct_increment = + recover_level_change_discrete_increment(0.4, 3.0, -0.5, 2.0, LagClock::EventTime) + .expect("distinct-increment"); + let distinct_impulse = recover_time_dependent_predictor_impulse(0.4, 3.0).expect("dirac"); + assert!(rmse(&[distinct_intercept], &[distinction]) > 1e-3); + assert!(rmse(&[distinct_increment], &[distinction]) > 1e-3); + assert!(rmse(&[distinct_impulse], &[distinction]) > 1e-3); + assert_eq!( + refuse_level_change_extra_process_as_impulse(recovered, impulse), + Err(PsychometricError::LevelChangeExtraProcessIsNotImpulse) + ); + assert_eq!( + refuse_level_change_extra_process_as_intercept(recovered, intercept), + Err(PsychometricError::LevelChangeExtraProcessIsNotIntercept) + ); + assert_eq!( + refuse_level_change_extra_process_as_increment(recovered, increment), + Err(PsychometricError::LevelChangeExtraProcessIsNotIncrement) + ); +} + +#[test] +fn extra_process_contribution_refuses_nonnegative_extra_drift_clock_and_overflow() { + assert_eq!( + recover_level_change_extra_process_contribution( + 0.4, + 3.0, + -0.5, + 0.0, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::LevelChangeExtraProcessRequiresNegativeDrift) + ); + assert_eq!( + recover_level_change_extra_process_contribution( + 0.4, + 3.0, + -0.5, + 0.5, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::LevelChangeExtraProcessRequiresNegativeDrift) + ); + assert_eq!( + recover_level_change_extra_process_contribution( + 0.4, + 3.0, + -0.5, + -0.000_001, + 2.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_level_change_extra_process_contribution( + 0.4, + 3.0, + -0.5, + -0.000_001, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_level_change_extra_process_contribution( + 1e308, + 2.0, + -0.5, + -0.000_001, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_level_change_extra_process_contribution( + 0.0, + 3.0, + -0.5, + 0.0, + 2.0, + LagClock::EventTime + ), + Ok(0.0) + ); + let overflow_fallback = recover_level_change_extra_process_contribution( + 0.4, + 3.0, + -0.8, + -0.000_001, + 900.0, + LagClock::EventTime, + ) + .expect("expm1-overflow-fallback"); + assert!(overflow_fallback.is_finite()); +} + +#[test] +fn extra_process_observed_mean_recovers_driver_equation_five_of_section_seven_point_two() { + let loading = 1.0_f64; + let coupling = 0.569_907_f64; + let predictor = 1.0_f64; + let original = -0.1393_f64; + let extra = -0.000_001_f64; + let delta = 1.0_f64; + let initial = 0.0_f64; + let intercept = 0.0_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean_with_extra_process( + loading, + initial, + original, + intercept, + coupling, + predictor, + extra, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-extra-process-mean"); + let composed = recover_discrete_latent_mean_with_extra_process( + initial, + original, + intercept, + coupling, + predictor, + extra, + delta, + LagClock::EventTime, + ) + .expect("extra-latent"); + let contribution = recover_level_change_extra_process_contribution( + coupling, + predictor, + original, + extra, + delta, + LagClock::EventTime, + ) + .expect("extra-process"); + let expected = manifest_mean + loading * composed; + let error = rmse(&[expected], &[observed]); + assert!( + error < 1e-15, + "Driver Eq. 5 of §7.2 extra-process contribution RMSE {error}: got {observed}" + ); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + original, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + assert!( + rmse(&[expected], &[evolved_observed]) > error, + "τ + λ μ_t is not extra-process E(y_t)" + ); + let impulse_observed = recover_discrete_observed_mean_with_impulse( + loading, + initial, + original, + intercept, + coupling, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + assert!( + rmse(&[expected], &[impulse_observed]) > error, + "τ + λ(μ_t + m x) is not extra-process E(y_t)" + ); + assert!(rmse(&[expected], &[manifest_mean]) > error); + assert!(rmse(&[expected], &[composed]) > error); + assert!(rmse(&[expected], &[contribution]) > error); + assert_eq!( + refuse_evolved_observed_mean_as_extra_process_observed_mean(evolved_observed, observed), + Err(PsychometricError::EvolvedObservedMeanIsNotExtraProcessObservedMean) + ); + assert_eq!( + refuse_impulse_observed_mean_as_extra_process_observed_mean(impulse_observed, observed), + Err(PsychometricError::ImpulseObservedMeanIsNotExtraProcessObservedMean) + ); + assert_eq!( + refuse_extra_process_contribution_as_observed_mean(contribution, observed), + Err(PsychometricError::ExtraProcessContributionIsNotObservedMean) + ); + assert_eq!( + refuse_extra_process_latent_mean_as_observed_mean(composed, observed), + Err(PsychometricError::ExtraProcessLatentMeanIsNotObservedMean) + ); +} + +#[test] +fn extra_process_observed_mean_zero_loading_is_manifest_mean() { + let coupling = 0.4_f64; + let predictor = 3.0_f64; + let original = -0.5_f64; + let extra = -0.05_f64; + let delta = 2.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let zero_loading = recover_discrete_observed_mean_with_extra_process( + 0.0, + initial, + original, + intercept, + coupling, + predictor, + extra, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("zero-loading"); + assert!( + rmse(&[manifest_mean], &[zero_loading]) < 1e-15, + "zero original-indicator loading is τ: got {zero_loading}" + ); + let extra_loading_zero = recover_manifest_observed_mean(0.0, 12.0, manifest_mean) + .expect("extra-process-lambda-zero"); + let original_observed = recover_discrete_observed_mean_with_extra_process( + 2.0, + initial, + original, + intercept, + coupling, + predictor, + extra, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("original-indicator"); + assert!( + rmse(&[extra_loading_zero], &[original_observed]) > 1e-3, + "printed extra-process LAMBDA 0 is τ, not original-indicator E(y_t)" + ); + let zero_coupling = recover_discrete_observed_mean_with_extra_process( + 2.0, + initial, + original, + intercept, + 0.0, + predictor, + extra, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("zero-coupling"); + let evolved_observed = recover_discrete_observed_mean( + 2.0, + initial, + original, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + assert!(rmse(&[evolved_observed], &[zero_coupling]) < 1e-15); +} + +#[test] +fn extra_process_observed_mean_refuses_clock_nonpositive_interval_and_nonnegative_drift() { + let coupling = 0.4_f64; + let predictor = 3.0_f64; + let original = -0.5_f64; + let extra = -0.05_f64; + let delta = 2.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + assert_eq!( + recover_discrete_observed_mean_with_extra_process( + 2.0, + initial, + original, + intercept, + coupling, + predictor, + extra, + manifest_mean, + delta, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_observed_mean_with_extra_process( + 2.0, + initial, + original, + intercept, + coupling, + predictor, + 0.0, + manifest_mean, + delta, + LagClock::EventTime + ), + Err(PsychometricError::LevelChangeExtraProcessRequiresNegativeDrift) + ); + assert_eq!( + recover_discrete_observed_mean_with_extra_process( + 2.0, + initial, + original, + intercept, + coupling, + predictor, + extra, + manifest_mean, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_latent_mean_with_extra_process( + 1e308, + 0.0, + 0.0, + 1e308, + 1.0, + extra, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn after_extra_process_observed_mean_recovers_driver_equation_five_after_t0() { + let loading = 1.0_f64; + let coupling = 1.0_f64; + let predictor = 1.0_f64; + let original = -0.4_f64; + let extra = -0.000_001_f64; + let delta = 2.0_f64; + let elapsed = 1.0_f64; + let initial = 0.0_f64; + let intercept = 0.0_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean_with_extra_process_after( + loading, + initial, + original, + intercept, + coupling, + predictor, + extra, + manifest_mean, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("eq5-after-extra-process-mean"); + let composed = recover_discrete_latent_mean_with_extra_process_after( + initial, + original, + intercept, + coupling, + predictor, + extra, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("after-extra-latent"); + let contribution = recover_level_change_extra_process_contribution_after( + coupling, + predictor, + original, + extra, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("after-extra-process"); + let expected = manifest_mean + loading * composed; + let error = rmse(&[expected], &[observed]); + assert!( + error < 1e-15, + "Driver Eq. 5 of §7.2 after-t0 extra-process RMSE {error}: got {observed}" + ); + let first_occasion = recover_discrete_observed_mean_with_extra_process( + loading, + initial, + original, + intercept, + coupling, + predictor, + extra, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-t0-extra-process-mean"); + assert!( + rmse(&[expected], &[first_occasion]) > error, + "T0TDPREDEFFECT extra-process E(y_t) is not after-t0 E(y_t)" + ); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + original, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + assert!(rmse(&[expected], &[evolved_observed]) > error); + let carry_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + original, + intercept, + coupling, + predictor, + manifest_mean, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("eq5-impulse-carry-mean"); + assert!( + rmse(&[expected], &[carry_observed]) > error, + "e^{{a(t-u)}} m x is not extra-process DRIFT drive" + ); + assert!(rmse(&[expected], &[manifest_mean]) > error); + assert!(rmse(&[expected], &[composed]) > error); + assert!(rmse(&[expected], &[contribution]) > error); + assert_eq!( + refuse_extra_process_observed_mean_as_after_extra_process_observed_mean( + first_occasion, + observed + ), + Err(PsychometricError::ExtraProcessObservedMeanIsNotAfterExtraProcessObservedMean) + ); + assert_eq!( + refuse_evolved_observed_mean_as_after_extra_process_observed_mean( + evolved_observed, + observed + ), + Err(PsychometricError::EvolvedObservedMeanIsNotAfterExtraProcessObservedMean) + ); + assert_eq!( + refuse_impulse_carry_observed_mean_as_after_extra_process_observed_mean( + carry_observed, + observed + ), + Err(PsychometricError::ImpulseCarryObservedMeanIsNotAfterExtraProcessObservedMean) + ); + assert_eq!( + refuse_after_extra_process_contribution_as_observed_mean(contribution, observed), + Err(PsychometricError::AfterExtraProcessContributionIsNotObservedMean) + ); + assert_eq!( + refuse_after_extra_process_latent_mean_as_observed_mean(composed, observed), + Err(PsychometricError::AfterExtraProcessLatentMeanIsNotObservedMean) + ); +} + +#[test] +fn after_extra_process_observed_mean_refuses_non_interior_interval_and_clock() { + let coupling = 1.0_f64; + let predictor = 1.0_f64; + let original = -0.4_f64; + let extra = -0.000_001_f64; + assert_eq!( + recover_level_change_extra_process_contribution_after( + coupling, + predictor, + original, + extra, + 2.0, + 2.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_observed_mean_with_extra_process_after( + 1.0, + 0.0, + original, + 0.0, + coupling, + predictor, + extra, + 0.5, + 2.0, + 1.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_discrete_observed_mean_with_extra_process_after( + 0.0, + 0.0, + original, + 0.0, + coupling, + predictor, + extra, + 0.5, + 2.0, + 1.0, + LagClock::EventTime + ), + Ok(0.5) + ); +} + +#[test] +fn asymptotic_time_independent_effect_recovers_driver_section_seven_point_two() { + // Driver et al. (2017, §7.2, p. 21) print LeisureTime + // TIPREDEFFECT = −0.225 and asymTIPREDEFFECT = −1.673 for a unit + // increase. Reconstruct a = −B / asym. + let effect = -0.225_f64; + let predictor = 1.0_f64; + let printed_asym = -1.673_f64; + let log_rate = -effect / printed_asym; + let recovered = recover_asymptotic_time_independent_predictor_effect( + effect, + predictor, + log_rate, + LagClock::EventTime, + ) + .expect("asymTIPREDEFFECT"); + let expected = -(effect * predictor) / log_rate; + let error = rmse(&[expected], &[recovered]); + assert!( + error < 1e-15, + "Driver §7.2 asymTIPREDEFFECT RMSE {error}: got {recovered}" + ); + assert!( + rmse(&[printed_asym], &[recovered]) < 1e-12, + "printed LeisureTime asymTIPREDEFFECT" + ); + let discrete = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + log_rate, + 1.0, + LagClock::EventTime, + ) + .expect("discreteTIPREDEFFECT"); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("impulse"); + assert!( + rmse(&[recovered], &[effect]) > error, + "TIPREDEFFECT B is not asymTIPREDEFFECT" + ); + assert!( + rmse(&[recovered], &[discrete]) > error, + "A^{{-1}}[e^{{A Δt}} − I] B z is not -B z / a" + ); + assert!(rmse(&[recovered], &[impulse]) > error); + let happiness = recover_asymptotic_time_independent_predictor_effect( + 0.549, + 1.0, + -0.549 / 0.219, + LagClock::EventTime, + ) + .expect("happiness-asym"); + assert!(rmse(&[0.219], &[happiness]) < 1e-12); + assert_eq!( + refuse_asymptotic_time_independent_effect_as_coefficient(recovered, effect), + Err(PsychometricError::AsymptoticTimeIndependentEffectIsNotCoefficient) + ); + assert_eq!( + refuse_asymptotic_time_independent_effect_as_discrete_effect(recovered, discrete), + Err(PsychometricError::AsymptoticTimeIndependentEffectIsNotDiscreteEffect) + ); + assert_eq!( + refuse_asymptotic_time_independent_effect_as_continuous_intercept(recovered, 0.3), + Err(PsychometricError::AsymptoticTimeIndependentEffectIsNotContinuousIntercept) + ); + assert_eq!( + refuse_asymptotic_time_independent_effect_as_time_dependent_impulse(recovered, impulse), + Err(PsychometricError::AsymptoticTimeIndependentEffectIsNotTimeDependentImpulse) + ); +} + +#[test] +fn asymptotic_time_independent_effect_refuses_unstable_drift_and_non_event_clocks() { + let effect = -0.225_f64; + let predictor = 1.0_f64; + let log_rate = -0.134_488_942_f64; + assert_eq!( + recover_asymptotic_time_independent_predictor_effect( + effect, + predictor, + log_rate, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_effect( + effect, + predictor, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_effect( + effect, + predictor, + 0.5, + LagClock::EventTime + ), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_effect( + 1e308, + 2.0, + log_rate, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_effect( + 0.0, + predictor, + 0.0, + LagClock::EventTime + ), + Ok(0.0) + ); +} + +#[test] +fn asymptotic_time_independent_variance_recovers_driver_section_seven_point_two() { + let effect = -0.225_f64; + let printed_asym = -1.673_f64; + let log_rate = -effect / printed_asym; + let predictor_variance = 1.0_f64; + let recovered = recover_asymptotic_time_independent_predictor_variance( + effect, + predictor_variance, + log_rate, + LagClock::EventTime, + ) + .expect("addedTIPREDVAR"); + let expected = printed_asym * printed_asym * predictor_variance; + let error = rmse(&[expected], &[recovered]); + assert!( + error < 1e-12, + "Driver §7.2 addedTIPREDVAR RMSE {error}: got {recovered}" + ); + let mean_effect = recover_asymptotic_time_independent_predictor_effect( + effect, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("asymTIPREDEFFECT"); + let stationary = recover_stationary_latent_variance(0.4, log_rate, LagClock::EventTime) + .expect("asymDIFFUSION"); + let trait_plus = recover_trait_plus_state_latent_variance(0.8, 0.3).expect("trait"); + assert!( + rmse(&[recovered], &[mean_effect]) > error, + "asymTIPREDEFFECT is not addedTIPREDVAR" + ); + assert!(rmse(&[recovered], &[stationary]) > error); + assert!(rmse(&[recovered], &[trait_plus]) > error); + assert_eq!( + refuse_asymptotic_time_independent_variance_as_trait_variance(recovered, trait_plus), + Err(PsychometricError::AsymptoticTimeIndependentVarianceIsNotTraitVariance) + ); + assert_eq!( + refuse_asymptotic_time_independent_variance_as_stationary_within_subject( + recovered, stationary + ), + Err(PsychometricError::AsymptoticTimeIndependentVarianceIsNotStationaryWithinSubject) + ); + assert_eq!( + refuse_asymptotic_time_independent_variance_as_asymptotic_effect(recovered, mean_effect), + Err(PsychometricError::AsymptoticTimeIndependentVarianceIsNotAsymptoticEffect) + ); +} + +#[test] +fn asymptotic_time_independent_variance_refuses_unstable_drift_and_non_event_clocks() { + let effect = -0.225_f64; + let log_rate = -0.134_488_942_f64; + assert_eq!( + recover_asymptotic_time_independent_predictor_variance( + effect, + 1.0, + log_rate, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_variance( + effect, + 1.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_variance( + effect, + -1.0, + log_rate, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_variance(0.0, 1.0, 0.0, LagClock::EventTime), + Ok(0.0) + ); + assert_eq!( + recover_asymptotic_time_independent_predictor_variance( + 1.0, + 1.0, + -1e-308, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); +} + +#[test] +fn asymptotic_continuous_intercept_recovers_driver_table_two() { + let printed_effect = -0.225_f64; + let printed_asym = -1.673_f64; + let log_rate = -printed_effect / printed_asym; + let intercept = 0.3_f64; + let recovered = + recover_asymptotic_continuous_intercept(intercept, log_rate, LagClock::EventTime) + .expect("asymCINT"); + let expected = intercept / -log_rate; + let error = rmse(&[expected], &[recovered]); + assert!( + error < 1e-12, + "Driver Table 2 asymCINT RMSE {error}: got {recovered}" + ); + let discrete = + recover_discrete_continuous_intercept_effect(intercept, log_rate, 1.0, LagClock::EventTime) + .expect("dtCINT"); + let tipred = recover_asymptotic_time_independent_predictor_effect( + printed_effect, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("asymTIPREDEFFECT"); + assert!( + rmse(&[recovered], &[intercept]) > error, + "CINT is not asymCINT" + ); + assert!(rmse(&[recovered], &[discrete]) > error); + assert!(rmse(&[recovered], &[2.823]) > error); + assert!(rmse(&[recovered], &[tipred]) > error); + assert_eq!( + refuse_asymptotic_continuous_intercept_as_continuous_intercept(recovered, intercept), + Err(PsychometricError::AsymptoticContinuousInterceptIsNotContinuousIntercept) + ); + assert_eq!( + refuse_asymptotic_continuous_intercept_as_discrete_increment(recovered, discrete), + Err(PsychometricError::AsymptoticContinuousInterceptIsNotDiscreteIncrement) + ); + assert_eq!( + refuse_asymptotic_continuous_intercept_as_initial_latent_mean(recovered, 2.823), + Err(PsychometricError::AsymptoticContinuousInterceptIsNotInitialLatentMean) + ); + assert_eq!( + refuse_asymptotic_continuous_intercept_as_asymptotic_time_independent_effect( + recovered, tipred + ), + Err(PsychometricError::AsymptoticContinuousInterceptIsNotAsymptoticTimeIndependentEffect) + ); +} + +#[test] +fn asymptotic_continuous_intercept_refuses_unstable_drift_and_non_event_clocks() { + let intercept = 0.3_f64; + let log_rate = -0.134_488_942_f64; + assert_eq!( + recover_asymptotic_continuous_intercept(intercept, log_rate, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_asymptotic_continuous_intercept(intercept, 0.0, LagClock::EventTime), + Err(PsychometricError::AsymptoticContinuousInterceptRequiresStableDrift) + ); + assert_eq!( + recover_asymptotic_continuous_intercept(intercept, 0.5, LagClock::EventTime), + Err(PsychometricError::AsymptoticContinuousInterceptRequiresStableDrift) + ); + assert_eq!( + recover_asymptotic_continuous_intercept(f64::NAN, log_rate, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_asymptotic_continuous_intercept(0.0, 0.0, LagClock::EventTime), + Ok(0.0) + ); +} + +#[test] +fn stationary_initial_latent_mean_recovers_driver_page_sixteen() { + let printed_effect = -0.225_f64; + let printed_asym = -1.673_f64; + let log_rate = -printed_effect / printed_asym; + let intercept = 0.3_f64; + let recovered = recover_stationary_initial_latent_mean( + intercept, + printed_effect, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0MEANS"); + let intercept_only = + recover_asymptotic_continuous_intercept(intercept, log_rate, LagClock::EventTime) + .expect("asymCINT"); + let tipred = recover_asymptotic_time_independent_predictor_effect( + printed_effect, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("asymTIPREDEFFECT"); + let expected = intercept_only + tipred; + let error = rmse(&[expected], &[recovered]); + assert!( + error < 1e-12, + "Driver p. 16 stationary T0MEANS RMSE {error}: got {recovered}" + ); + let discrete = + recover_discrete_latent_mean(2.823, log_rate, intercept, 1.0, LagClock::EventTime) + .expect("μ_t"); + assert!( + rmse(&[recovered], &[2.823]) > error, + "T0MEANS is not stationary T0MEANS" + ); + assert!(rmse(&[recovered], &[intercept_only]) > error); + assert!(rmse(&[recovered], &[tipred]) > error); + assert!(rmse(&[recovered], &[discrete]) > error); + assert_eq!( + refuse_stationary_initial_latent_mean_as_initial_latent_mean(recovered, 2.823), + Err(PsychometricError::StationaryInitialLatentMeanIsNotInitialLatentMean) + ); + assert_eq!( + refuse_stationary_initial_latent_mean_as_asymptotic_continuous_intercept( + recovered, + intercept_only + ), + Err(PsychometricError::StationaryInitialLatentMeanIsNotAsymptoticContinuousIntercept) + ); + assert_eq!( + refuse_stationary_initial_latent_mean_as_asymptotic_time_independent_effect( + recovered, tipred + ), + Err(PsychometricError::StationaryInitialLatentMeanIsNotAsymptoticTimeIndependentEffect) + ); + assert_eq!( + refuse_stationary_initial_latent_mean_as_discrete_mean(recovered, discrete), + Err(PsychometricError::StationaryInitialLatentMeanIsNotDiscreteMean) + ); +} + +#[test] +fn stationary_initial_latent_mean_refuses_unstable_drift_and_non_event_clocks() { + assert_eq!( + recover_stationary_initial_latent_mean(0.3, -0.225, 1.0, -0.13, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_stationary_initial_latent_mean(0.3, 0.0, 1.0, 0.0, LagClock::EventTime), + Err(PsychometricError::AsymptoticContinuousInterceptRequiresStableDrift) + ); + assert_eq!( + recover_stationary_initial_latent_mean(0.0, -0.225, 1.0, 0.5, LagClock::EventTime), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_stationary_initial_latent_mean(0.0, 0.0, 1.0, 0.0, LagClock::EventTime), + Ok(0.0) + ); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn stationary_initial_observed_mean_recovers_driver_equation_five_of_section_four_point_three() { + let printed_effect = -0.225_f64; + let printed_asym = -1.673_f64; + let log_rate = -printed_effect / printed_asym; + let intercept = 0.3_f64; + let loading = 2.0_f64; + let manifest_mean = 0.5_f64; + let recovered = recover_stationary_initial_observed_mean( + loading, + intercept, + printed_effect, + 1.0, + log_rate, + manifest_mean, + LagClock::EventTime, + ) + .expect("eq5-stationary-T0MEANS"); + let latent = recover_stationary_initial_latent_mean( + intercept, + printed_effect, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0MEANS"); + let expected = recover_manifest_observed_mean(loading, latent, manifest_mean).expect("τ+λμ"); + let error = rmse(&[expected], &[recovered]); + assert!( + error < 1e-12, + "Driver §4.3 Eq. 5 of stationary T0MEANS RMSE {error}: got {recovered}" + ); + let intercept_only = + recover_asymptotic_continuous_intercept(intercept, log_rate, LagClock::EventTime) + .expect("asymCINT"); + let intercept_only_observed = + recover_manifest_observed_mean(loading, intercept_only, manifest_mean).expect("τ+λ(−κ/a)"); + let free_initial_observed = + recover_manifest_observed_mean(loading, 2.823, manifest_mean).expect("τ+λμ_0"); + let evolved = recover_discrete_observed_mean( + loading, + 2.823, + log_rate, + intercept, + manifest_mean, + 1.0, + LagClock::EventTime, + ) + .expect("τ+λμ_t"); + assert!( + rmse(&[recovered], &[manifest_mean]) > error, + "MANIFESTMEANS is not E(y_0)" + ); + assert!(rmse(&[recovered], &[latent]) > error); + assert!(rmse(&[recovered], &[intercept_only_observed]) > error); + assert!(rmse(&[recovered], &[free_initial_observed]) > error); + assert!(rmse(&[recovered], &[evolved]) > error); + assert_eq!( + recover_stationary_initial_observed_mean( + 0.0, + intercept, + printed_effect, + 1.0, + log_rate, + manifest_mean, + LagClock::EventTime, + ), + Ok(manifest_mean) + ); + let evolved_from_stationary = recover_discrete_observed_mean_with_time_independent_predictor( + loading, + latent, + log_rate, + intercept, + printed_effect, + 1.0, + manifest_mean, + 2.0, + LagClock::EventTime, + ) + .expect("invariance"); + assert!(rmse(&[recovered], &[evolved_from_stationary]) < 1e-12); + assert_eq!( + refuse_stationary_initial_latent_mean_as_observed_mean(latent, recovered), + Err(PsychometricError::StationaryInitialLatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_stationary_initial_observed_mean_as_manifest_means(recovered, manifest_mean), + Err(PsychometricError::StationaryInitialObservedMeanIsNotManifestMeans) + ); + assert_eq!( + refuse_evolved_observed_mean_as_stationary_initial_observed_mean(evolved, recovered), + Err(PsychometricError::EvolvedObservedMeanIsNotStationaryInitialObservedMean) + ); + assert_eq!( + refuse_asymptotic_continuous_intercept_observed_mean_as_stationary_initial_observed_mean( + intercept_only_observed, + recovered + ), + Err( + PsychometricError::AsymptoticContinuousInterceptObservedMeanIsNotStationaryInitialObservedMean + ) + ); + assert_eq!( + refuse_initial_observed_mean_as_stationary_initial_observed_mean( + free_initial_observed, + recovered + ), + Err(PsychometricError::InitialObservedMeanIsNotStationaryInitialObservedMean) + ); +} + +#[test] +fn stationary_initial_observed_mean_refuses_unstable_drift_and_non_event_clocks() { + assert_eq!( + recover_stationary_initial_observed_mean( + 2.0, + 0.3, + -0.225, + 1.0, + -0.13, + 0.5, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_stationary_initial_observed_mean(2.0, 0.3, 0.0, 1.0, 0.0, 0.5, LagClock::EventTime), + Err(PsychometricError::AsymptoticContinuousInterceptRequiresStableDrift) + ); + assert_eq!( + recover_stationary_initial_observed_mean( + 2.0, + 0.0, + -0.225, + 1.0, + 0.5, + 0.5, + LagClock::EventTime + ), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_stationary_initial_observed_mean(2.0, 0.0, 0.0, 1.0, 0.0, 0.5, LagClock::EventTime), + Ok(0.5) + ); +} + +#[test] +fn stationary_initial_latent_variance_recovers_driver_section_four_point_three() { + let printed_effect = -0.225_f64; + let printed_asym = -1.673_f64; + let log_rate = -printed_effect / printed_asym; + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let predictor_variance = 1.0_f64; + let recovered = recover_stationary_initial_latent_variance( + trait_variance, + diffusion, + printed_effect, + predictor_variance, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0VAR"); + let state = recover_stationary_latent_variance(diffusion, log_rate, LagClock::EventTime) + .expect("asymDIFFUSION"); + let trait_plus_state = + recover_trait_plus_state_latent_variance(trait_variance, state).expect("trait+state"); + let added = recover_asymptotic_time_independent_predictor_variance( + printed_effect, + predictor_variance, + log_rate, + LagClock::EventTime, + ) + .expect("addedTIPREDVAR"); + let expected = trait_plus_state + added; + let error = rmse(&[expected], &[recovered]); + assert!( + error < 1e-12, + "Driver §4.3 stationary T0VAR RMSE {error}: got {recovered}" + ); + let discrete = + recover_discrete_latent_variance(recovered, diffusion, log_rate, 1.0, LagClock::EventTime) + .expect("Var(η_t)"); + let free_t0 = 2.0_f64; + assert!(rmse(&[recovered], &[free_t0]) > error); + assert!(rmse(&[recovered], &[state]) > error); + assert!(rmse(&[recovered], &[trait_variance]) > error); + assert!(rmse(&[recovered], &[added]) > error); + assert!(rmse(&[recovered], &[discrete]) > error); + assert!(rmse(&[recovered], &[2.838]) > error); + assert_eq!( + recover_stationary_initial_latent_variance( + 0.0, + 0.0, + 0.0, + predictor_variance, + log_rate, + LagClock::EventTime, + ), + Ok(0.0) + ); + assert_eq!( + recover_stationary_initial_latent_variance( + trait_variance, + 0.0, + 0.0, + predictor_variance, + 0.0, + LagClock::EventTime, + ), + Ok(trait_variance) + ); + assert_eq!( + refuse_stationary_initial_latent_variance_as_initial_latent_variance(recovered, free_t0), + Err(PsychometricError::StationaryInitialLatentVarianceIsNotInitialLatentVariance) + ); + assert_eq!( + refuse_stationary_initial_latent_variance_as_stationary_within_subject(recovered, state), + Err(PsychometricError::StationaryInitialLatentVarianceIsNotStationaryWithinSubject) + ); + assert_eq!( + refuse_stationary_initial_latent_variance_as_trait_variance(recovered, trait_variance), + Err(PsychometricError::StationaryInitialLatentVarianceIsNotTraitVariance) + ); + assert_eq!( + refuse_stationary_initial_latent_variance_as_asymptotic_time_independent_variance( + recovered, added + ), + Err( + PsychometricError::StationaryInitialLatentVarianceIsNotAsymptoticTimeIndependentVariance + ) + ); + assert_eq!( + refuse_stationary_initial_latent_variance_as_discrete_variance(recovered, discrete), + Err(PsychometricError::StationaryInitialLatentVarianceIsNotDiscreteVariance) + ); +} + +#[test] +fn stationary_initial_latent_variance_refuses_unstable_drift_and_non_event_clocks() { + assert_eq!( + recover_stationary_initial_latent_variance( + 1.0, + 0.4, + -0.225, + 1.0, + -0.13, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_stationary_initial_latent_variance(0.0, 0.4, 0.0, 1.0, 0.0, LagClock::EventTime), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_stationary_initial_latent_variance(0.0, 0.0, -0.225, 1.0, 0.5, LagClock::EventTime), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_stationary_initial_latent_variance(0.0, 0.0, 0.0, 1.0, 0.0, LagClock::EventTime), + Ok(0.0) + ); + assert_eq!( + recover_stationary_initial_latent_variance( + f64::NAN, + 0.4, + 0.0, + 0.0, + -0.5, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_stationary_initial_latent_variance( + f64::MAX, + f64::MAX, + 0.0, + 0.0, + -0.5, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_stationary_initial_latent_variance( + f64::MAX, + 0.0, + 1.0, + f64::MAX, + -1.0, + LagClock::EventTime + ), + Err(PsychometricError::InvalidNumericInput) + ); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn stationary_initial_observed_variance_recovers_driver_equation_five_of_section_four_point_three() +{ + let printed_effect = -0.225_f64; + let printed_asym = -1.673_f64; + let log_rate = -printed_effect / printed_asym; + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let loading = 2.0_f64; + let measurement_error = 0.5_f64; + let manifest_trait = 0.1_f64; + let recovered = recover_stationary_initial_observed_variance( + loading, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + measurement_error, + manifest_trait, + LagClock::EventTime, + ) + .expect("eq5-stationary-T0VAR"); + let latent = recover_stationary_initial_latent_variance( + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0VAR"); + let expected = recover_manifest_trait_plus_state_observed_variance( + loading, + latent, + measurement_error, + manifest_trait, + ) + .expect("λ²p+θ+ψ"); + let error = rmse(&[expected], &[recovered]); + assert!( + error < 1e-12, + "Driver §4.3 Eq. 5 of stationary T0VAR RMSE {error}: got {recovered}" + ); + let state = recover_stationary_latent_variance(diffusion, log_rate, LagClock::EventTime) + .expect("asymDIFFUSION"); + let state_only_observed = + recover_manifest_observed_variance(loading, state, measurement_error).expect("λ²(−q/2a)+θ"); + let free_initial_observed = + recover_manifest_observed_variance(loading, 2.0, measurement_error).expect("λ²p_0+θ"); + let discrete = + recover_discrete_latent_variance(latent, diffusion, log_rate, 1.0, LagClock::EventTime) + .expect("Var(η_t)"); + let evolved = recover_manifest_observed_variance(loading, discrete, measurement_error) + .expect("λ²Var(η_t)+θ"); + assert!( + rmse(&[recovered], &[measurement_error]) > error, + "MANIFESTVAR is not Var(y_0)" + ); + assert!(rmse(&[recovered], &[latent]) > error); + assert!(rmse(&[recovered], &[state_only_observed]) > error); + assert!(rmse(&[recovered], &[free_initial_observed]) > error); + assert!(rmse(&[recovered], &[evolved]) > error); + assert_eq!( + recover_stationary_initial_observed_variance( + 0.0, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + measurement_error, + manifest_trait, + LagClock::EventTime, + ), + Ok(measurement_error + manifest_trait) + ); + assert_eq!( + refuse_stationary_initial_latent_variance_as_observed_variance(latent, recovered), + Err(PsychometricError::StationaryInitialLatentVarianceIsNotObservedVariance) + ); + assert_eq!( + refuse_stationary_initial_observed_variance_as_measurement_error( + recovered, + measurement_error + ), + Err(PsychometricError::StationaryInitialObservedVarianceIsNotMeasurementError) + ); + assert_eq!( + refuse_evolved_observed_variance_as_stationary_initial_observed_variance( + evolved, recovered + ), + Err(PsychometricError::EvolvedObservedVarianceIsNotStationaryInitialObservedVariance) + ); + assert_eq!( + refuse_stationary_within_subject_observed_variance_as_stationary_initial_observed_variance( + state_only_observed, + recovered + ), + Err( + PsychometricError::StationaryWithinSubjectObservedVarianceIsNotStationaryInitialObservedVariance + ) + ); + assert_eq!( + refuse_initial_observed_variance_as_stationary_initial_observed_variance( + free_initial_observed, + recovered + ), + Err(PsychometricError::InitialObservedVarianceIsNotStationaryInitialObservedVariance) + ); +} + +#[test] +fn stationary_initial_observed_variance_refuses_unstable_drift_and_non_event_clocks() { + assert_eq!( + recover_stationary_initial_observed_variance( + 2.0, + 1.0, + 0.4, + -0.225, + 1.0, + -0.13, + 0.5, + 0.1, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_stationary_initial_observed_variance( + 2.0, + 0.0, + 0.4, + 0.0, + 1.0, + 0.0, + 0.5, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_stationary_initial_observed_variance( + 2.0, + 0.0, + 0.0, + -0.225, + 1.0, + 0.5, + 0.5, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_stationary_initial_observed_variance( + 2.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.5, + 0.1, + LagClock::EventTime + ), + Ok(0.6) + ); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn stationary_lagged_latent_covariance_recovers_driver_section_four_point_three() { + let printed_effect = -0.225_f64; + let printed_asym = -1.673_f64; + let log_rate = -printed_effect / printed_asym; + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let predictor_variance = 1.0_f64; + let event_delta = 1.0_f64; + let recovered = recover_stationary_lagged_latent_covariance( + trait_variance, + diffusion, + printed_effect, + predictor_variance, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("stationary lagged T0VAR"); + let state = recover_stationary_latent_variance(diffusion, log_rate, LagClock::EventTime) + .expect("asymDIFFUSION"); + let trait_plus_state = recover_trait_plus_state_lagged_covariance( + trait_variance, + state, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("trait+state lagged"); + let added = recover_asymptotic_time_independent_predictor_variance( + printed_effect, + predictor_variance, + log_rate, + LagClock::EventTime, + ) + .expect("addedTIPREDVAR"); + let expected = trait_plus_state + added; + let error = rmse(&[expected], &[recovered]); + assert!( + error < 1e-12, + "Driver §4.3 lagged stationary T0VAR RMSE {error}: got {recovered}" + ); + let contemporaneous = recover_stationary_initial_latent_variance( + trait_variance, + diffusion, + printed_effect, + predictor_variance, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0VAR"); + let decayed = recover_discrete_lagged_latent_covariance( + contemporaneous, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("e^{aΔt} p_stat"); + assert!(rmse(&[recovered], &[contemporaneous]) > error); + assert!(rmse(&[recovered], &[decayed]) > error); + assert!(rmse(&[recovered], &[trait_plus_state]) > error); + assert_eq!( + recover_stationary_lagged_latent_covariance( + 0.0, + 0.0, + 0.0, + predictor_variance, + log_rate, + event_delta, + LagClock::EventTime, + ), + Ok(0.0) + ); + assert_eq!( + recover_stationary_lagged_latent_covariance( + trait_variance, + 0.0, + 0.0, + predictor_variance, + 0.0, + event_delta, + LagClock::EventTime, + ), + Ok(trait_variance) + ); + let far = recover_stationary_lagged_latent_covariance( + trait_variance, + diffusion, + printed_effect, + predictor_variance, + log_rate, + 1e8, + LagClock::EventTime, + ) + .expect("Δt→∞"); + assert!(rmse(&[far], &[trait_variance + added]) < 1e-12); + assert_eq!( + refuse_stationary_lagged_latent_covariance_as_stationary_initial_latent_variance( + recovered, + contemporaneous + ), + Err( + PsychometricError::StationaryLaggedLatentCovarianceIsNotStationaryInitialLatentVariance + ) + ); + assert_eq!( + refuse_stationary_lagged_latent_covariance_as_decayed_stationary_variance( + recovered, decayed + ), + Err(PsychometricError::StationaryLaggedLatentCovarianceIsNotDecayedStationaryVariance) + ); + assert_eq!( + refuse_trait_plus_state_lagged_covariance_as_stationary_lagged_latent_covariance( + trait_plus_state, + recovered + ), + Err(PsychometricError::TraitPlusStateLaggedCovarianceIsNotStationaryLaggedLatentCovariance) + ); +} + +#[test] +fn stationary_lagged_latent_covariance_refuses_unstable_drift_and_non_event_clocks() { + assert_eq!( + recover_stationary_lagged_latent_covariance( + 1.0, + 0.4, + -0.225, + 1.0, + -0.13, + 1.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_stationary_lagged_latent_covariance( + 1.0, + 0.4, + -0.225, + 1.0, + -0.13, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_stationary_lagged_latent_covariance( + 0.0, + 0.4, + 0.0, + 1.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_stationary_lagged_latent_covariance( + 0.0, + 0.0, + -0.225, + 1.0, + 0.5, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_stationary_lagged_latent_covariance( + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + LagClock::EventTime + ), + Ok(0.0) + ); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn stationary_lagged_observed_covariance_recovers_driver_equation_five_of_section_four_point_three() +{ + let printed_effect = -0.225_f64; + let printed_asym = -1.673_f64; + let log_rate = -printed_effect / printed_asym; + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let loading = 2.0_f64; + let measurement_error = 0.5_f64; + let manifest_trait = 0.1_f64; + let event_delta = 1.0_f64; + let recovered = recover_stationary_lagged_observed_covariance( + loading, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + event_delta, + manifest_trait, + LagClock::EventTime, + ) + .expect("eq5-lagged-stationary-T0VAR"); + let latent = recover_stationary_lagged_latent_covariance( + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("stationary lagged T0VAR"); + let expected = recover_manifest_lagged_observed_covariance(loading, latent, manifest_trait) + .expect("λ²c+ψ"); + let error = rmse(&[expected], &[recovered]); + assert!( + error < 1e-12, + "Driver §4.3 Eq. 5 of lagged stationary T0VAR RMSE {error}: got {recovered}" + ); + let contemporaneous = recover_stationary_initial_observed_variance( + loading, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + measurement_error, + manifest_trait, + LagClock::EventTime, + ) + .expect("eq5-stationary-T0VAR"); + assert!( + rmse(&[recovered], &[measurement_error]) > error, + "MANIFESTVAR is not lagged cov(y)" + ); + assert!(rmse(&[recovered], &[latent]) > error); + assert!(rmse(&[recovered], &[contemporaneous]) > error); + assert_eq!( + recover_stationary_lagged_observed_covariance( + 0.0, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + event_delta, + manifest_trait, + LagClock::EventTime, + ), + Ok(manifest_trait) + ); + assert_eq!( + refuse_stationary_lagged_latent_covariance_as_observed_covariance(latent, recovered), + Err(PsychometricError::StationaryLaggedLatentCovarianceIsNotObservedCovariance) + ); + assert_eq!( + refuse_measurement_error_as_stationary_lagged_observed_covariance( + measurement_error, + recovered + ), + Err(PsychometricError::MeasurementErrorIsNotStationaryLaggedObservedCovariance) + ); + assert_eq!( + refuse_stationary_initial_observed_variance_as_stationary_lagged_observed_covariance( + contemporaneous, + recovered + ), + Err( + PsychometricError::StationaryInitialObservedVarianceIsNotStationaryLaggedObservedCovariance + ) + ); +} + +#[test] +fn stationary_lagged_observed_covariance_refuses_unstable_drift_and_non_event_clocks() { + assert_eq!( + recover_stationary_lagged_observed_covariance( + 2.0, + 1.0, + 0.4, + -0.225, + 1.0, + -0.13, + 1.0, + 0.1, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_stationary_lagged_observed_covariance( + 2.0, + 1.0, + 0.4, + -0.225, + 1.0, + -0.13, + 0.0, + 0.1, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_stationary_lagged_observed_covariance( + 2.0, + 0.0, + 0.4, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_stationary_lagged_observed_covariance( + 2.0, + 0.0, + 0.0, + -0.225, + 1.0, + 0.5, + 1.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_stationary_lagged_observed_covariance( + 2.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.1, + LagClock::EventTime + ), + Ok(0.1) + ); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn stationary_later_latent_variance_recovers_driver_section_four_point_three() { + let printed_effect = -0.225_f64; + let printed_asym = -1.673_f64; + let log_rate = -printed_effect / printed_asym; + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let predictor_variance = 1.0_f64; + let event_delta = 1.0_f64; + let recovered = recover_stationary_later_latent_variance( + trait_variance, + diffusion, + printed_effect, + predictor_variance, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("stationary later T0VAR"); + let state = recover_stationary_latent_variance(diffusion, log_rate, LagClock::EventTime) + .expect("asymDIFFUSION"); + let evolved_state = recover_discrete_latent_variance( + state, + diffusion, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("e^{2aΔt}p+Q_Δt"); + let added = recover_asymptotic_time_independent_predictor_variance( + printed_effect, + predictor_variance, + log_rate, + LagClock::EventTime, + ) + .expect("addedTIPREDVAR"); + let expected = trait_variance + evolved_state + added; + let error = rmse(&[expected], &[recovered]); + assert!( + error < 1e-12, + "Driver §4.3 later-occasion stationary T0VAR RMSE {error}: got {recovered}" + ); + let contemporaneous = recover_stationary_initial_latent_variance( + trait_variance, + diffusion, + printed_effect, + predictor_variance, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0VAR"); + let lagged = recover_stationary_lagged_latent_covariance( + trait_variance, + diffusion, + printed_effect, + predictor_variance, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("stationary lagged T0VAR"); + let free_discrete = recover_discrete_latent_variance( + contemporaneous, + diffusion, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("e^{2aΔt} p_stat + Q_Δt"); + let process_noise = + recover_discrete_process_noise(diffusion, log_rate, event_delta, LagClock::EventTime) + .expect("Q_Δt"); + assert!(rmse(&[recovered], &[contemporaneous]) < 1e-12); + assert!(rmse(&[recovered], &[lagged]) > error); + assert!(rmse(&[recovered], &[free_discrete]) > error); + assert!(rmse(&[recovered], &[process_noise]) > error); + assert_eq!( + recover_stationary_later_latent_variance( + 0.0, + 0.0, + 0.0, + predictor_variance, + log_rate, + event_delta, + LagClock::EventTime, + ), + Ok(0.0) + ); + assert_eq!( + recover_stationary_later_latent_variance( + trait_variance, + 0.0, + 0.0, + predictor_variance, + 0.0, + event_delta, + LagClock::EventTime, + ), + Ok(trait_variance) + ); + let far = recover_stationary_later_latent_variance( + trait_variance, + diffusion, + printed_effect, + predictor_variance, + log_rate, + 1e8, + LagClock::EventTime, + ) + .expect("Δt→∞"); + assert!(rmse(&[far], &[contemporaneous]) < 1e-12); + assert_eq!( + refuse_stationary_later_latent_variance_as_lagged_covariance(recovered, lagged), + Err(PsychometricError::StationaryLaterLatentVarianceIsNotLaggedCovariance) + ); + assert_eq!( + refuse_stationary_later_latent_variance_as_discrete_variance(recovered, free_discrete), + Err(PsychometricError::StationaryLaterLatentVarianceIsNotDiscreteVariance) + ); + assert_eq!( + refuse_stationary_later_latent_variance_as_process_noise(recovered, process_noise), + Err(PsychometricError::StationaryLaterLatentVarianceIsNotProcessNoise) + ); +} + +#[test] +fn stationary_later_latent_variance_refuses_unstable_drift_and_non_event_clocks() { + assert_eq!( + recover_stationary_later_latent_variance( + 1.0, + 0.4, + -0.225, + 1.0, + -0.13, + 1.0, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_stationary_later_latent_variance( + 1.0, + 0.4, + -0.225, + 1.0, + -0.13, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_stationary_later_latent_variance(0.0, 0.4, 0.0, 1.0, 0.0, 1.0, LagClock::EventTime), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_stationary_later_latent_variance( + 0.0, + 0.0, + -0.225, + 1.0, + 0.5, + 1.0, + LagClock::EventTime + ), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_stationary_later_latent_variance(0.0, 0.0, 0.0, 1.0, 0.0, 1.0, LagClock::EventTime), + Ok(0.0) + ); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn stationary_later_observed_variance_recovers_driver_equation_five_of_section_four_point_three() { + let printed_effect = -0.225_f64; + let printed_asym = -1.673_f64; + let log_rate = -printed_effect / printed_asym; + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let loading = 2.0_f64; + let measurement_error = 0.5_f64; + let manifest_trait = 0.1_f64; + let event_delta = 1.0_f64; + let recovered = recover_stationary_later_observed_variance( + loading, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + event_delta, + measurement_error, + manifest_trait, + LagClock::EventTime, + ) + .expect("eq5-later-stationary-T0VAR"); + let latent = recover_stationary_later_latent_variance( + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("stationary later T0VAR"); + let expected = recover_manifest_trait_plus_state_observed_variance( + loading, + latent, + measurement_error, + manifest_trait, + ) + .expect("λ²p+θ+ψ"); + let error = rmse(&[expected], &[recovered]); + assert!( + error < 1e-12, + "Driver §4.3 Eq. 5 of later-occasion stationary T0VAR RMSE {error}: got {recovered}" + ); + let contemporaneous = recover_stationary_initial_observed_variance( + loading, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + measurement_error, + manifest_trait, + LagClock::EventTime, + ) + .expect("eq5-stationary-T0VAR"); + let lagged = recover_stationary_lagged_observed_covariance( + loading, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + event_delta, + manifest_trait, + LagClock::EventTime, + ) + .expect("eq5-lagged-stationary-T0VAR"); + assert!(rmse(&[recovered], &[contemporaneous]) < 1e-12); + assert!( + rmse(&[recovered], &[measurement_error]) > error, + "MANIFESTVAR is not later Var(y)" + ); + assert!(rmse(&[recovered], &[latent]) > error); + assert!(rmse(&[recovered], &[lagged]) > error); + assert_eq!( + recover_stationary_later_observed_variance( + 0.0, + trait_variance, + diffusion, + printed_effect, + 1.0, + log_rate, + event_delta, + measurement_error, + manifest_trait, + LagClock::EventTime, + ), + Ok(measurement_error + manifest_trait) + ); + assert_eq!( + refuse_stationary_later_latent_variance_as_observed_variance(latent, recovered), + Err(PsychometricError::StationaryLaterLatentVarianceIsNotObservedVariance) + ); + assert_eq!( + refuse_measurement_error_as_stationary_later_observed_variance( + measurement_error, + recovered + ), + Err(PsychometricError::MeasurementErrorIsNotStationaryLaterObservedVariance) + ); + assert_eq!( + refuse_stationary_lagged_observed_covariance_as_stationary_later_observed_variance( + lagged, recovered + ), + Err( + PsychometricError::StationaryLaggedObservedCovarianceIsNotStationaryLaterObservedVariance + ) + ); +} + +#[test] +fn stationary_later_observed_variance_refuses_unstable_drift_and_non_event_clocks() { + assert_eq!( + recover_stationary_later_observed_variance( + 2.0, + 1.0, + 0.4, + -0.225, + 1.0, + -0.13, + 1.0, + 0.5, + 0.1, + LagClock::SystemTime + ), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_stationary_later_observed_variance( + 2.0, + 1.0, + 0.4, + -0.225, + 1.0, + -0.13, + 0.0, + 0.5, + 0.1, + LagClock::EventTime + ), + Err(PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_stationary_later_observed_variance( + 2.0, + 0.0, + 0.4, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_stationary_later_observed_variance( + 2.0, + 0.0, + 0.0, + -0.225, + 1.0, + 0.5, + 1.0, + 0.0, + 0.0, + LagClock::EventTime + ), + Err(PsychometricError::AsymptoticTimeIndependentEffectRequiresStableDrift) + ); + assert_eq!( + recover_stationary_later_observed_variance( + 2.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.5, + 0.1, + LagClock::EventTime + ), + Ok(0.6) + ); +} diff --git a/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs b/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs new file mode 100644 index 00000000..8d86a158 --- /dev/null +++ b/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs @@ -0,0 +1,31 @@ +//! Posterior-draw point-estimate aggregation must remain finite under valid extreme draws. + +use psychometric_core::{PsychometricError, posterior_draw_point_estimate_mean}; + +#[test] +fn scaled_mean_recovers_balanced_extreme_posterior_draws() { + let mean = posterior_draw_point_estimate_mean(&[f64::MAX, f64::MAX, -f64::MAX, -f64::MAX]) + .expect("balanced finite draws have a finite mean"); + assert!(mean.abs() < f64::EPSILON); +} + +#[test] +fn scaled_mean_preserves_an_extreme_constant_draw() { + let mean = posterior_draw_point_estimate_mean(&[f64::MAX, f64::MAX]) + .expect("constant finite extreme draws have a finite mean"); + assert_eq!(mean.to_bits(), f64::MAX.to_bits()); +} + +#[test] +fn all_zero_draws_have_an_exact_zero_mean() { + let mean = posterior_draw_point_estimate_mean(&[0.0, 0.0, 0.0]).expect("zero draws"); + assert!(mean.abs() < f64::EPSILON); +} + +#[test] +fn nonfinite_draws_remain_rejected() { + assert_eq!( + posterior_draw_point_estimate_mean(&[1.0, f64::INFINITY]), + Err(PsychometricError::InvalidNumericInput) + ); +} diff --git a/crates/psychometric_core/tests/rubin_and_mean_gate_contract.rs b/crates/psychometric_core/tests/rubin_and_mean_gate_contract.rs new file mode 100644 index 00000000..998db025 --- /dev/null +++ b/crates/psychometric_core/tests/rubin_and_mean_gate_contract.rs @@ -0,0 +1,247 @@ +//! Rubin T combining and strong-invariance latent-mean claim boundaries. +#![allow(clippy::cast_precision_loss)] + +use psychometric_core::{ + GroupIndicatorSeries, IndicatorKind, MeanInvarianceStatus, PsychometricError, + classify_two_group_ols_invariance, combine_draw_level_ols_loadings, + recover_loading_point_estimate_mean, recover_strong_gated_latent_mean_difference, +}; + +fn rmse(truth: &[f64], recovered: &[f64]) -> f64 { + let n = truth.len() as f64; + let sum_sq: f64 = truth + .iter() + .zip(recovered) + .map(|(left, right)| { + let residual = left - right; + residual * residual + }) + .sum(); + (sum_sq / n).sqrt() +} + +#[test] +fn rubin_t_mean_recovers_true_loading_and_is_not_a_point_estimate_alias() { + let true_loading = 0.8_f64; + let factors = [-2.0_f64, -1.0, 0.0, 1.0, 2.0]; + let draws = [ + factors + .iter() + .map(|score| (true_loading - 0.05) * score) + .collect::>(), + factors + .iter() + .map(|score| (true_loading + 0.05) * score) + .collect::>(), + factors + .iter() + .map(|score| true_loading * score) + .collect::>(), + ]; + let combined = combine_draw_level_ols_loadings(&factors, &draws, IndicatorKind::LogisticNormal) + .expect("rubin"); + let point = + recover_loading_point_estimate_mean(&factors, &draws, IndicatorKind::LogisticNormal) + .expect("point"); + let error = rmse(&[true_loading], &[combined.mean_loading]); + assert!(error < 1e-12, "Rubin mean RMSE {error}"); + assert!((combined.mean_loading - point).abs() < 1e-15); + let expected = combined.within_variance + + (1.0 + 1.0 / (combined.draw_count as f64)) * combined.between_variance; + assert!((combined.total_variance - expected).abs() < 1e-15); + assert!(combined.between_variance > 0.0); +} + +#[test] +fn rubin_t_noisy_truth_reports_bias_rmse_and_interval_coverage() { + let true_loading = 0.8_f64; + let factors: Vec = (0..24).map(|index| f64::from(index) - 11.5).collect(); + let mut recovered = Vec::new(); + let mut covered = 0_usize; + + for replicate in 0..40 { + let mut draws = Vec::new(); + for draw in 0..8 { + let phase = f64::from(replicate) * 0.37 + f64::from(draw) * 0.91; + draws.push( + factors + .iter() + .enumerate() + .map(|(index, factor)| { + let position = + (f64::from(u32::try_from(index).expect("tiny")) + 1.0) * 0.73 + phase; + 0.4 + true_loading * factor + + 0.25 * position.sin() + + 0.12 * (1.7 * position).cos() + }) + .collect::>(), + ); + } + + let combined = + combine_draw_level_ols_loadings(&factors, &draws, IndicatorKind::LogisticNormal) + .expect("noisy Rubin draw"); + assert!(combined.within_variance > 0.0); + let half_width = 1.96 * combined.total_variance.sqrt(); + if (combined.mean_loading - true_loading).abs() <= half_width { + covered += 1; + } + recovered.push(combined.mean_loading); + } + + let mean = recovered.iter().sum::() / recovered.len() as f64; + let bias = mean - true_loading; + let rmse = (recovered + .iter() + .map(|estimate| (estimate - true_loading).powi(2)) + .sum::() + / recovered.len() as f64) + .sqrt(); + let coverage = covered as f64 / recovered.len() as f64; + assert!(bias.abs() < 0.01, "loading bias {bias}"); + assert!(rmse < 0.02, "loading RMSE {rmse}"); + assert!( + coverage >= 0.95, + "95% interval coverage {coverage} must meet the constructed 1.96 gate" + ); +} + +#[test] +fn hash84_wire_map_excludes_local_strict() { + assert_eq!(MeanInvarianceStatus::Strict.as_str(), "strict"); + assert_eq!( + MeanInvarianceStatus::Strict.as_measurement_invariance_wire_name(), + None + ); + assert!(MeanInvarianceStatus::Strict.licenses_latent_mean_comparison()); + assert!(MeanInvarianceStatus::Strict.licenses_shared_metric_meaning()); + let hash84_wire_names = ["configural", "metric", "scalar"]; + assert_eq!( + MeanInvarianceStatus::Configural.as_measurement_invariance_wire_name(), + Some("configural") + ); + assert_eq!( + MeanInvarianceStatus::Metric.as_measurement_invariance_wire_name(), + Some("metric") + ); + assert_eq!( + MeanInvarianceStatus::Strong.as_measurement_invariance_wire_name(), + Some("scalar") + ); + assert!(!hash84_wire_names.contains(&"strict")); +} + +#[test] +fn metric_status_matches_hash84_metric_and_refuses_latent_means() { + assert_eq!( + MeanInvarianceStatus::Metric.as_measurement_invariance_wire_name(), + Some("metric") + ); + assert!(MeanInvarianceStatus::Metric.licenses_shared_metric_meaning()); + assert!(!MeanInvarianceStatus::Metric.licenses_latent_mean_comparison()); + + let reference = GroupIndicatorSeries { + factor_scores: vec![-1.0, 0.0, 1.0], + indicators: vec![0.2, 1.0, 1.8], + }; + let comparison = GroupIndicatorSeries { + factor_scores: vec![-1.0, 0.0, 1.0], + indicators: vec![1.2, 2.0, 2.8], + }; + let classified = classify_two_group_ols_invariance( + &reference, + &comparison, + IndicatorKind::AdditiveLogRatio, + 1e-9, + 1e-9, + 1e-9, + ) + .expect("metric"); + assert_eq!(classified.status, MeanInvarianceStatus::Metric); + assert_eq!( + recover_strong_gated_latent_mean_difference( + &reference, + &comparison, + IndicatorKind::AdditiveLogRatio, + 1e-9, + 1e-9, + 1e-9, + ), + Err(PsychometricError::StrongInvarianceRequired) + ); +} + +#[test] +fn strong_status_matches_hash84_scalar_and_recovers_mean_difference() { + assert_eq!( + MeanInvarianceStatus::Strong.as_measurement_invariance_wire_name(), + Some("scalar") + ); + assert!(MeanInvarianceStatus::Strong.licenses_latent_mean_comparison()); + + let reference = GroupIndicatorSeries { + factor_scores: vec![-1.0, 0.0, 1.0], + indicators: vec![-0.8, 0.0, 0.8], + }; + let comparison = GroupIndicatorSeries { + factor_scores: vec![1.0, 2.0, 3.0], + indicators: vec![0.8, 1.6, 2.4], + }; + let difference = recover_strong_gated_latent_mean_difference( + &reference, + &comparison, + IndicatorKind::IsometricLogRatio, + 1e-9, + 1e-9, + 1e-9, + ) + .expect("strong"); + let error = rmse(&[2.0], &[difference]); + assert!(error < 1e-12, "latent-mean RMSE {error}"); +} + +#[test] +fn two_observation_series_cap_at_strong_scalar_and_still_license_means() { + let reference = GroupIndicatorSeries { + factor_scores: vec![-1.0, 1.0], + indicators: vec![-0.7, 1.7], + }; + let comparison = GroupIndicatorSeries { + factor_scores: vec![0.0, 2.0], + indicators: vec![0.5, 2.9], + }; + let classified = classify_two_group_ols_invariance( + &reference, + &comparison, + IndicatorKind::AdditiveLogRatio, + 1e-9, + 1e-9, + 1e-9, + ) + .expect("two-obs"); + assert_eq!(classified.status, MeanInvarianceStatus::Strong); + assert_eq!( + classified.status.as_measurement_invariance_wire_name(), + Some("scalar") + ); + assert_eq!( + classified.reference_residual_variance.to_bits(), + 0.0_f64.to_bits() + ); + assert_eq!( + classified.comparison_residual_variance.to_bits(), + 0.0_f64.to_bits() + ); + assert!(classified.status.licenses_latent_mean_comparison()); + let difference = recover_strong_gated_latent_mean_difference( + &reference, + &comparison, + IndicatorKind::AdditiveLogRatio, + 1e-9, + 1e-9, + 1e-9, + ) + .expect("licensed"); + let error = rmse(&[1.0], &[difference]); + assert!(error < 1e-12, "two-obs latent-mean RMSE {error}"); +} diff --git a/crates/psychometric_core/tests/scientific_claim_boundary_contract.rs b/crates/psychometric_core/tests/scientific_claim_boundary_contract.rs new file mode 100644 index 00000000..769ec14a --- /dev/null +++ b/crates/psychometric_core/tests/scientific_claim_boundary_contract.rs @@ -0,0 +1,2975 @@ +//! Scientific claim boundaries for compositional coordinates and posterior draws. + +use psychometric_core::{ + ClusteredEventScore, ClusteredScore, IndicatorKind, LagClock, LaggedWithinResidual, + ordinary_least_squares_slope, posterior_draw_point_estimate_mean, + recover_asymptotic_continuous_intercept, recover_asymptotic_time_independent_predictor_effect, + recover_asymptotic_time_independent_predictor_variance, + recover_cluster_mean_within_between_slopes, recover_discrete_constant_predictor_effect, + recover_discrete_continuous_intercept_effect, recover_discrete_lagged_latent_covariance, + recover_discrete_latent_mean, recover_discrete_latent_mean_with_extra_process, + recover_discrete_latent_mean_with_extra_process_after, + recover_discrete_latent_mean_with_impulse, recover_discrete_latent_mean_with_impulse_carry, + recover_discrete_latent_mean_with_initial_time_dependent_predictor, + recover_discrete_latent_mean_with_initial_time_independent_predictor, + recover_discrete_latent_mean_with_time_independent_predictor, recover_discrete_latent_variance, + recover_discrete_observed_mean, recover_discrete_observed_mean_with_extra_process, + recover_discrete_observed_mean_with_extra_process_after, + recover_discrete_observed_mean_with_impulse, recover_discrete_observed_mean_with_impulse_carry, + recover_discrete_observed_mean_with_initial_time_dependent_predictor, + recover_discrete_observed_mean_with_initial_time_independent_predictor, + recover_discrete_observed_mean_with_time_independent_predictor, recover_discrete_process_noise, + recover_discrete_time_independent_predictor_effect, + recover_discrete_time_varying_predictor_effect, recover_initial_time_dependent_predictor_carry, + recover_initial_time_dependent_predictor_effect, + recover_initial_time_independent_predictor_carry, + recover_initial_time_independent_predictor_effect, + recover_irregular_centered_residual_log_rate, recover_level_change_continuous_intercept, + recover_level_change_discrete_increment, recover_level_change_extra_process_contribution, + recover_level_change_extra_process_contribution_after, recover_loading_point_estimate_mean, + recover_manifest_lagged_observed_covariance, recover_manifest_observed_mean, + recover_manifest_observed_variance, recover_manifest_trait_plus_state_observed_variance, + recover_stationary_initial_latent_mean, recover_stationary_initial_latent_variance, + recover_stationary_initial_observed_mean, recover_stationary_initial_observed_variance, + recover_stationary_lagged_latent_covariance, recover_stationary_lagged_observed_covariance, + recover_stationary_latent_variance, recover_stationary_later_latent_variance, + recover_stationary_later_observed_variance, recover_time_dependent_predictor_impulse, + recover_time_dependent_predictor_impulse_carry, recover_trait_plus_state_lagged_covariance, + recover_trait_plus_state_latent_variance, recover_within_residual_event_time_log_rate, + refuse_after_extra_process_contribution_as_observed_mean, + refuse_after_extra_process_latent_mean_as_observed_mean, + refuse_asymptotic_continuous_intercept_as_asymptotic_time_independent_effect, + refuse_asymptotic_continuous_intercept_as_continuous_intercept, + refuse_asymptotic_continuous_intercept_as_discrete_increment, + refuse_asymptotic_continuous_intercept_as_initial_latent_mean, + refuse_asymptotic_continuous_intercept_observed_mean_as_stationary_initial_observed_mean, + refuse_asymptotic_time_independent_effect_as_coefficient, + refuse_asymptotic_time_independent_effect_as_continuous_intercept, + refuse_asymptotic_time_independent_effect_as_discrete_effect, + refuse_asymptotic_time_independent_effect_as_time_dependent_impulse, + refuse_asymptotic_time_independent_variance_as_asymptotic_effect, + refuse_asymptotic_time_independent_variance_as_stationary_within_subject, + refuse_asymptotic_time_independent_variance_as_trait_variance, + refuse_continuous_intercept_as_discrete_mean_increment, + refuse_continuous_intercept_as_initial_latent_mean, + refuse_continuous_intercept_as_manifest_means, + refuse_evolved_observed_mean_as_after_extra_process_observed_mean, + refuse_evolved_observed_mean_as_extra_process_observed_mean, + refuse_evolved_observed_mean_as_impulse_carry_observed_mean, + refuse_evolved_observed_mean_as_impulse_observed_mean, + refuse_evolved_observed_mean_as_initial_time_dependent_observed_mean, + refuse_evolved_observed_mean_as_initial_time_independent_observed_mean, + refuse_evolved_observed_mean_as_stationary_initial_observed_mean, + refuse_evolved_observed_mean_as_time_independent_observed_mean, + refuse_evolved_observed_variance_as_stationary_initial_observed_variance, + refuse_extra_process_contribution_as_observed_mean, + refuse_extra_process_latent_mean_as_observed_mean, + refuse_extra_process_observed_mean_as_after_extra_process_observed_mean, + refuse_finite_interval_process_noise_as_stationary_variance, + refuse_impulse_carry_observed_mean_as_after_extra_process_observed_mean, + refuse_impulse_carry_observed_mean_as_initial_time_dependent_observed_mean, + refuse_impulse_carry_observed_mean_as_initial_time_independent_observed_mean, + refuse_impulse_carry_observed_mean_as_time_independent_observed_mean, + refuse_impulse_observed_mean_as_extra_process_observed_mean, + refuse_impulse_observed_mean_as_impulse_carry_observed_mean, + refuse_impulse_observed_mean_as_initial_time_dependent_observed_mean, + refuse_impulse_observed_mean_as_initial_time_independent_observed_mean, + refuse_impulse_observed_mean_as_time_independent_observed_mean, + refuse_initial_latent_mean_as_evolved_mean, + refuse_initial_observed_mean_as_evolved_observed_mean, + refuse_initial_observed_mean_as_stationary_initial_observed_mean, + refuse_initial_observed_variance_as_stationary_initial_observed_variance, + refuse_initial_time_dependent_carry_as_impulse_carry, + refuse_initial_time_dependent_carry_as_initial_effect, + refuse_initial_time_dependent_coefficient_as_initial_effect, + refuse_initial_time_dependent_effect_as_contemporaneous_impulse, + refuse_initial_time_dependent_effect_as_continuous_intercept, + refuse_initial_time_dependent_effect_as_initial_time_independent_effect, + refuse_initial_time_dependent_effect_as_process_increment, + refuse_initial_time_independent_carry_as_initial_effect, + refuse_initial_time_independent_coefficient_as_initial_effect, + refuse_initial_time_independent_effect_as_continuous_intercept, + refuse_initial_time_independent_effect_as_process_increment, + refuse_initial_time_independent_effect_as_time_dependent_impulse, + refuse_initial_time_independent_observed_mean_as_initial_time_dependent_observed_mean, + refuse_latent_lagged_covariance_as_observed_covariance, refuse_latent_mean_as_observed_mean, + refuse_latent_variance_as_observed_variance, refuse_level_change_extra_process_as_impulse, + refuse_level_change_extra_process_as_increment, refuse_level_change_extra_process_as_intercept, + refuse_level_change_increment_as_impulse, refuse_level_change_increment_as_intercept, + refuse_level_change_increment_as_process_increment, + refuse_level_change_intercept_as_free_continuous_intercept, + refuse_level_change_intercept_as_impulse, refuse_level_change_intercept_as_process_increment, + refuse_manifest_means_as_observed_mean, refuse_manifest_trait_variance_as_measurement_error, + refuse_measurement_error_as_lagged_observed_covariance, + refuse_measurement_error_as_observed_variance, + refuse_measurement_error_as_stationary_lagged_observed_covariance, + refuse_measurement_error_as_stationary_later_observed_variance, + refuse_process_noise_as_unconditional_variance, + refuse_stationary_initial_latent_mean_as_asymptotic_continuous_intercept, + refuse_stationary_initial_latent_mean_as_asymptotic_time_independent_effect, + refuse_stationary_initial_latent_mean_as_discrete_mean, + refuse_stationary_initial_latent_mean_as_initial_latent_mean, + refuse_stationary_initial_latent_mean_as_observed_mean, + refuse_stationary_initial_latent_variance_as_asymptotic_time_independent_variance, + refuse_stationary_initial_latent_variance_as_discrete_variance, + refuse_stationary_initial_latent_variance_as_initial_latent_variance, + refuse_stationary_initial_latent_variance_as_observed_variance, + refuse_stationary_initial_latent_variance_as_stationary_within_subject, + refuse_stationary_initial_latent_variance_as_trait_variance, + refuse_stationary_initial_observed_mean_as_manifest_means, + refuse_stationary_initial_observed_variance_as_measurement_error, + refuse_stationary_initial_observed_variance_as_stationary_lagged_observed_covariance, + refuse_stationary_lagged_latent_covariance_as_decayed_stationary_variance, + refuse_stationary_lagged_latent_covariance_as_observed_covariance, + refuse_stationary_lagged_latent_covariance_as_stationary_initial_latent_variance, + refuse_stationary_lagged_observed_covariance_as_stationary_later_observed_variance, + refuse_stationary_later_latent_variance_as_discrete_variance, + refuse_stationary_later_latent_variance_as_lagged_covariance, + refuse_stationary_later_latent_variance_as_observed_variance, + refuse_stationary_later_latent_variance_as_process_noise, + refuse_stationary_within_subject_observed_variance_as_stationary_initial_observed_variance, + refuse_time_dependent_impulse_as_continuous_intercept, + refuse_time_dependent_impulse_as_time_independent_effect, + refuse_time_dependent_impulse_as_time_varying_discrete_effect, + refuse_time_dependent_impulse_carry_as_contemporaneous_impulse, + refuse_time_dependent_impulse_carry_as_continuous_intercept, + refuse_time_dependent_impulse_carry_as_time_independent_effect, + refuse_time_dependent_impulse_carry_as_time_varying_discrete_effect, + refuse_time_independent_coefficient_as_discrete_effect, + refuse_time_independent_effect_as_continuous_intercept, + refuse_time_independent_effect_as_time_dependent_impulse, + refuse_time_independent_effect_as_time_varying_discrete_effect, + refuse_time_independent_observed_mean_as_initial_time_dependent_observed_mean, + refuse_time_independent_observed_mean_as_initial_time_independent_observed_mean, + refuse_trait_plus_state_lagged_covariance_as_stationary_lagged_latent_covariance, + refuse_trait_variance_as_process_noise, refuse_trait_variance_as_stationary_within_subject, +}; + +#[test] +fn only_ilr_claims_orthonormal_aitchison_geometry() { + assert!(IndicatorKind::AdditiveLogRatio.is_valid_structural_input()); + assert!(!IndicatorKind::AdditiveLogRatio.preserves_aitchison_distance()); + assert!(IndicatorKind::IsometricLogRatio.is_valid_structural_input()); + assert!(IndicatorKind::IsometricLogRatio.preserves_aitchison_distance()); + assert!(IndicatorKind::LogisticNormal.is_valid_structural_input()); + assert!(!IndicatorKind::LogisticNormal.preserves_aitchison_distance()); + assert!(!IndicatorKind::RawProportion.is_valid_structural_input()); + assert!(!IndicatorKind::RawProportion.preserves_aitchison_distance()); +} + +#[test] +fn posterior_draw_helpers_report_point_estimates_without_rubin_variance_claims() { + let mean = posterior_draw_point_estimate_mean(&[0.7, 0.8, 0.9]) + .expect("finite posterior point estimates"); + assert!((mean - 0.8).abs() < 1e-15); + + let factor_scores = [-1.0_f64, 0.0, 1.0]; + let indicator_draws = vec![vec![-0.7, 0.0, 0.7], vec![-0.9, 0.0, 0.9]]; + let loading = recover_loading_point_estimate_mean( + &factor_scores, + &indicator_draws, + IndicatorKind::AdditiveLogRatio, + ) + .expect("posterior-draw point-estimate mean"); + assert!((loading - 0.8).abs() < 1e-15); +} + +#[test] +fn person_mean_subtraction_on_raw_ar_is_not_the_lagged_within_effect() { + let drift = -0.28_f64; + let centered = recover_irregular_centered_residual_log_rate( + &[LaggedWithinResidual { + earlier_residual: 1.0, + later_residual: (drift * 1.3).exp(), + event_delta: 1.3, + }], + LagClock::EventTime, + ) + .expect("already centered"); + assert!((centered - drift).abs() < 1e-12); + + let raw = [ + ClusteredEventScore { + cluster_key: 1, + event_time: 0.0, + score: 6.0 + 1.0, + }, + ClusteredEventScore { + cluster_key: 1, + event_time: 1.0, + score: 6.0 + drift.exp(), + }, + ClusteredEventScore { + cluster_key: 1, + event_time: 2.0, + score: 6.0 + (drift * 2.0).exp(), + }, + ClusteredEventScore { + cluster_key: 2, + event_time: 0.0, + score: -3.0 + 1.2, + }, + ClusteredEventScore { + cluster_key: 2, + event_time: 1.0, + score: -3.0 + 1.2 * drift.exp(), + }, + ClusteredEventScore { + cluster_key: 2, + event_time: 2.0, + score: -3.0 + 1.2 * (drift * 2.0).exp(), + }, + ]; + let cwc = recover_within_residual_event_time_log_rate(&raw, LagClock::EventTime).expect("cwc"); + assert!( + (cwc - drift).abs() > 1e-6, + "Curran & Bauer (2011, pp. 607–608): CWC of raw AR recovered {cwc}, which must not equal drift {drift}" + ); +} + +#[test] +fn cwc_cluster_mean_coefficient_is_not_the_between_cluster_effect() { + let rows = [ + ClusteredScore { + cluster_key: 1, + predictor: 0.0, + outcome: 2.0, + }, + ClusteredScore { + cluster_key: 1, + predictor: 2.0, + outcome: 3.0, + }, + ClusteredScore { + cluster_key: 2, + predictor: 4.0, + outcome: 10.0, + }, + ClusteredScore { + cluster_key: 2, + predictor: 6.0, + outcome: 11.0, + }, + ]; + let recovered = recover_cluster_mean_within_between_slopes(&rows).expect("cwc"); + let predictors: Vec = rows.iter().map(|row| row.predictor).collect(); + let outcomes: Vec = rows.iter().map(|row| row.outcome).collect(); + let pooled = ordinary_least_squares_slope(&predictors, &outcomes).expect("pooled"); + assert!( + (recovered.contextual_effect - recovered.between_slope).abs() > 1e-9, + "Enders & Tofighi (2007, Table 2, pp. 124–127): CWC γ01 is contextual, not between" + ); + assert!( + (recovered.contextual_effect - pooled).abs() > 1e-9, + "pooled OLS must not be treated as the CWC contextual effect" + ); + assert!( + ((recovered.contextual_effect + recovered.within_slope) - recovered.between_slope).abs() + < 1e-15, + "adding CWC γ01 to γ10 must recover the between-cluster slope" + ); +} + +#[test] +fn time_varying_equation_fourteen_is_not_constant_equation_twelve() { + let outcome_on_predictor = 0.35_f64; + let delta = 1.5_f64; + let time_varying = recover_discrete_time_varying_predictor_effect( + outcome_on_predictor, + delta, + delta, + delta, + LagClock::EventTime, + ) + .expect("eq 14"); + let constant = recover_discrete_constant_predictor_effect( + outcome_on_predictor, + -0.4, + delta, + LagClock::EventTime, + ) + .expect("eq 12"); + assert!( + (time_varying - constant).abs() > 1e-3, + "Voelkle et al. (2012, manuscript p. 21): Eq. 14 a_yx Δt must not equal Eq. 12" + ); + assert!((time_varying - outcome_on_predictor * delta).abs() < 1e-15); +} + +#[test] +fn discrete_process_noise_is_not_the_continuous_diffusion() { + let diffusion = 0.4_f64; + let drift = -0.5_f64; + let delta = 1.0_f64; + let discrete = + recover_discrete_process_noise(diffusion, drift, delta, LagClock::EventTime).expect("q_dt"); + assert!( + (discrete - diffusion).abs() > 1e-3, + "Driver et al. (2017, Eq. 3, p. 4): Q_Δt must not equal continuous G G⊤" + ); + let expected = diffusion * ((2.0 * drift * delta).exp() - 1.0) / (2.0 * drift); + assert!((discrete - expected).abs() < 1e-15); + assert_eq!( + recover_discrete_process_noise(0.4, -0.5, f64::NAN, LagClock::EventTime), + Err(psychometric_core::PsychometricError::NonPositiveInterval) + ); + assert_eq!( + recover_discrete_process_noise(-0.1, -0.5, 1.0, LagClock::EventTime), + Err(psychometric_core::PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_process_noise(f64::NAN, -0.5, 1.0, LagClock::EventTime), + Err(psychometric_core::PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_discrete_process_noise(0.4, f64::NAN, 1.0, LagClock::EventTime), + Err(psychometric_core::PsychometricError::InvalidNumericInput) + ); + let constant = + recover_discrete_constant_predictor_effect(diffusion, drift, delta, LagClock::EventTime) + .expect("eq 12"); + assert!( + (discrete - constant).abs() > 1e-3, + "Driver Eq. 3 Q_Δt is not Voelkle Eq. 12" + ); +} + +#[test] +fn process_noise_is_not_the_unconditional_latent_variance() { + let prior = 2.0_f64; + let diffusion = 0.4_f64; + let drift = -0.5_f64; + let delta = 1.0_f64; + let process_noise = + recover_discrete_process_noise(diffusion, drift, delta, LagClock::EventTime).expect("q_dt"); + let lagged = + recover_discrete_lagged_latent_covariance(prior, drift, delta, LagClock::EventTime) + .expect("lagged"); + let latent = + recover_discrete_latent_variance(prior, diffusion, drift, delta, LagClock::EventTime) + .expect("var"); + assert!( + (process_noise - latent).abs() > 1e-3, + "Driver et al. (2017, Eq. 3-4, pp. 4-5): Q_Δt is cov(η_t | η_{{t-1}}), not Var(η_t)" + ); + assert!((lagged - (drift * delta).exp() * prior).abs() < 1e-15); + assert!((latent - ((2.0 * drift * delta).exp() * prior + process_noise)).abs() < 1e-15); + assert_eq!( + refuse_process_noise_as_unconditional_variance(process_noise, prior), + Err(psychometric_core::PsychometricError::ProcessNoiseIsConditionalVariance) + ); +} + +#[test] +fn finite_interval_process_noise_is_not_the_stationary_variance() { + let diffusion = 0.4_f64; + let drift = -0.5_f64; + let delta = 1.0_f64; + let process_noise = + recover_discrete_process_noise(diffusion, drift, delta, LagClock::EventTime).expect("q_dt"); + let stationary = + recover_stationary_latent_variance(diffusion, drift, LagClock::EventTime).expect("asym"); + assert!( + (process_noise - stationary).abs() > 1e-3, + "Driver et al. (2017, Eq. 4 / p. 16): finite-Δt Q_Δt is not asymDIFFUSION" + ); + let evolved = + recover_discrete_latent_variance(stationary, diffusion, drift, delta, LagClock::EventTime) + .expect("invariant"); + assert!((evolved - stationary).abs() < 1e-12); + assert_eq!( + refuse_finite_interval_process_noise_as_stationary_variance(process_noise, delta), + Err(psychometric_core::PsychometricError::FiniteIntervalProcessNoiseIsNotStationary) + ); + assert_eq!( + recover_stationary_latent_variance(diffusion, 0.0, LagClock::EventTime), + Err(psychometric_core::PsychometricError::StationaryVarianceRequiresStableDrift) + ); +} + +#[test] +fn trait_variance_is_not_process_noise_or_stationary_within_subject() { + let trait_variance = 1.5_f64; + let diffusion = 0.4_f64; + let drift = -0.5_f64; + let delta = 1.0_f64; + let state = + recover_stationary_latent_variance(diffusion, drift, LagClock::EventTime).expect("state"); + let total = recover_trait_plus_state_latent_variance(trait_variance, state).expect("sum"); + let process_noise = + recover_discrete_process_noise(diffusion, drift, delta, LagClock::EventTime).expect("q_dt"); + assert!( + (trait_variance - process_noise).abs() > 1e-3, + "Driver et al. (2017, §4.3, p. 9): TRAITVAR is not Q_Δt" + ); + assert!( + (trait_variance - state).abs() > 1e-3, + "Driver et al. (2017, §4.3, p. 9): TRAITVAR is not asymDIFFUSION" + ); + let evolved_as_state = + recover_discrete_latent_variance(total, diffusion, drift, delta, LagClock::EventTime) + .expect("wrong"); + assert!( + (evolved_as_state - total).abs() > 1e-3, + "Driver et al. (2017, §4.3): evolving trait+state as all-state is not the trait map" + ); + assert_eq!( + refuse_trait_variance_as_process_noise(trait_variance, process_noise), + Err(psychometric_core::PsychometricError::TraitVarianceIsNotProcessNoise) + ); + assert_eq!( + refuse_trait_variance_as_stationary_within_subject(trait_variance, state), + Err(psychometric_core::PsychometricError::TraitVarianceIsNotStationaryWithinSubject) + ); +} + +#[test] +fn measurement_error_and_latent_variance_are_not_the_observed_variance() { + let loading = 2.0_f64; + let latent = 0.4_f64; + let measurement_error = 0.1_f64; + let observed = + recover_manifest_observed_variance(loading, latent, measurement_error).expect("eq5"); + assert!( + (measurement_error - observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 / Table 2 p. 12): MANIFESTVAR is not Var(y)" + ); + assert!( + (latent - observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5): Var(η) is not Var(y)" + ); + assert_eq!( + refuse_measurement_error_as_observed_variance(measurement_error, observed), + Err(psychometric_core::PsychometricError::MeasurementErrorIsNotObservedVariance) + ); + assert_eq!( + refuse_latent_variance_as_observed_variance(latent, observed), + Err(psychometric_core::PsychometricError::LatentVarianceIsNotObservedVariance) + ); +} + +#[test] +fn manifest_trait_variance_is_not_measurement_error() { + let loading = 2.0_f64; + let latent = 0.4_f64; + let measurement_error = 0.1_f64; + let manifest_trait = 0.5_f64; + let observed = recover_manifest_trait_plus_state_observed_variance( + loading, + latent, + measurement_error, + manifest_trait, + ) + .expect("eq5-trait"); + let without_trait = + recover_manifest_observed_variance(loading, latent, measurement_error).expect("psi0"); + assert!( + (without_trait - observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 / Table 2 p. 12): MANIFESTTRAITVAR is not dropped" + ); + let stuffed = + recover_manifest_observed_variance(loading, latent, manifest_trait).expect("psi-as-theta"); + assert!( + (stuffed - observed).abs() > 1e-3, + "Driver et al. (2017, Table 2 p. 12): MANIFESTTRAITVAR is not MANIFESTVAR" + ); + let latent_trait = + recover_manifest_observed_variance(loading, latent + manifest_trait, measurement_error) + .expect("traitvar"); + assert!( + (latent_trait - observed).abs() > 1e-3, + "Driver et al. (2017, Table 2 p. 12): TRAITVAR is latent and scaled by λ²; MANIFESTTRAITVAR is not" + ); + assert_eq!( + refuse_manifest_trait_variance_as_measurement_error(manifest_trait, measurement_error), + Err(psychometric_core::PsychometricError::ManifestTraitVarianceIsNotMeasurementError) + ); +} + +#[test] +fn lagged_latent_covariance_and_measurement_error_are_not_lagged_observed_covariance() { + let loading = 2.0_f64; + let lagged = 0.4_f64; + let manifest_trait = 0.5_f64; + let measurement_error = 0.1_f64; + let observed = recover_manifest_lagged_observed_covariance(loading, lagged, manifest_trait) + .expect("eq5-lag"); + assert!( + (lagged - observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5): cov(η_t, η_{{t-1}}) is not cov(y_t, y_{{t-1}})" + ); + assert!( + (measurement_error - observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5): MANIFESTVAR does not enter lagged observed covariance" + ); + assert_eq!( + refuse_latent_lagged_covariance_as_observed_covariance(lagged, observed), + Err(psychometric_core::PsychometricError::LatentLaggedCovarianceIsNotObservedCovariance) + ); + assert_eq!( + refuse_measurement_error_as_lagged_observed_covariance(measurement_error, observed), + Err(psychometric_core::PsychometricError::MeasurementErrorIsNotLaggedObservedCovariance) + ); +} + +#[test] +fn manifest_means_and_latent_mean_are_not_observed_mean() { + let loading = 2.0_f64; + let latent_mean = 0.4_f64; + let manifest_mean = 0.5_f64; + let continuous_intercept = 0.3_f64; + let observed = + recover_manifest_observed_mean(loading, latent_mean, manifest_mean).expect("eq5-mean"); + assert!( + (manifest_mean - observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 / Table 2 p. 12): MANIFESTMEANS is not E(y)" + ); + assert!( + (latent_mean - observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5): E(η) is not E(y)" + ); + assert!( + (continuous_intercept - manifest_mean).abs() > 1e-3, + "Driver et al. (2017, Table 2 p. 12): CINT is not MANIFESTMEANS" + ); + assert_eq!( + refuse_manifest_means_as_observed_mean(manifest_mean, observed), + Err(psychometric_core::PsychometricError::ManifestMeansIsNotObservedMean) + ); + assert_eq!( + refuse_latent_mean_as_observed_mean(latent_mean, observed), + Err(psychometric_core::PsychometricError::LatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_continuous_intercept_as_manifest_means(continuous_intercept, manifest_mean), + Err(psychometric_core::PsychometricError::ContinuousInterceptIsNotManifestMeans) + ); +} + +#[test] +fn initial_latent_mean_and_continuous_intercept_are_not_evolved_mean() { + let drift = -0.5_f64; + let delta = 2.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let evolved = + recover_discrete_latent_mean(initial, drift, intercept, delta, LagClock::EventTime) + .expect("eq3-mean"); + let increment = + recover_discrete_continuous_intercept_effect(intercept, drift, delta, LagClock::EventTime) + .expect("cint"); + assert!( + (initial - evolved).abs() > 1e-3, + "Driver et al. (2017, Eq. 3 / Table 2 p. 12): T0MEANS is not μ_t" + ); + assert!( + (intercept - increment).abs() > 1e-3, + "Driver et al. (2017, Eq. 3 / Table 2 p. 12): CINT is not the discrete mean increment" + ); + assert!( + (intercept - initial).abs() > 1e-3, + "Driver et al. (2017, Table 2 p. 12): CINT is not T0MEANS" + ); + assert_eq!( + refuse_initial_latent_mean_as_evolved_mean(initial, evolved), + Err(psychometric_core::PsychometricError::InitialLatentMeanIsNotEvolvedMean) + ); + assert_eq!( + refuse_continuous_intercept_as_discrete_mean_increment(intercept, increment), + Err(psychometric_core::PsychometricError::ContinuousInterceptIsNotDiscreteMeanIncrement) + ); + assert_eq!( + refuse_continuous_intercept_as_initial_latent_mean(intercept, initial), + Err(psychometric_core::PsychometricError::ContinuousInterceptIsNotInitialLatentMean) + ); +} + +#[test] +fn first_occasion_observed_mean_is_not_evolved_observed_mean() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let first_occasion = + recover_manifest_observed_mean(loading, initial, manifest_mean).expect("t0"); + let evolved_latent = + recover_discrete_latent_mean(initial, drift, intercept, delta, LagClock::EventTime) + .expect("mu-t"); + assert!( + (first_occasion - evolved_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of Eq. 3): τ + λ μ_0 is not E(y_t)" + ); + assert!( + (manifest_mean - evolved_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 / Table 2 p. 12): MANIFESTMEANS is not E(y_t)" + ); + assert!( + (evolved_latent - evolved_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5): μ_t is not E(y_t)" + ); + assert_eq!( + refuse_initial_observed_mean_as_evolved_observed_mean(first_occasion, evolved_observed), + Err(psychometric_core::PsychometricError::InitialObservedMeanIsNotEvolvedObservedMean) + ); + assert_eq!( + refuse_latent_mean_as_observed_mean(evolved_latent, evolved_observed), + Err(psychometric_core::PsychometricError::LatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_manifest_means_as_observed_mean(manifest_mean, evolved_observed), + Err(psychometric_core::PsychometricError::ManifestMeansIsNotObservedMean) + ); +} + +#[test] +fn evolved_observed_mean_is_not_impulse_observed_mean() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let impulse_observed = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let composed = recover_discrete_latent_mean_with_impulse( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("mx"); + let carried_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + 1.0, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + assert!( + (evolved_observed - impulse_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of Eq. 3 impulse): τ + λ μ_t is not contemporaneous-impulse E(y_t)" + ); + assert!( + (manifest_mean - impulse_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 / Table 2 p. 12): MANIFESTMEANS is not contemporaneous-impulse E(y_t)" + ); + assert!( + (composed - impulse_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5): evolved-plus-impulse latent mean is not E(y_t)" + ); + assert!( + (carried_observed - impulse_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of Eq. 1–2): τ + λ(μ_t + carry) is not contemporaneous-impulse E(y_t)" + ); + assert_eq!( + refuse_evolved_observed_mean_as_impulse_observed_mean(evolved_observed, impulse_observed), + Err(psychometric_core::PsychometricError::EvolvedObservedMeanIsNotImpulseObservedMean) + ); + assert_eq!( + refuse_impulse_observed_mean_as_impulse_carry_observed_mean( + impulse_observed, + carried_observed + ), + Err(psychometric_core::PsychometricError::ImpulseObservedMeanIsNotImpulseCarryObservedMean) + ); + assert_eq!( + refuse_latent_mean_as_observed_mean(composed, impulse_observed), + Err(psychometric_core::PsychometricError::LatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_manifest_means_as_observed_mean(manifest_mean, impulse_observed), + Err(psychometric_core::PsychometricError::ManifestMeansIsNotObservedMean) + ); +} + +#[test] +fn time_dependent_impulse_is_not_cint_tipred_or_equation_fourteen() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("tdpred"); + let intercept_effect = + recover_discrete_continuous_intercept_effect(effect, drift, delta, LagClock::EventTime) + .expect("cint"); + let equation_fourteen = recover_discrete_time_varying_predictor_effect( + effect, + delta, + delta, + delta, + LagClock::EventTime, + ) + .expect("eq14"); + let evolved = + recover_discrete_latent_mean(1.0, drift, 0.3, delta, LagClock::EventTime).expect("mu-t"); + let composed = recover_discrete_latent_mean_with_impulse( + 1.0, + drift, + 0.3, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-impulse"); + assert!( + (impulse - effect).abs() > 1e-3, + "Driver et al. (2017, Eq. 3 / Table 2 p. 12): TDPREDEFFECT is not CINT" + ); + assert!( + (impulse - intercept_effect).abs() > 1e-3, + "Driver et al. (2017, Eq. 3): M x is not the time-independent discrete effect" + ); + assert!( + (impulse - equation_fourteen).abs() > 1e-3, + "Driver et al. (2017, Eq. 3): M x is not Voelkle Eq. 14" + ); + assert!( + (composed - evolved).abs() > 1e-3, + "Driver et al. (2017, Eq. 3): μ_t is not μ_t + M x" + ); + assert_eq!( + refuse_time_dependent_impulse_as_continuous_intercept(impulse, effect), + Err(psychometric_core::PsychometricError::TimeDependentImpulseIsNotContinuousIntercept) + ); + assert_eq!( + refuse_time_dependent_impulse_as_time_independent_effect(impulse, intercept_effect), + Err(psychometric_core::PsychometricError::TimeDependentImpulseIsNotTimeIndependentEffect) + ); + assert_eq!( + refuse_time_dependent_impulse_as_time_varying_discrete_effect(impulse, equation_fourteen), + Err(psychometric_core::PsychometricError::TimeDependentImpulseIsNotTimeVaryingDiscreteEffect) + ); +} + +#[test] +fn time_independent_predictor_is_not_cint_impulse_equation_fourteen_or_coefficient() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let increment = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("tipred"); + let intercept_effect = + recover_discrete_continuous_intercept_effect(effect, drift, delta, LagClock::EventTime) + .expect("cint"); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("tdpred"); + let equation_fourteen = recover_discrete_time_varying_predictor_effect( + effect, + delta, + delta, + delta, + LagClock::EventTime, + ) + .expect("eq14"); + let evolved = + recover_discrete_latent_mean(1.0, drift, 0.3, delta, LagClock::EventTime).expect("mu-t"); + let composed = recover_discrete_latent_mean_with_time_independent_predictor( + 1.0, + drift, + 0.3, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-tipred"); + assert!( + (increment - effect).abs() > 1e-3, + "Driver et al. (2017, Eq. 3 / Table 2 p. 12): TIPREDEFFECT is not the discrete increment" + ); + assert!( + (increment - intercept_effect).abs() > 1e-3, + "Driver et al. (2017, Eq. 3): A^{{-1}}[e^{{A Δt}} − I] B z is not CINT" + ); + assert!( + (increment - impulse).abs() > 1e-3, + "Driver et al. (2017, Eq. 3): A^{{-1}}[e^{{A Δt}} − I] B z is not M x" + ); + assert!( + (increment - equation_fourteen).abs() > 1e-3, + "Driver et al. (2017, Eq. 3): A^{{-1}}[e^{{A Δt}} − I] B z is not Voelkle Eq. 14" + ); + assert!( + (composed - evolved).abs() > 1e-3, + "Driver et al. (2017, Eq. 3): μ_t is not μ_t + A^{{-1}}[e^{{A Δt}} − I] B z" + ); + assert_eq!( + refuse_time_independent_effect_as_continuous_intercept(increment, effect), + Err(psychometric_core::PsychometricError::TimeIndependentEffectIsNotContinuousIntercept) + ); + assert_eq!( + refuse_time_independent_effect_as_time_dependent_impulse(increment, impulse), + Err(psychometric_core::PsychometricError::TimeIndependentEffectIsNotTimeDependentImpulse) + ); + assert_eq!( + refuse_time_independent_effect_as_time_varying_discrete_effect(increment, equation_fourteen), + Err(psychometric_core::PsychometricError::TimeIndependentEffectIsNotTimeVaryingDiscreteEffect) + ); + assert_eq!( + refuse_time_independent_coefficient_as_discrete_effect(effect, increment), + Err(psychometric_core::PsychometricError::TimeIndependentCoefficientIsNotDiscreteEffect) + ); +} + +#[test] +fn initial_time_independent_predictor_is_not_process_increment_cint_or_impulse() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let shift = + recover_initial_time_independent_predictor_effect(effect, predictor).expect("t0-tipred"); + let carry = recover_initial_time_independent_predictor_carry( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("t0-carry"); + let increment = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("tipred"); + let intercept_effect = + recover_discrete_continuous_intercept_effect(effect, drift, delta, LagClock::EventTime) + .expect("cint"); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("tdpred"); + let evolved = + recover_discrete_latent_mean(1.0, drift, 0.3, delta, LagClock::EventTime).expect("mu-t"); + let composed = recover_discrete_latent_mean_with_initial_time_independent_predictor( + 1.0, + drift, + 0.3, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-t0tipred"); + assert!( + (shift - effect).abs() > 1e-3, + "Driver et al. (2017, Table 3 p. 13): T0TIPREDEFFECT is not t0_b z" + ); + assert!( + (shift - increment).abs() > 1e-3, + "Driver et al. (2017, Table 3 / Eq. 3): t0_b z is not A^{{-1}}[e^{{A Δt}} − I] B z" + ); + assert!( + (carry - shift).abs() > 1e-3, + "Driver et al. (2017, Eq. 3): e^{{A Δt}} t0_b z is not t0_b z" + ); + assert!( + (carry - increment).abs() > 1e-3, + "Driver et al. (2017, Eq. 3): e^{{A Δt}} t0_b z is not A^{{-1}}[e^{{A Δt}} − I] B z" + ); + assert!( + (shift - intercept_effect).abs() > 1e-3, + "Driver et al. (2017, Table 3): t0_b z is not CINT" + ); + assert!( + (composed - evolved).abs() > 1e-3, + "Driver et al. (2017, Eq. 3): μ_t is not μ_t + e^{{A Δt}} t0_b z" + ); + assert_eq!( + refuse_initial_time_independent_effect_as_process_increment(shift, increment), + Err( + psychometric_core::PsychometricError::InitialTimeIndependentEffectIsNotProcessIncrement + ) + ); + assert_eq!( + refuse_initial_time_independent_carry_as_initial_effect(carry, shift), + Err(psychometric_core::PsychometricError::InitialTimeIndependentCarryIsNotInitialEffect) + ); + assert_eq!( + refuse_initial_time_independent_effect_as_continuous_intercept(shift, effect), + Err( + psychometric_core::PsychometricError::InitialTimeIndependentEffectIsNotContinuousIntercept + ) + ); + assert_eq!( + refuse_initial_time_independent_effect_as_time_dependent_impulse(shift, impulse), + Err( + psychometric_core::PsychometricError::InitialTimeIndependentEffectIsNotTimeDependentImpulse + ) + ); + assert_eq!( + refuse_initial_time_independent_coefficient_as_initial_effect(effect, shift), + Err( + psychometric_core::PsychometricError::InitialTimeIndependentCoefficientIsNotInitialEffect + ) + ); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn initial_time_dependent_predictor_is_not_impulse_cint_process_or_t0_tipred() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let shift = + recover_initial_time_dependent_predictor_effect(effect, predictor).expect("t0-tdpred"); + let carry = recover_initial_time_dependent_predictor_carry( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("t0-td-carry"); + let increment = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("tipred"); + let intercept_effect = + recover_discrete_continuous_intercept_effect(effect, drift, delta, LagClock::EventTime) + .expect("cint"); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("tdpred"); + let tipred_shift = + recover_initial_time_independent_predictor_effect(effect, predictor).expect("t0-tipred"); + let impulse_carry = recover_time_dependent_predictor_impulse_carry( + effect, + predictor, + drift, + delta, + 1.0, + LagClock::EventTime, + ) + .expect("td-carry"); + let evolved = + recover_discrete_latent_mean(1.0, drift, 0.3, delta, LagClock::EventTime).expect("mu-t"); + let composed = recover_discrete_latent_mean_with_initial_time_dependent_predictor( + 1.0, + drift, + 0.3, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-t0tdpred"); + assert!( + (shift - effect).abs() > 1e-3, + "Driver et al. (2017, Table 3 p. 13): T0TDPREDEFFECT is not t0_m x0" + ); + assert!( + (shift - increment).abs() > 1e-3, + "Driver et al. (2017, Table 3 / Eq. 3): t0_m x0 is not A^{{-1}}[e^{{A Δt}} − I] B z" + ); + assert!( + (carry - shift).abs() > 1e-3, + "Driver et al. (2017, Eq. 3): e^{{A Δt}} t0_m x0 is not t0_m x0" + ); + assert!( + (carry - impulse_carry).abs() > 1e-3, + "Driver et al. (2017, Eq. 3): e^{{A Δt}} t0_m x0 is not e^{{A(t−u)}} M x" + ); + assert!( + (shift - intercept_effect).abs() > 1e-3, + "Driver et al. (2017, Table 3): t0_m x0 is not CINT" + ); + assert!( + (composed - evolved).abs() > 1e-3, + "Driver et al. (2017, Eq. 3): μ_t is not μ_t + e^{{A Δt}} t0_m x0" + ); + assert_eq!( + refuse_initial_time_dependent_effect_as_contemporaneous_impulse(shift, impulse), + Err( + psychometric_core::PsychometricError::InitialTimeDependentEffectIsNotContemporaneousImpulse + ) + ); + assert_eq!( + refuse_initial_time_dependent_carry_as_initial_effect(carry, shift), + Err(psychometric_core::PsychometricError::InitialTimeDependentCarryIsNotInitialEffect) + ); + assert_eq!( + refuse_initial_time_dependent_effect_as_continuous_intercept(shift, effect), + Err( + psychometric_core::PsychometricError::InitialTimeDependentEffectIsNotContinuousIntercept + ) + ); + assert_eq!( + refuse_initial_time_dependent_effect_as_process_increment(shift, increment), + Err(psychometric_core::PsychometricError::InitialTimeDependentEffectIsNotProcessIncrement) + ); + assert_eq!( + refuse_initial_time_dependent_effect_as_initial_time_independent_effect(shift, tipred_shift), + Err( + psychometric_core::PsychometricError::InitialTimeDependentEffectIsNotInitialTimeIndependentEffect + ) + ); + assert_eq!( + refuse_initial_time_dependent_coefficient_as_initial_effect(effect, shift), + Err( + psychometric_core::PsychometricError::InitialTimeDependentCoefficientIsNotInitialEffect + ) + ); + assert_eq!( + refuse_initial_time_dependent_carry_as_impulse_carry(carry, impulse_carry), + Err(psychometric_core::PsychometricError::InitialTimeDependentCarryIsNotImpulseCarry) + ); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn evolved_and_process_observed_mean_are_not_initial_time_independent_observed_mean() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let initial_observed = recover_discrete_observed_mean_with_initial_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-t0tipred-mean"); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let process_observed = recover_discrete_observed_mean_with_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-tipred-mean"); + let impulse_observed = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + let carried_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + 1.0, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + let composed = recover_discrete_latent_mean_with_initial_time_independent_predictor( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-t0tipred"); + assert!( + (evolved_observed - initial_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of Table 3 T0TIPREDEFFECT): τ + λ μ_t is not T0TIPREDEFFECT E(y_t)" + ); + assert!( + (process_observed - initial_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5): TIPREDEFFECT E(y_t) is not T0TIPREDEFFECT E(y_t)" + ); + assert!( + (impulse_observed - initial_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5): τ + λ(μ_t + m x) is not T0TIPREDEFFECT E(y_t)" + ); + assert!( + (carried_observed - initial_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5): τ + λ(μ_t + carry) is not T0TIPREDEFFECT E(y_t)" + ); + assert!( + (composed - initial_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5): evolved-plus-T0TIPRED latent mean is not E(y_t)" + ); + assert!( + (manifest_mean - initial_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 / Table 2 p. 12): MANIFESTMEANS is not T0TIPREDEFFECT E(y_t)" + ); + assert_eq!( + refuse_evolved_observed_mean_as_initial_time_independent_observed_mean( + evolved_observed, + initial_observed + ), + Err( + psychometric_core::PsychometricError::EvolvedObservedMeanIsNotInitialTimeIndependentObservedMean + ) + ); + assert_eq!( + refuse_time_independent_observed_mean_as_initial_time_independent_observed_mean( + process_observed, + initial_observed + ), + Err( + psychometric_core::PsychometricError::TimeIndependentObservedMeanIsNotInitialTimeIndependentObservedMean + ) + ); + assert_eq!( + refuse_impulse_observed_mean_as_initial_time_independent_observed_mean( + impulse_observed, + initial_observed + ), + Err( + psychometric_core::PsychometricError::ImpulseObservedMeanIsNotInitialTimeIndependentObservedMean + ) + ); + assert_eq!( + refuse_impulse_carry_observed_mean_as_initial_time_independent_observed_mean( + carried_observed, + initial_observed + ), + Err( + psychometric_core::PsychometricError::ImpulseCarryObservedMeanIsNotInitialTimeIndependentObservedMean + ) + ); + assert_eq!( + refuse_latent_mean_as_observed_mean(composed, initial_observed), + Err(psychometric_core::PsychometricError::LatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_manifest_means_as_observed_mean(manifest_mean, initial_observed), + Err(psychometric_core::PsychometricError::ManifestMeansIsNotObservedMean) + ); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn evolved_and_process_observed_mean_are_not_initial_time_dependent_observed_mean() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let initial_observed = recover_discrete_observed_mean_with_initial_time_dependent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-t0tdpred-mean"); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let process_observed = recover_discrete_observed_mean_with_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-tipred-mean"); + let impulse_observed = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + let carried_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + 1.0, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + let tipred_observed = recover_discrete_observed_mean_with_initial_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-t0tipred-mean"); + let composed = recover_discrete_latent_mean_with_initial_time_dependent_predictor( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-t0tdpred"); + assert!( + (evolved_observed - initial_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of Table 3 T0TDPREDEFFECT): τ + λ μ_t is not T0TDPREDEFFECT E(y_t)" + ); + assert!( + (process_observed - initial_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5): TIPREDEFFECT E(y_t) is not T0TDPREDEFFECT E(y_t)" + ); + assert!( + (impulse_observed - initial_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5): τ + λ(μ_t + m x) is not T0TDPREDEFFECT E(y_t)" + ); + assert!( + (carried_observed - initial_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5): τ + λ(μ_t + carry) is not T0TDPREDEFFECT E(y_t)" + ); + assert!( + (composed - initial_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5): evolved-plus-T0TDPRED latent mean is not E(y_t)" + ); + assert!( + (manifest_mean - initial_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 / Table 2 p. 12): MANIFESTMEANS is not T0TDPREDEFFECT E(y_t)" + ); + // Same numbers as T0TIPRED yield the same product; Table 3 names a different matrix. + assert!((tipred_observed - initial_observed).abs() < 1e-15); + assert_eq!( + refuse_evolved_observed_mean_as_initial_time_dependent_observed_mean( + evolved_observed, + initial_observed + ), + Err( + psychometric_core::PsychometricError::EvolvedObservedMeanIsNotInitialTimeDependentObservedMean + ) + ); + assert_eq!( + refuse_time_independent_observed_mean_as_initial_time_dependent_observed_mean( + process_observed, + initial_observed + ), + Err( + psychometric_core::PsychometricError::TimeIndependentObservedMeanIsNotInitialTimeDependentObservedMean + ) + ); + assert_eq!( + refuse_impulse_observed_mean_as_initial_time_dependent_observed_mean( + impulse_observed, + initial_observed + ), + Err( + psychometric_core::PsychometricError::ImpulseObservedMeanIsNotInitialTimeDependentObservedMean + ) + ); + assert_eq!( + refuse_impulse_carry_observed_mean_as_initial_time_dependent_observed_mean( + carried_observed, + initial_observed + ), + Err( + psychometric_core::PsychometricError::ImpulseCarryObservedMeanIsNotInitialTimeDependentObservedMean + ) + ); + assert_eq!( + refuse_initial_time_independent_observed_mean_as_initial_time_dependent_observed_mean( + tipred_observed, + initial_observed + ), + Err( + psychometric_core::PsychometricError::InitialTimeIndependentObservedMeanIsNotInitialTimeDependentObservedMean + ) + ); + assert_eq!( + refuse_latent_mean_as_observed_mean(composed, initial_observed), + Err(psychometric_core::PsychometricError::LatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_manifest_means_as_observed_mean(manifest_mean, initial_observed), + Err(psychometric_core::PsychometricError::ManifestMeansIsNotObservedMean) + ); +} + +#[test] +fn time_dependent_impulse_carry_is_not_contemporaneous_cint_tipred_or_equation_fourteen() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let elapsed = 1.0_f64; + let carry = recover_time_dependent_predictor_impulse_carry( + effect, + predictor, + drift, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("tdpred-carry"); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("tdpred"); + let intercept_effect = + recover_discrete_continuous_intercept_effect(effect, drift, delta, LagClock::EventTime) + .expect("cint"); + let time_independent = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("tipred"); + let equation_fourteen = recover_discrete_time_varying_predictor_effect( + effect, + delta, + delta, + delta, + LagClock::EventTime, + ) + .expect("eq14"); + let evolved = + recover_discrete_latent_mean(1.0, drift, 0.3, delta, LagClock::EventTime).expect("mu-t"); + let composed = recover_discrete_latent_mean_with_impulse_carry( + 1.0, + drift, + 0.3, + effect, + predictor, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("eq3-carry"); + assert!( + (carry - impulse).abs() > 1e-3, + "Driver et al. (2017, Eq. 1–2 / §7.2): e^{{A(t−u)}} M x is not the contemporaneous Dirac" + ); + assert!( + (carry - intercept_effect).abs() > 1e-3, + "Driver et al. (2017, Eq. 1–2): e^{{A(t−u)}} M x is not CINT" + ); + assert!( + (carry - time_independent).abs() > 1e-3, + "Driver et al. (2017, Eq. 1–2): e^{{A(t−u)}} M x is not TIPREDEFFECT" + ); + assert!( + (carry - equation_fourteen).abs() > 1e-3, + "Driver et al. (2017, Eq. 1–2): e^{{A(t−u)}} M x is not Voelkle Eq. 14" + ); + assert!( + (composed - evolved).abs() > 1e-3, + "Driver et al. (2017, Eq. 1–2): μ_t is not μ_t + e^{{A(t−u)}} M x" + ); + assert_eq!( + refuse_time_dependent_impulse_carry_as_contemporaneous_impulse(carry, impulse), + Err(psychometric_core::PsychometricError::TimeDependentImpulseCarryIsNotContemporaneousImpulse) + ); + assert_eq!( + refuse_time_dependent_impulse_carry_as_continuous_intercept(carry, effect), + Err( + psychometric_core::PsychometricError::TimeDependentImpulseCarryIsNotContinuousIntercept + ) + ); + assert_eq!( + refuse_time_dependent_impulse_carry_as_time_independent_effect(carry, time_independent), + Err(psychometric_core::PsychometricError::TimeDependentImpulseCarryIsNotTimeIndependentEffect) + ); + assert_eq!( + refuse_time_dependent_impulse_carry_as_time_varying_discrete_effect( + carry, + equation_fourteen + ), + Err(psychometric_core::PsychometricError::TimeDependentImpulseCarryIsNotTimeVaryingDiscreteEffect) + ); +} + +#[test] +fn evolved_observed_mean_is_not_impulse_carry_observed_mean() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let elapsed = 1.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let impulse_carry_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let carried = recover_discrete_latent_mean_with_impulse_carry( + initial, + drift, + intercept, + effect, + predictor, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("carried"); + let contemporaneous = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-mx"); + assert!( + (evolved_observed - impulse_carry_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of Eq. 1–2): τ + λ μ_t is not impulse-carry E(y_t)" + ); + assert!( + (manifest_mean - impulse_carry_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 / Table 2 p. 12): MANIFESTMEANS is not impulse-carry E(y_t)" + ); + assert!( + (carried - impulse_carry_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5): carried latent mean is not E(y_t)" + ); + assert!( + (contemporaneous - impulse_carry_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of Eq. 1–2): τ + λ(μ_t + m x) is not impulse-carry E(y_t)" + ); + assert_eq!( + refuse_evolved_observed_mean_as_impulse_carry_observed_mean( + evolved_observed, + impulse_carry_observed + ), + Err(psychometric_core::PsychometricError::EvolvedObservedMeanIsNotImpulseCarryObservedMean) + ); + assert_eq!( + refuse_impulse_observed_mean_as_impulse_carry_observed_mean( + contemporaneous, + impulse_carry_observed + ), + Err(psychometric_core::PsychometricError::ImpulseObservedMeanIsNotImpulseCarryObservedMean) + ); + assert_eq!( + refuse_latent_mean_as_observed_mean(carried, impulse_carry_observed), + Err(psychometric_core::PsychometricError::LatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_manifest_means_as_observed_mean(manifest_mean, impulse_carry_observed), + Err(psychometric_core::PsychometricError::ManifestMeansIsNotObservedMean) + ); +} + +#[test] +fn evolved_observed_mean_is_not_time_independent_observed_mean() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let time_independent_observed = recover_discrete_observed_mean_with_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-tipred-mean"); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + drift, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let composed = recover_discrete_latent_mean_with_time_independent_predictor( + initial, + drift, + intercept, + effect, + predictor, + delta, + LagClock::EventTime, + ) + .expect("eq3-tipred"); + assert!( + (evolved_observed - time_independent_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of Eq. 3 TIPREDEFFECT): τ + λ μ_t is not TIPREDEFFECT E(y_t)" + ); + assert!( + (manifest_mean - time_independent_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 / Table 2 p. 12): MANIFESTMEANS is not TIPREDEFFECT E(y_t)" + ); + assert!( + (composed - time_independent_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5): evolved-plus-increment latent mean is not E(y_t)" + ); + assert_eq!( + refuse_evolved_observed_mean_as_time_independent_observed_mean( + evolved_observed, + time_independent_observed + ), + Err( + psychometric_core::PsychometricError::EvolvedObservedMeanIsNotTimeIndependentObservedMean + ) + ); + assert_eq!( + refuse_latent_mean_as_observed_mean(composed, time_independent_observed), + Err(psychometric_core::PsychometricError::LatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_manifest_means_as_observed_mean(manifest_mean, time_independent_observed), + Err(psychometric_core::PsychometricError::ManifestMeansIsNotObservedMean) + ); +} + +#[test] +fn impulse_and_carry_observed_mean_are_not_time_independent_observed_mean() { + let loading = 2.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let effect = 0.4_f64; + let predictor = 3.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let time_independent_observed = recover_discrete_observed_mean_with_time_independent_predictor( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-tipred-mean"); + let impulse_observed = recover_discrete_observed_mean_with_impulse( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + let carried_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + drift, + intercept, + effect, + predictor, + manifest_mean, + delta, + 1.0, + LagClock::EventTime, + ) + .expect("eq5-carry-mean"); + assert!( + (impulse_observed - time_independent_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of Eq. 3 impulse): τ + λ(μ_t + m x) is not TIPREDEFFECT E(y_t)" + ); + assert!( + (carried_observed - time_independent_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of Eq. 1–2): τ + λ(μ_t + carry) is not TIPREDEFFECT E(y_t)" + ); + assert_eq!( + refuse_impulse_observed_mean_as_time_independent_observed_mean( + impulse_observed, + time_independent_observed + ), + Err( + psychometric_core::PsychometricError::ImpulseObservedMeanIsNotTimeIndependentObservedMean + ) + ); + assert_eq!( + refuse_impulse_carry_observed_mean_as_time_independent_observed_mean( + carried_observed, + time_independent_observed + ), + Err( + psychometric_core::PsychometricError::ImpulseCarryObservedMeanIsNotTimeIndependentObservedMean + ) + ); +} + +#[test] +fn level_change_cint_is_not_impulse_free_cint_or_process_increment() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let intercept = + recover_level_change_continuous_intercept(effect, predictor, drift).expect("level-change"); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("impulse"); + let increment = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + drift, + 2.0, + LagClock::EventTime, + ) + .expect("tipred"); + assert!( + (intercept - impulse).abs() > 1e-3, + "Driver et al. (2017, §7.2): −a m x is not the dissipating Dirac m x" + ); + assert!( + (intercept - 0.3).abs() > 1e-3, + "Driver et al. (2017, §7.2): −a m x is not a free CINT" + ); + assert!( + (intercept - increment).abs() > 1e-3, + "Driver et al. (2017, §7.2): −a m x is not TIPREDEFFECT increment" + ); + assert_eq!( + refuse_level_change_intercept_as_impulse(intercept, impulse), + Err(psychometric_core::PsychometricError::LevelChangeInterceptIsNotImpulse) + ); + assert_eq!( + refuse_level_change_intercept_as_free_continuous_intercept(intercept, 0.3), + Err(psychometric_core::PsychometricError::LevelChangeInterceptIsNotFreeContinuousIntercept) + ); + assert_eq!( + refuse_level_change_intercept_as_process_increment(intercept, increment), + Err(psychometric_core::PsychometricError::LevelChangeInterceptIsNotProcessIncrement) + ); +} + +#[test] +fn level_change_increment_is_not_impulse_intercept_or_process_increment() { + let effect = 0.4_f64; + let predictor = 3.0_f64; + let drift = -0.5_f64; + let delta = 2.0_f64; + let intercept = + recover_level_change_continuous_intercept(effect, predictor, drift).expect("level-change"); + let increment = recover_level_change_discrete_increment( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("level-change-increment"); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("impulse"); + let tipred = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + drift, + delta, + LagClock::EventTime, + ) + .expect("tipred"); + assert!( + (increment - impulse).abs() > 1e-3, + "Driver et al. (2017, §7.2 / Eq. 3): (1 − e^{{aΔt}}) m x is not the dissipating Dirac m x" + ); + assert!( + (increment - intercept).abs() > 1e-3, + "Driver et al. (2017, §7.2 / Eq. 3): (1 − e^{{aΔt}}) m x is not κ" + ); + assert!( + (increment - tipred).abs() > 1e-3, + "Driver et al. (2017, §7.2 / Eq. 3): (1 − e^{{aΔt}}) m x is not TIPREDEFFECT increment" + ); + assert_eq!( + refuse_level_change_increment_as_impulse(increment, impulse), + Err(psychometric_core::PsychometricError::LevelChangeIncrementIsNotImpulse) + ); + assert_eq!( + refuse_level_change_increment_as_intercept(increment, intercept), + Err(psychometric_core::PsychometricError::LevelChangeIncrementIsNotIntercept) + ); + assert_eq!( + refuse_level_change_increment_as_process_increment(increment, tipred), + Err(psychometric_core::PsychometricError::LevelChangeIncrementIsNotProcessIncrement) + ); +} + +#[test] +fn extra_process_contribution_is_not_cint_rewrite_increment_or_impulse() { + let coupling = 0.4_f64; + let predictor = 3.0_f64; + let original = -0.5_f64; + let extra = -0.05_f64; + let delta = 2.0_f64; + let recovered = recover_level_change_extra_process_contribution( + coupling, + predictor, + original, + extra, + delta, + LagClock::EventTime, + ) + .expect("extra-process"); + let intercept = + recover_level_change_continuous_intercept(coupling, predictor, original).expect("cint"); + let increment = recover_level_change_discrete_increment( + coupling, + predictor, + original, + delta, + LagClock::EventTime, + ) + .expect("increment"); + let impulse = recover_time_dependent_predictor_impulse(coupling, predictor).expect("impulse"); + assert!( + (recovered - intercept).abs() > 1e-3, + "Driver et al. (2017, §7.2 pp. 22–23): extra-process contribution is not κ = −a m x" + ); + assert!( + (recovered - increment).abs() > 1e-3, + "Driver et al. (2017, §7.2 pp. 22–23): extra-process contribution is not (1 − e^{{aΔt}}) m x" + ); + assert!( + (recovered - impulse).abs() > 1e-3, + "Driver et al. (2017, §7.2 pp. 22–23): extra-process contribution is not the dissipating Dirac m x" + ); + assert_eq!( + refuse_level_change_extra_process_as_impulse(recovered, impulse), + Err(psychometric_core::PsychometricError::LevelChangeExtraProcessIsNotImpulse) + ); + assert_eq!( + refuse_level_change_extra_process_as_intercept(recovered, intercept), + Err(psychometric_core::PsychometricError::LevelChangeExtraProcessIsNotIntercept) + ); + assert_eq!( + refuse_level_change_extra_process_as_increment(recovered, increment), + Err(psychometric_core::PsychometricError::LevelChangeExtraProcessIsNotIncrement) + ); +} + +#[test] +fn extra_process_observed_mean_is_not_evolved_mean_impulse_mean_or_contribution() { + let loading = 2.0_f64; + let coupling = 0.4_f64; + let predictor = 3.0_f64; + let original = -0.5_f64; + let extra = -0.05_f64; + let delta = 2.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean_with_extra_process( + loading, + initial, + original, + intercept, + coupling, + predictor, + extra, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-extra-process-mean"); + let composed = recover_discrete_latent_mean_with_extra_process( + initial, + original, + intercept, + coupling, + predictor, + extra, + delta, + LagClock::EventTime, + ) + .expect("extra-latent"); + let contribution = recover_level_change_extra_process_contribution( + coupling, + predictor, + original, + extra, + delta, + LagClock::EventTime, + ) + .expect("extra-process"); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + original, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let impulse_observed = recover_discrete_observed_mean_with_impulse( + loading, + initial, + original, + intercept, + coupling, + predictor, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-impulse-mean"); + assert!( + (observed - evolved_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of §7.2): extra-process E(y_t) is not τ + λ μ_t" + ); + assert!( + (observed - impulse_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of §7.2): extra-process E(y_t) is not τ + λ(μ_t + m x)" + ); + assert!( + (observed - contribution).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of §7.2): extra-process contribution is not E(y_t)" + ); + assert!( + (observed - composed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of §7.2): evolved-plus-contribution latent mean is not E(y_t)" + ); + assert_eq!( + refuse_evolved_observed_mean_as_extra_process_observed_mean(evolved_observed, observed), + Err(psychometric_core::PsychometricError::EvolvedObservedMeanIsNotExtraProcessObservedMean) + ); + assert_eq!( + refuse_impulse_observed_mean_as_extra_process_observed_mean(impulse_observed, observed), + Err(psychometric_core::PsychometricError::ImpulseObservedMeanIsNotExtraProcessObservedMean) + ); + assert_eq!( + refuse_extra_process_contribution_as_observed_mean(contribution, observed), + Err(psychometric_core::PsychometricError::ExtraProcessContributionIsNotObservedMean) + ); + assert_eq!( + refuse_extra_process_latent_mean_as_observed_mean(composed, observed), + Err(psychometric_core::PsychometricError::ExtraProcessLatentMeanIsNotObservedMean) + ); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn after_extra_process_observed_mean_is_not_t0_extra_evolved_or_impulse_carry() { + let loading = 2.0_f64; + let coupling = 0.4_f64; + let predictor = 3.0_f64; + let original = -0.5_f64; + let extra = -0.05_f64; + let delta = 2.0_f64; + let elapsed = 1.0_f64; + let initial = 1.0_f64; + let intercept = 0.3_f64; + let manifest_mean = 0.5_f64; + let observed = recover_discrete_observed_mean_with_extra_process_after( + loading, + initial, + original, + intercept, + coupling, + predictor, + extra, + manifest_mean, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("eq5-after-extra-process-mean"); + let composed = recover_discrete_latent_mean_with_extra_process_after( + initial, + original, + intercept, + coupling, + predictor, + extra, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("after-extra-latent"); + let contribution = recover_level_change_extra_process_contribution_after( + coupling, + predictor, + original, + extra, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("after-extra-process"); + let first_occasion = recover_discrete_observed_mean_with_extra_process( + loading, + initial, + original, + intercept, + coupling, + predictor, + extra, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq5-t0-extra-process-mean"); + let evolved_observed = recover_discrete_observed_mean( + loading, + initial, + original, + intercept, + manifest_mean, + delta, + LagClock::EventTime, + ) + .expect("eq3-eq5-mean"); + let carry_observed = recover_discrete_observed_mean_with_impulse_carry( + loading, + initial, + original, + intercept, + coupling, + predictor, + manifest_mean, + delta, + elapsed, + LagClock::EventTime, + ) + .expect("eq5-impulse-carry-mean"); + assert!( + (observed - first_occasion).abs() > 1e-3, + "Driver et al. (2017, §7.2): T0TDPREDEFFECT extra E(y_t) is not after-t0 E(y_t)" + ); + assert!( + (observed - evolved_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of §7.2 after t0): after-t0 E(y_t) is not τ + λ μ_t" + ); + assert!( + (observed - carry_observed).abs() > 1e-3, + "Driver et al. (2017, §7.2): e^{{a(t-u)}} m x is not extra-process DRIFT drive" + ); + assert!((observed - contribution).abs() > 1e-3); + assert!((observed - composed).abs() > 1e-3); + assert_eq!( + refuse_extra_process_observed_mean_as_after_extra_process_observed_mean( + first_occasion, + observed + ), + Err( + psychometric_core::PsychometricError::ExtraProcessObservedMeanIsNotAfterExtraProcessObservedMean + ) + ); + assert_eq!( + refuse_evolved_observed_mean_as_after_extra_process_observed_mean( + evolved_observed, + observed + ), + Err( + psychometric_core::PsychometricError::EvolvedObservedMeanIsNotAfterExtraProcessObservedMean + ) + ); + assert_eq!( + refuse_impulse_carry_observed_mean_as_after_extra_process_observed_mean( + carry_observed, + observed + ), + Err( + psychometric_core::PsychometricError::ImpulseCarryObservedMeanIsNotAfterExtraProcessObservedMean + ) + ); + assert_eq!( + refuse_after_extra_process_contribution_as_observed_mean(contribution, observed), + Err(psychometric_core::PsychometricError::AfterExtraProcessContributionIsNotObservedMean) + ); + assert_eq!( + refuse_after_extra_process_latent_mean_as_observed_mean(composed, observed), + Err(psychometric_core::PsychometricError::AfterExtraProcessLatentMeanIsNotObservedMean) + ); +} + +#[test] +fn asymptotic_time_independent_effect_is_not_coefficient_discrete_cint_or_impulse() { + let effect = -0.225_f64; + let predictor = 2.0_f64; + let log_rate = -0.134_488_942_f64; + let recovered = recover_asymptotic_time_independent_predictor_effect( + effect, + predictor, + log_rate, + LagClock::EventTime, + ) + .expect("asymTIPREDEFFECT"); + let discrete = recover_discrete_time_independent_predictor_effect( + effect, + predictor, + log_rate, + 1.0, + LagClock::EventTime, + ) + .expect("discreteTIPREDEFFECT"); + let impulse = recover_time_dependent_predictor_impulse(effect, predictor).expect("impulse"); + assert!( + (recovered - effect).abs() > 1e-3, + "Driver et al. (2017, §7.2, pp. 20–21): asymTIPREDEFFECT is not TIPREDEFFECT B" + ); + assert!( + (recovered - discrete).abs() > 1e-3, + "Driver et al. (2017, §7.2): -B z / a is not A^{{-1}}[e^{{A Δt}} − I] B z" + ); + assert!( + (recovered - impulse).abs() > 1e-3, + "Driver et al. (2017, §7.2): -B z / a is not M x" + ); + assert_eq!( + refuse_asymptotic_time_independent_effect_as_coefficient(recovered, effect), + Err(psychometric_core::PsychometricError::AsymptoticTimeIndependentEffectIsNotCoefficient) + ); + assert_eq!( + refuse_asymptotic_time_independent_effect_as_discrete_effect(recovered, discrete), + Err( + psychometric_core::PsychometricError::AsymptoticTimeIndependentEffectIsNotDiscreteEffect + ) + ); + assert_eq!( + refuse_asymptotic_time_independent_effect_as_continuous_intercept(recovered, 0.3), + Err( + psychometric_core::PsychometricError::AsymptoticTimeIndependentEffectIsNotContinuousIntercept + ) + ); + assert_eq!( + refuse_asymptotic_time_independent_effect_as_time_dependent_impulse(recovered, impulse), + Err( + psychometric_core::PsychometricError::AsymptoticTimeIndependentEffectIsNotTimeDependentImpulse + ) + ); +} + +#[test] +fn asymptotic_time_independent_variance_is_not_trait_stationary_or_mean_effect() { + let effect = -0.225_f64; + let log_rate = -0.134_488_942_f64; + let recovered = recover_asymptotic_time_independent_predictor_variance( + effect, + 2.0, + log_rate, + LagClock::EventTime, + ) + .expect("addedTIPREDVAR"); + let mean_effect = recover_asymptotic_time_independent_predictor_effect( + effect, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("asymTIPREDEFFECT"); + let stationary = recover_stationary_latent_variance(0.4, log_rate, LagClock::EventTime) + .expect("asymDIFFUSION"); + let trait_plus = recover_trait_plus_state_latent_variance(0.8, 0.3).expect("trait"); + assert!( + (recovered - mean_effect).abs() > 1e-3, + "Driver et al. (2017, §7.2, pp. 20–21): addedTIPREDVAR is not asymTIPREDEFFECT" + ); + assert!( + (recovered - stationary).abs() > 1e-3, + "Driver et al. (2017, §7.2): addedTIPREDVAR is not asymDIFFUSION" + ); + assert!( + (recovered - trait_plus).abs() > 1e-3, + "Driver et al. (2017, §7.2): addedTIPREDVAR is not TRAITVAR" + ); + assert_eq!( + refuse_asymptotic_time_independent_variance_as_trait_variance(recovered, trait_plus), + Err( + psychometric_core::PsychometricError::AsymptoticTimeIndependentVarianceIsNotTraitVariance + ) + ); + assert_eq!( + refuse_asymptotic_time_independent_variance_as_stationary_within_subject( + recovered, + stationary + ), + Err( + psychometric_core::PsychometricError::AsymptoticTimeIndependentVarianceIsNotStationaryWithinSubject + ) + ); + assert_eq!( + refuse_asymptotic_time_independent_variance_as_asymptotic_effect(recovered, mean_effect), + Err( + psychometric_core::PsychometricError::AsymptoticTimeIndependentVarianceIsNotAsymptoticEffect + ) + ); +} + +#[test] +fn asymptotic_continuous_intercept_is_not_cint_increment_t0_or_tipred() { + let intercept = 0.3_f64; + let log_rate = -0.134_488_942_f64; + let recovered = + recover_asymptotic_continuous_intercept(intercept, log_rate, LagClock::EventTime) + .expect("asymCINT"); + let discrete = + recover_discrete_continuous_intercept_effect(intercept, log_rate, 1.0, LagClock::EventTime) + .expect("dtCINT"); + let tipred = recover_asymptotic_time_independent_predictor_effect( + -0.225, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("asymTIPREDEFFECT"); + assert!( + (recovered - intercept).abs() > 1e-3, + "Driver et al. (2017, Table 2, p. 12): asymCINT is not CINT" + ); + assert!( + (recovered - discrete).abs() > 1e-3, + "Driver et al. (2017, Table 2): -κ / a is not A^{{-1}}[e^{{A Δt}} − I] κ" + ); + assert!( + (recovered - 2.823).abs() > 1e-3, + "Driver et al. (2017, Table 2): -κ / a is not T0MEANS" + ); + assert!( + (recovered - tipred).abs() > 1e-3, + "Driver et al. (2017, Table 2): -κ / a is not -B z / a" + ); + assert_eq!( + refuse_asymptotic_continuous_intercept_as_continuous_intercept(recovered, intercept), + Err( + psychometric_core::PsychometricError::AsymptoticContinuousInterceptIsNotContinuousIntercept + ) + ); + assert_eq!( + refuse_asymptotic_continuous_intercept_as_discrete_increment(recovered, discrete), + Err( + psychometric_core::PsychometricError::AsymptoticContinuousInterceptIsNotDiscreteIncrement + ) + ); + assert_eq!( + refuse_asymptotic_continuous_intercept_as_initial_latent_mean(recovered, 2.823), + Err( + psychometric_core::PsychometricError::AsymptoticContinuousInterceptIsNotInitialLatentMean + ) + ); + assert_eq!( + refuse_asymptotic_continuous_intercept_as_asymptotic_time_independent_effect( + recovered, tipred + ), + Err( + psychometric_core::PsychometricError::AsymptoticContinuousInterceptIsNotAsymptoticTimeIndependentEffect + ) + ); +} + +#[test] +fn stationary_initial_latent_mean_is_not_t0_cint_tipred_or_discrete() { + let intercept = 0.3_f64; + let log_rate = -0.134_488_942_f64; + let recovered = recover_stationary_initial_latent_mean( + intercept, + -0.225, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0MEANS"); + let intercept_only = + recover_asymptotic_continuous_intercept(intercept, log_rate, LagClock::EventTime) + .expect("asymCINT"); + let tipred = recover_asymptotic_time_independent_predictor_effect( + -0.225, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("asymTIPREDEFFECT"); + let discrete = + recover_discrete_latent_mean(2.823, log_rate, intercept, 1.0, LagClock::EventTime) + .expect("μ_t"); + assert!( + (recovered - 2.823).abs() > 1e-3, + "Driver et al. (2017, p. 16): constrained T0MEANS is not free T0MEANS" + ); + assert!( + (recovered - intercept_only).abs() > 1e-3, + "Driver et al. (2017, p. 16): constrained T0MEANS is not asymCINT" + ); + assert!( + (recovered - tipred).abs() > 1e-3, + "Driver et al. (2017, p. 16): constrained T0MEANS is not asymTIPREDEFFECT" + ); + assert!( + (recovered - discrete).abs() > 1e-3, + "Driver et al. (2017, p. 16): constrained T0MEANS is not μ_t" + ); + assert_eq!( + refuse_stationary_initial_latent_mean_as_initial_latent_mean(recovered, 2.823), + Err( + psychometric_core::PsychometricError::StationaryInitialLatentMeanIsNotInitialLatentMean + ) + ); + assert_eq!( + refuse_stationary_initial_latent_mean_as_asymptotic_continuous_intercept( + recovered, + intercept_only + ), + Err( + psychometric_core::PsychometricError::StationaryInitialLatentMeanIsNotAsymptoticContinuousIntercept + ) + ); + assert_eq!( + refuse_stationary_initial_latent_mean_as_asymptotic_time_independent_effect( + recovered, tipred + ), + Err( + psychometric_core::PsychometricError::StationaryInitialLatentMeanIsNotAsymptoticTimeIndependentEffect + ) + ); + assert_eq!( + refuse_stationary_initial_latent_mean_as_discrete_mean(recovered, discrete), + Err(psychometric_core::PsychometricError::StationaryInitialLatentMeanIsNotDiscreteMean) + ); +} + +#[test] +fn stationary_initial_observed_mean_is_not_manifest_latent_evolved_or_free() { + let intercept = 0.3_f64; + let log_rate = -0.134_488_942_f64; + let loading = 2.0_f64; + let manifest_mean = 0.5_f64; + let recovered = recover_stationary_initial_observed_mean( + loading, + intercept, + -0.225, + 1.0, + log_rate, + manifest_mean, + LagClock::EventTime, + ) + .expect("eq5-stationary-T0MEANS"); + let latent = recover_stationary_initial_latent_mean( + intercept, + -0.225, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0MEANS"); + let intercept_only = + recover_asymptotic_continuous_intercept(intercept, log_rate, LagClock::EventTime) + .expect("asymCINT"); + let intercept_only_observed = + recover_manifest_observed_mean(loading, intercept_only, manifest_mean).expect("τ+λ(−κ/a)"); + let free_initial_observed = + recover_manifest_observed_mean(loading, 2.823, manifest_mean).expect("τ+λμ_0"); + let evolved = recover_discrete_observed_mean( + loading, + 2.823, + log_rate, + intercept, + manifest_mean, + 1.0, + LagClock::EventTime, + ) + .expect("τ+λμ_t"); + assert!( + (recovered - manifest_mean).abs() > 1e-3, + "Driver et al. (2017, §4.3 / Eq. 5): E(y_0) is not MANIFESTMEANS" + ); + assert!( + (recovered - latent).abs() > 1e-3, + "Driver et al. (2017, §4.3 / Eq. 5): E(y_0) is not the constrained latent mean" + ); + assert!( + (recovered - intercept_only_observed).abs() > 1e-3, + "Driver et al. (2017, §4.3 / Eq. 5): E(y_0) is not τ + λ(−κ / a)" + ); + assert!( + (recovered - free_initial_observed).abs() > 1e-3, + "Driver et al. (2017, §4.3 / Eq. 5): E(y_0) is not τ + λ μ_0" + ); + assert!( + (recovered - evolved).abs() > 1e-3, + "Driver et al. (2017, §4.3 / Eq. 5): E(y_0) is not τ + λ μ_t" + ); + assert_eq!( + refuse_stationary_initial_latent_mean_as_observed_mean(latent, recovered), + Err(psychometric_core::PsychometricError::StationaryInitialLatentMeanIsNotObservedMean) + ); + assert_eq!( + refuse_stationary_initial_observed_mean_as_manifest_means(recovered, manifest_mean), + Err(psychometric_core::PsychometricError::StationaryInitialObservedMeanIsNotManifestMeans) + ); + assert_eq!( + refuse_evolved_observed_mean_as_stationary_initial_observed_mean(evolved, recovered), + Err( + psychometric_core::PsychometricError::EvolvedObservedMeanIsNotStationaryInitialObservedMean + ) + ); + assert_eq!( + refuse_asymptotic_continuous_intercept_observed_mean_as_stationary_initial_observed_mean( + intercept_only_observed, + recovered + ), + Err( + psychometric_core::PsychometricError::AsymptoticContinuousInterceptObservedMeanIsNotStationaryInitialObservedMean + ) + ); + assert_eq!( + refuse_initial_observed_mean_as_stationary_initial_observed_mean( + free_initial_observed, + recovered + ), + Err( + psychometric_core::PsychometricError::InitialObservedMeanIsNotStationaryInitialObservedMean + ) + ); +} + +#[test] +fn stationary_initial_latent_variance_is_not_t0_state_trait_tipred_or_discrete() { + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let log_rate = -0.134_488_942_f64; + let recovered = recover_stationary_initial_latent_variance( + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0VAR"); + let state = recover_stationary_latent_variance(diffusion, log_rate, LagClock::EventTime) + .expect("asymDIFFUSION"); + let added = recover_asymptotic_time_independent_predictor_variance( + -0.225, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("addedTIPREDVAR"); + let discrete = + recover_discrete_latent_variance(recovered, diffusion, log_rate, 1.0, LagClock::EventTime) + .expect("Var(η_t)"); + assert!( + (recovered - 2.0).abs() > 1e-3, + "Driver et al. (2017, §4.3 / p. 16): constrained T0VAR is not free T0VAR" + ); + assert!( + (recovered - state).abs() > 1e-3, + "Driver et al. (2017, §4.3 / p. 16): constrained T0VAR is not asymDIFFUSION" + ); + assert!( + (recovered - trait_variance).abs() > 1e-3, + "Driver et al. (2017, §4.3 / p. 16): constrained T0VAR is not TRAITVAR" + ); + assert!( + (recovered - added).abs() > 1e-3, + "Driver et al. (2017, §4.3 / p. 16): constrained T0VAR is not addedTIPREDVAR" + ); + assert!( + (recovered - discrete).abs() > 1e-3, + "Driver et al. (2017, §4.3 / p. 16): constrained T0VAR is not Var(η_t)" + ); + assert!( + (recovered - 2.838).abs() > 1e-3, + "Driver et al. (2017, §7.2): printed 2-latent addedTIPREDVAR is not this scalar map" + ); + assert_eq!( + refuse_stationary_initial_latent_variance_as_initial_latent_variance(recovered, 2.0), + Err( + psychometric_core::PsychometricError::StationaryInitialLatentVarianceIsNotInitialLatentVariance + ) + ); + assert_eq!( + refuse_stationary_initial_latent_variance_as_stationary_within_subject(recovered, state), + Err( + psychometric_core::PsychometricError::StationaryInitialLatentVarianceIsNotStationaryWithinSubject + ) + ); + assert_eq!( + refuse_stationary_initial_latent_variance_as_trait_variance(recovered, trait_variance), + Err( + psychometric_core::PsychometricError::StationaryInitialLatentVarianceIsNotTraitVariance + ) + ); + assert_eq!( + refuse_stationary_initial_latent_variance_as_asymptotic_time_independent_variance( + recovered, added + ), + Err( + psychometric_core::PsychometricError::StationaryInitialLatentVarianceIsNotAsymptoticTimeIndependentVariance + ) + ); + assert_eq!( + refuse_stationary_initial_latent_variance_as_discrete_variance(recovered, discrete), + Err( + psychometric_core::PsychometricError::StationaryInitialLatentVarianceIsNotDiscreteVariance + ) + ); +} + +#[test] +fn stationary_initial_observed_variance_is_not_manifest_latent_evolved_or_free() { + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let log_rate = -0.134_488_942_f64; + let loading = 2.0_f64; + let measurement_error = 0.5_f64; + let recovered = recover_stationary_initial_observed_variance( + loading, + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + measurement_error, + 0.1, + LagClock::EventTime, + ) + .expect("eq5-stationary-T0VAR"); + let latent = recover_stationary_initial_latent_variance( + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0VAR"); + let state = recover_stationary_latent_variance(diffusion, log_rate, LagClock::EventTime) + .expect("asymDIFFUSION"); + let state_only_observed = + recover_manifest_observed_variance(loading, state, measurement_error).expect("λ²(−q/2a)+θ"); + let free_initial_observed = + recover_manifest_observed_variance(loading, 2.0, measurement_error).expect("λ²p_0+θ"); + let discrete = + recover_discrete_latent_variance(latent, diffusion, log_rate, 1.0, LagClock::EventTime) + .expect("Var(η_t)"); + let evolved = recover_manifest_observed_variance(loading, discrete, measurement_error) + .expect("λ²Var(η_t)+θ"); + assert!( + (recovered - measurement_error).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of §4.3 T0VAR): Var(y_0) is not MANIFESTVAR" + ); + assert!( + (recovered - latent).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of §4.3 T0VAR): Var(y_0) is not constrained T0VAR" + ); + assert!( + (recovered - state_only_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of §4.3 T0VAR): Var(y_0) is not λ² asymDIFFUSION + θ" + ); + assert!( + (recovered - free_initial_observed).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of §4.3 T0VAR): Var(y_0) is not λ² free T0VAR + θ" + ); + assert!( + (recovered - evolved).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of §4.3 T0VAR): Var(y_0) is not λ² Var(η_t) + θ" + ); + assert_eq!( + refuse_stationary_initial_latent_variance_as_observed_variance(latent, recovered), + Err( + psychometric_core::PsychometricError::StationaryInitialLatentVarianceIsNotObservedVariance + ) + ); + assert_eq!( + refuse_stationary_initial_observed_variance_as_measurement_error( + recovered, + measurement_error + ), + Err( + psychometric_core::PsychometricError::StationaryInitialObservedVarianceIsNotMeasurementError + ) + ); + assert_eq!( + refuse_evolved_observed_variance_as_stationary_initial_observed_variance( + evolved, recovered + ), + Err( + psychometric_core::PsychometricError::EvolvedObservedVarianceIsNotStationaryInitialObservedVariance + ) + ); + assert_eq!( + refuse_stationary_within_subject_observed_variance_as_stationary_initial_observed_variance( + state_only_observed, + recovered + ), + Err( + psychometric_core::PsychometricError::StationaryWithinSubjectObservedVarianceIsNotStationaryInitialObservedVariance + ) + ); + assert_eq!( + refuse_initial_observed_variance_as_stationary_initial_observed_variance( + free_initial_observed, + recovered + ), + Err( + psychometric_core::PsychometricError::InitialObservedVarianceIsNotStationaryInitialObservedVariance + ) + ); +} + +#[test] +fn stationary_lagged_latent_covariance_is_not_contemporaneous_decayed_or_trait_state() { + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let log_rate = -0.134_488_942_f64; + let event_delta = 1.0_f64; + let recovered = recover_stationary_lagged_latent_covariance( + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("stationary lagged T0VAR"); + let contemporaneous = recover_stationary_initial_latent_variance( + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0VAR"); + let decayed = recover_discrete_lagged_latent_covariance( + contemporaneous, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("e^{aΔt} p_stat"); + let state = recover_stationary_latent_variance(diffusion, log_rate, LagClock::EventTime) + .expect("asymDIFFUSION"); + let trait_plus_state = recover_trait_plus_state_lagged_covariance( + trait_variance, + state, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("trait+state lagged"); + assert!( + (recovered - contemporaneous).abs() > 1e-3, + "Driver et al. (2017, Eq. 3–4 of §4.3 T0VAR): lagged covariance is not contemporaneous T0VAR" + ); + assert!( + (recovered - decayed).abs() > 1e-3, + "Driver et al. (2017, Eq. 3–4 of §4.3 T0VAR): trait and addedTIPREDVAR do not decay" + ); + assert!( + (recovered - trait_plus_state).abs() > 1e-3, + "Driver et al. (2017, Eq. 3–4 of §4.3 T0VAR): lagged T0VAR is not trait-plus-state lagged" + ); + assert_eq!( + refuse_stationary_lagged_latent_covariance_as_stationary_initial_latent_variance( + recovered, + contemporaneous + ), + Err( + psychometric_core::PsychometricError::StationaryLaggedLatentCovarianceIsNotStationaryInitialLatentVariance + ) + ); + assert_eq!( + refuse_stationary_lagged_latent_covariance_as_decayed_stationary_variance( + recovered, decayed + ), + Err( + psychometric_core::PsychometricError::StationaryLaggedLatentCovarianceIsNotDecayedStationaryVariance + ) + ); + assert_eq!( + refuse_trait_plus_state_lagged_covariance_as_stationary_lagged_latent_covariance( + trait_plus_state, + recovered + ), + Err( + psychometric_core::PsychometricError::TraitPlusStateLaggedCovarianceIsNotStationaryLaggedLatentCovariance + ) + ); +} + +#[test] +fn stationary_lagged_observed_covariance_is_not_manifest_latent_or_contemporaneous() { + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let log_rate = -0.134_488_942_f64; + let loading = 2.0_f64; + let measurement_error = 0.5_f64; + let event_delta = 1.0_f64; + let recovered = recover_stationary_lagged_observed_covariance( + loading, + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + event_delta, + 0.1, + LagClock::EventTime, + ) + .expect("eq5-lagged-stationary-T0VAR"); + let latent = recover_stationary_lagged_latent_covariance( + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("stationary lagged T0VAR"); + let contemporaneous = recover_stationary_initial_observed_variance( + loading, + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + measurement_error, + 0.1, + LagClock::EventTime, + ) + .expect("eq5-stationary-T0VAR"); + assert!( + (recovered - measurement_error).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of lagged §4.3 T0VAR): lagged cov(y) is not MANIFESTVAR" + ); + assert!( + (recovered - latent).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of lagged §4.3 T0VAR): lagged cov(y) is not lagged T0VAR" + ); + assert!( + (recovered - contemporaneous).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of lagged §4.3 T0VAR): lagged cov(y) is not Var(y_0)" + ); + assert_eq!( + refuse_stationary_lagged_latent_covariance_as_observed_covariance(latent, recovered), + Err( + psychometric_core::PsychometricError::StationaryLaggedLatentCovarianceIsNotObservedCovariance + ) + ); + assert_eq!( + refuse_measurement_error_as_stationary_lagged_observed_covariance( + measurement_error, + recovered + ), + Err( + psychometric_core::PsychometricError::MeasurementErrorIsNotStationaryLaggedObservedCovariance + ) + ); + assert_eq!( + refuse_stationary_initial_observed_variance_as_stationary_lagged_observed_covariance( + contemporaneous, + recovered + ), + Err( + psychometric_core::PsychometricError::StationaryInitialObservedVarianceIsNotStationaryLaggedObservedCovariance + ) + ); +} + +#[test] +fn stationary_later_latent_variance_is_not_lagged_discrete_or_process_noise() { + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let log_rate = -0.134_488_942_f64; + let event_delta = 1.0_f64; + let recovered = recover_stationary_later_latent_variance( + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("stationary later T0VAR"); + let lagged = recover_stationary_lagged_latent_covariance( + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("stationary lagged T0VAR"); + let contemporaneous = recover_stationary_initial_latent_variance( + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + LagClock::EventTime, + ) + .expect("stationary T0VAR"); + let free_discrete = recover_discrete_latent_variance( + contemporaneous, + diffusion, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("e^{2aΔt} p_stat + Q_Δt"); + let process_noise = + recover_discrete_process_noise(diffusion, log_rate, event_delta, LagClock::EventTime) + .expect("Q_Δt"); + assert!( + (recovered - contemporaneous).abs() < 1e-12, + "Driver et al. (2017, Eq. 3–4 of §4.3 T0VAR): later-occasion variance equals contemporaneous T0VAR under stationarity" + ); + assert!( + (recovered - lagged).abs() > 1e-3, + "Driver et al. (2017, Eq. 3–4 of §4.3 T0VAR): later-occasion variance is not lagged covariance" + ); + assert!( + (recovered - free_discrete).abs() > 1e-3, + "Driver et al. (2017, Eq. 3–4 of §4.3 T0VAR): trait and addedTIPREDVAR do not enter Q_Δt" + ); + assert!( + (recovered - process_noise).abs() > 1e-3, + "Driver et al. (2017, Eq. 3–4 of §4.3 T0VAR): later-occasion variance is not Q_Δt" + ); + assert_eq!( + refuse_stationary_later_latent_variance_as_lagged_covariance(recovered, lagged), + Err( + psychometric_core::PsychometricError::StationaryLaterLatentVarianceIsNotLaggedCovariance + ) + ); + assert_eq!( + refuse_stationary_later_latent_variance_as_discrete_variance(recovered, free_discrete), + Err( + psychometric_core::PsychometricError::StationaryLaterLatentVarianceIsNotDiscreteVariance + ) + ); + assert_eq!( + refuse_stationary_later_latent_variance_as_process_noise(recovered, process_noise), + Err(psychometric_core::PsychometricError::StationaryLaterLatentVarianceIsNotProcessNoise) + ); +} + +#[test] +fn stationary_later_observed_variance_is_not_manifest_latent_or_lagged() { + let trait_variance = 1.0_f64; + let diffusion = 0.4_f64; + let log_rate = -0.134_488_942_f64; + let loading = 2.0_f64; + let measurement_error = 0.5_f64; + let event_delta = 1.0_f64; + let recovered = recover_stationary_later_observed_variance( + loading, + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + event_delta, + measurement_error, + 0.1, + LagClock::EventTime, + ) + .expect("eq5-later-stationary-T0VAR"); + let latent = recover_stationary_later_latent_variance( + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + event_delta, + LagClock::EventTime, + ) + .expect("stationary later T0VAR"); + let lagged = recover_stationary_lagged_observed_covariance( + loading, + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + event_delta, + 0.1, + LagClock::EventTime, + ) + .expect("eq5-lagged-stationary-T0VAR"); + let contemporaneous = recover_stationary_initial_observed_variance( + loading, + trait_variance, + diffusion, + -0.225, + 1.0, + log_rate, + measurement_error, + 0.1, + LagClock::EventTime, + ) + .expect("eq5-stationary-T0VAR"); + assert!( + (recovered - contemporaneous).abs() < 1e-12, + "Driver et al. (2017, Eq. 5 of later §4.3 T0VAR): Var(y_t) equals Var(y_0) under stationarity" + ); + assert!( + (recovered - measurement_error).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of later §4.3 T0VAR): Var(y_t) is not MANIFESTVAR" + ); + assert!( + (recovered - latent).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of later §4.3 T0VAR): Var(y_t) is not later T0VAR" + ); + assert!( + (recovered - lagged).abs() > 1e-3, + "Driver et al. (2017, Eq. 5 of later §4.3 T0VAR): Var(y_t) is not lagged cov(y)" + ); + assert_eq!( + refuse_stationary_later_latent_variance_as_observed_variance(latent, recovered), + Err( + psychometric_core::PsychometricError::StationaryLaterLatentVarianceIsNotObservedVariance + ) + ); + assert_eq!( + refuse_measurement_error_as_stationary_later_observed_variance( + measurement_error, + recovered + ), + Err( + psychometric_core::PsychometricError::MeasurementErrorIsNotStationaryLaterObservedVariance + ) + ); + assert_eq!( + refuse_stationary_lagged_observed_covariance_as_stationary_later_observed_variance( + lagged, recovered + ), + Err( + psychometric_core::PsychometricError::StationaryLaggedObservedCovarianceIsNotStationaryLaterObservedVariance + ) + ); +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 4d3df9ce..4535b23a 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -60,6 +60,8 @@ The full APA 7th standards/literature register remains `docs/research/standards- | report template/section/copied/style/modality method effects | ADR 0004/0012; PRD/TRD | simulation truth factors implemented; `prompt_source` prompt-versus-unique-content identity on the active PR; estimator-side method model remains future | partial | | candidate K statistical/Pareto gates + blinded LLM review | ADR 0012; research | future `model_selection` | accepted-target | | compositional topic correlation / stable clustering | ADR 0005/0012; research | future `network_analysis` | accepted-target | +| posterior ESEM / longitudinal invariance / DSEM | ADR 0005 | `psychometric_core` construct/input gates, true-loading OLS recovery, posterior-draw point-estimate averaging, Rubin `T` on draw-level OLS loadings, CWC within/between OLS plus the contextual effect, event-time log-rate, constant- and time-varying-predictor discrete effects (Voelkle Eqs. 12 and 14), exact scalar discrete process noise (Driver et al., 2017, Eq. 3), lagged latent covariance and unconditional latent variance (Driver et al., 2017, Eq. 3–4), stationary within-subject variance (Driver et al., 2017, Eq. 4 as `Δt → ∞`; `asymDIFFUSION`), trait-plus-state variance (Driver et al., 2017, §4.3 `TRAITVAR`; not process noise), observed-indicator variance and lagged observed covariance (Driver et al., 2017, Eq. 5; Table 2 `MANIFESTVAR` is `Θ`, not `Var(y)`; `MANIFESTTRAITVAR` is not `MANIFESTVAR`; `Θ` does not enter lagged observed covariance; observed-indicator mean is `τ + λ μ`; `MANIFESTMEANS` is not `E(y)`; `CINT` is not `MANIFESTMEANS`; discrete latent mean is `exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ`; `T0MEANS` is not `μ_t`; evolved observed mean is `τ + λ μ_t`; `τ + λ μ_0` is not `E(y_t)`; contemporaneous `TDPREDEFFECT` impulse is `m x`, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; Eq. 5 of that contemporaneous impulse is `τ + λ(μ_t + m x)`, and `τ + λ μ_t` is not that observed mean; time-independent `TIPREDEFFECT` increment is `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, not `M x`, not Voelkle Eq. 14, and not the coefficient `B`; Eq. 5 of that increment is `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that observed mean; `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t`; within-interval `TDPREDEFFECT` carry is `e^{A(t−u)} M x` for `t0 < u < t`, not the contemporaneous Dirac, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; Eq. 5 of that carry is `τ + λ(μ_t + e^{a(t−u)} m x)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that carried observed mean when `u ≠ t`; §7.2 level-change `CINT` is `κ = −a m x` (`a < 0`; not the dissipating Dirac, not a free `CINT`, not `TIPREDEFFECT`; Eq. 3 of that setting is `(1 − e^{a Δt}) m x`); §7.2 extra-process contribution is `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` (not `κ = −a m x`, not `(1 − e^{a Δt}) m x`, not the dissipating Dirac; `ε ≥ 0` fails closed; Eq. 5 of that contribution is `τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)`; extra `LAMBDA` is 0; `τ + λ μ_t` is not that observed mean; after-t0 extra-process `TDPREDEFFECT` uses `t − u` with `t0 < u < t` while `μ_t` uses `Δt`; that after-t0 observed mean is not the first-occasion extra-process observed mean; §7.2 `asymTIPREDEFFECT` is `-B z / a` for `a < 0` and is not `B`, not `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, and not `M x`; §7.2 `addedTIPREDVAR` is `(B / a)² v` and is not `TRAITVAR`, not `asymDIFFUSION`, and not `-B z / a`; Table 2 `asymCINT` is `-κ / a` for `a < 0` and is not `κ`, not `A^{-1}[e^{A Δt} − I] κ`, not `T0MEANS`, and not `-B z / a`; p. 16 stationary `T0MEANS` is `-κ / a + −B z / a` and is not free `T0MEANS`, not `asymCINT` alone, not `asymTIPREDEFFECT` alone, and not the finite-interval discrete latent mean; Eq. 5 of that constrained mean is `τ + λ(−κ / a + −B z / a)`; `τ + λ μ_0` is not that observed mean; `MANIFESTMEANS` is not `E(y_0)`; the constrained latent mean is not `E(y_0)`; stationary `T0VAR` is `trait + −q / (2 a) + (B / a)² v` (not free `T0VAR`, not `asymDIFFUSION` alone, not `TRAITVAR` alone, not `addedTIPREDVAR` alone, and not the finite-interval discrete latent variance. Eq. 5 of that constrained variance is `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` (JSS PDF re-opened 2026-08-22T03:20Z; form the stationary latent variance first, then `λ² p + θ + ψ`; `λ² p_0` is not that observed variance; `λ²(−q / (2 a)) + θ` is not that observed variance when `TRAITVAR` or `addedTIPREDVAR` is nonzero; `MANIFESTVAR` is not `Var(y_0)`; the constrained latent variance is not `Var(y_0)`)); lagged stationary `T0VAR` is `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` (trait and `addedTIPREDVAR` do not decay; contemporaneous `T0VAR` is not that lagged map; decaying the constrained total as if it were all state is not that lagged map; Eq. 5 of that lagged covariance is `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ`; `Θ` does not enter; contemporaneous `Var(y_0)` is not that lagged observed covariance; the lagged latent covariance is not that observed covariance); later-occasion stationary `T0VAR` is `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v` (trait and `addedTIPREDVAR` do not enter `Q_Δt`; under stationarity that composition equals contemporaneous `T0VAR`; evolving the constrained total as if it were all state is not that later map; the lagged covariance omits `Q_Δt`; `Q_Δt` is not that later map; Eq. 5 of that later-occasion variance is `λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ`; lagged observed covariance omits `Q_Δt` and `θ`; `MANIFESTVAR` is not `Var(y_t)`; the later-occasion latent variance is not `Var(y_t)`)), irregular already-centered residual lag, and strong/strict-gated latent means on the stacked psychometric PR (two-observation residual variance is identically `0` and caps at strong/scalar; Putnick & Bornstein, 2016, PMC5145197 opened 2026-08-19T22:15Z); full ESEM/DSEM remaining | partial | +| CPU bounded multithreading + GPU/VRAM streaming/parity | ADR 0001/0006 | future `compute_backend` | accepted-target | | posterior ESEM / longitudinal invariance / DSEM | ADR 0005 | `psychometric_fit` ESEM loading and DSEM lag gates on the active PR; `psychometric_core` input gates remain #49; invariance/multilevel remain accepted-target | active-PR | | CPU bounded multithreading + GPU/VRAM streaming/parity | ADR 0001/0006 | `compute_backend` CPU `f64` reference, bounded planning, and VRAM-budget refusal are active; full GPU streaming and CPU/GPU parity remain future | partial | | TDT detection/tracking vs CHRONOS schema/prediction/temporal consistency | ADR 0016; PRD/research | `prediction_contradiction` bounded Allen promotion gate on the active PR (`refuse_promotion` requires coverage; `refuse_contradiction_or_adjacency` is not promotion authority; remaining TDT/CHRONOS tasks stay accepted-target) | active-PR | diff --git a/docs/adr/0005-posterior-esem-dsem.md b/docs/adr/0005-posterior-esem-dsem.md index 8219c6f8..7191dd9d 100644 --- a/docs/adr/0005-posterior-esem-dsem.md +++ b/docs/adr/0005-posterior-esem-dsem.md @@ -1,7 +1,7 @@ # ADR 0005 — Posterior-aware ESEM/DSEM and structural interpretation **Decision status:** Accepted -**Implementation maturity:** active-PR — `longitudinal_core` separates unit means from within residuals and refuses between-as-within change; CPU `f64` ESEM/DSEM fit and event-time lag gates are active-PR; remaining invariance and multilevel estimator work remains accepted-target. +**Implementation maturity:** partial — construct classification, valid log-ratio/logistic-normal indicator gates, CPU `f64` OLS and posterior-draw loading point-estimate averaging, Rubin `T_m = Ū_m + (1+1/m) B_m` on draw-level OLS loadings, cluster-mean within/between OLS with the CWC contextual effect and Kish ESS WLS, event-time discrete lag-1 and exact scalar local log-rate, exact scalar forward map and unequal-interval remapping, exact scalar discrete effect of a constant predictor, first-order discrete effect of a time-varying predictor with matched sampling and constancy intervals (Voelkle et al., 2012, Eq. 14), exact scalar discrete process noise (Driver, Oud, & Voelkle, 2017, Eq. 3), exact scalar lagged latent covariance and unconditional latent variance (Driver et al., 2017, Eq. 3–4), exact scalar stationary within-subject variance (Driver et al., 2017, Eq. 4 as `Δt → ∞`; §4.3; p. 16 `asymDIFFUSION`), exact scalar trait-plus-state variance and lagged covariance (Driver et al., 2017, §4.3 `TRAITVAR`; not process noise and not `asymDIFFUSION`), exact scalar observed-indicator variance and lagged observed covariance (Driver et al., 2017, Eq. 5, p. 5; Table 2, p. 12; `λ² Var(η) + θ` when `MANIFESTTRAITVAR` is zero, else `λ² Var(η) + θ + ψ`; lagged `λ² cov(η_t, η_{t-1}) + ψ`; `MANIFESTVAR` is `Θ`, not `Var(y)`; `Θ` does not enter lagged observed covariance; `MANIFESTTRAITVAR` is not `MANIFESTVAR`; observed-indicator mean is `τ + λ μ` (`MANIFESTMEANS` is `τ`, not `E(y)`; `CINT` is not `MANIFESTMEANS`; `T0MEANS` is not `E(y)`; Equation 1 is the SDE; not a Kalman filter), exact scalar discrete latent mean `exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ` (Driver et al., 2017, Eq. 3; `T0MEANS` is not `μ_t`; `CINT` is not the discrete increment), exact scalar evolved observed-indicator mean `τ + λ μ_t` (Driver et al., 2017, Eq. 5 of that Eq. 3 map; the first-occasion map `τ + λ μ_0` is not `E(y_t)`), exact scalar contemporaneous `TDPREDEFFECT` impulse `m x` (Driver et al., 2017, Eq. 3 fourth summand; Table 2 `TDPREDEFFECT` is `M`, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; the §7.2 level-change form is not that impulse), exact scalar observed mean of that contemporaneous impulse `τ + λ(μ_t + m x)` (Driver et al., 2017, Eq. 5 of that Eq. 3 composition; `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t`), exact scalar time-independent `TIPREDEFFECT` increment `A^{-1}[e^{A Δt} − I] B z` (Driver et al., 2017, Eq. 3 second summand; Table 2 `TIPREDEFFECT` is `B`, not `κ`, not `M`, and not Voelkle Eq. 14; `B` is not that discrete increment), exact scalar observed mean of that increment `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` (Driver et al., 2017, Eq. 5 of that Eq. 3 printed addend after the `T0MEANS` carry and the `CINT` increment; `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that observed mean; `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t`), exact scalar within-interval `TDPREDEFFECT` carry `e^{A(t−u)} M x` for `t0 < u < t` (Driver et al., 2017, Eq. 1–2 Green-function integral of Eq. 2; §7.2 dissipation; not the contemporaneous Dirac, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14), exact scalar observed mean of that carry `τ + λ(μ_t + e^{a(t−u)} m x)` (Driver et al., 2017, Eq. 5 of that carried latent mean; `τ + λ μ_t` is not that observed mean), exact scalar first-occasion `T0TIPREDEFFECT` shift `t0_b z` and Eq. 3 first-summand carry `e^{A Δt} t0_b z` (Driver et al., 2017, Table 3, p. 13; Eq. 3, p. 5; `T0TIPREDEFFECT` is not `TIPREDEFFECT` `B`; `t0_b z` is not `A^{-1}[e^{A Δt} − I] B z`; `e^{A Δt} t0_b z` is not `t0_b z`), exact scalar observed mean of that first-occasion carry `τ + λ(μ_t + e^{a Δt} t0_b z)` (Driver et al., 2017, Eq. 5 of that Table 3 / Eq. 3 first-summand composition; `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not that observed mean), exact scalar first-occasion `T0TDPREDEFFECT` shift `t0_m x0` and Eq. 3 first-summand carry `e^{A Δt} t0_m x0` (Driver et al., 2017, Table 3, p. 13; Eq. 3, p. 5; JSS PDF re-opened 2026-08-20T19:10Z; `T0TDPREDEFFECT` is not `TDPREDEFFECT` `M`; `t0_m x0` is not `M x`; `e^{A Δt} t0_m x0` is not `t0_m x0`; `e^{A Δt} t0_m x0` is not `e^{A(t−u)} M x` for `t0 < u < t`; `t0_m x0` is not `t0_b z`; an impulse at `u ≤ t0` that used `M` is already in `η(t0)` as `TDPREDEFFECT`, not as `T0TDPREDEFFECT`), exact scalar observed mean of that first-occasion TD carry `τ + λ(μ_t + e^{a Δt} t0_m x0)` (Driver et al., 2017, Eq. 5 of that Table 3 / Eq. 3 first-summand TD composition; `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not that observed mean; `τ + λ(μ_t + e^{a Δt} t0_b z)` is not that observed mean), exact scalar §7.2 level-change `CINT` `κ = −a m x` (Driver et al., 2017, §7.2, pp. 20–21; `a < 0` so `−κ / a = m x`; not the dissipating Dirac, not a free `CINT`, not `TIPREDEFFECT`, and not the extra near-zero-drift latent process also named in §7.2), exact scalar Eq. 3 increment of that setting `(1 − e^{a Δt}) m x` (not `m x`, not `κ`, and not `TIPREDEFFECT`), exact scalar §7.2 extra near-zero-drift latent process contribution `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` (Driver et al., 2017, §7.2, pp. 22–23; JSS PDF re-opened 2026-08-20T23:10Z; identification `TDPREDEFFECT` on the extra process is 1; extra `DRIFT` printed as `−0.000001`; precisely 0 causes computational problems; `ε = a` is `a_{ηξ} x Δt e^{a Δt}`; not `κ = −a m x`, not `(1 − e^{a Δt}) m x`, and not the dissipating Dirac `m x`; `ε ≥ 0` fails closed), exact scalar observed mean of that extra-process contribution `τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a))` (Driver et al., 2017, Eq. 5, p. 5; §7.2, pp. 22–23; JSS PDF re-opened 2026-08-21T06:12Z; the extra process has `LAMBDA` 0 and is not an observed indicator; `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that observed mean; the contribution is not `E(y_t)`; the evolved-plus-contribution latent mean is not `E(y_t)`), exact scalar after-t0 extra-process contribution `a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a)` for `t0 < u < t` and Eq. 5 observed mean `τ + λ(μ_t + a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a))` (Driver et al., 2017, §7.2, pp. 22–23; JSS PDF re-opened 2026-08-21T06:32Z; `T0TDPREDEFFECT` uses `Δt` for both the evolution and the extra drive; `TDPREDEFFECT` after `t0` uses `t − u`; `e^{a(t−u)} m x` is a Dirac on the original process, not this `DRIFT` drive), exact scalar §7.2 `asymTIPREDEFFECT` `-B z / a` (Driver et al., 2017, §7.2, pp. 20–21; JSS PDF opened 2026-08-21T13:08Z; expected total change in process means given a time-independent predictor; `a < 0`; not the coefficient `B`, not `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, and not `M x`; `a ≥ 0` fails closed), exact scalar §7.2 `addedTIPREDVAR` `(B / a)² v` (Driver et al., 2017, §7.2, pp. 20–21; stable between-subject variance accounted for by a time-independent predictor; not `TRAITVAR`, not `asymDIFFUSION`, and not `-B z / a`), exact scalar Table 2 `asymCINT` `-κ / a` (Driver et al., 2017, Table 2, p. 12; Eq. 3 as `Δt → ∞`; `a < 0`; not `κ`, not `A^{-1}[e^{A Δt} − I] κ`, not `T0MEANS`, and not `-B z / a`; `a ≥ 0` fails closed), exact scalar p. 16 stationary `T0MEANS` `-κ / a + −B z / a` (Driver et al., 2017, p. 16; constrained first-occasion mean using `T0MEANSbase` / `T0MEANSfree`; not free `T0MEANS`, not `asymCINT` alone, not `asymTIPREDEFFECT` alone, and not the finite-interval discrete latent mean), exact scalar Eq. 5 of that constrained mean `τ + λ(−κ / a + −B z / a)` (Driver et al., 2017, §4.3, pp. 9–10; Eq. 5, p. 5; JSS PDF re-opened 2026-08-21T20:07Z; form the stationary latent mean first, then `τ + λ` of that mean; `τ + λ μ_0` is not that observed mean; `τ + λ(−κ / a)` is not that observed mean when `B z ≠ 0`; `τ + λ μ_t` is not that observed mean; `MANIFESTMEANS` is not `E(y_0)`; the constrained latent mean is not `E(y_0)`), exact scalar §4.3 / p. 16 stationary `T0VAR` `trait + −q / (2 a) + (B / a)² v` (Driver et al., 2017, §4.3, pp. 9–10; p. 16; JSS PDF re-opened 2026-08-22T03:07Z; not free `T0VAR`, not `asymDIFFUSION` alone, not `TRAITVAR` alone, not `addedTIPREDVAR` alone, and not the finite-interval discrete latent variance. Eq. 5 of that constrained variance is `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` (JSS PDF re-opened 2026-08-22T03:20Z; form the stationary latent variance first, then `λ² p + θ + ψ`; `λ² p_0` is not that observed variance; `λ²(−q / (2 a)) + θ` is not that observed variance when `TRAITVAR` or `addedTIPREDVAR` is nonzero; `MANIFESTVAR` is not `Var(y_0)`; the constrained latent variance is not `Var(y_0)`), exact scalar lagged covariance of that constrained process `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` (Driver et al., 2017, Eq. 3–4 of §4.3 / p. 16 `T0VAR`; JSS PDF re-opened 2026-08-22T19:13Z; trait and `addedTIPREDVAR` do not decay with `e^{a Δt}`; contemporaneous `T0VAR` is not that lagged map; decaying the constrained total as if it were all state is not that lagged map), exact scalar Eq. 5 of that lagged covariance `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ` (`Θ` does not enter; contemporaneous `Var(y_0)` is not that lagged observed covariance; the lagged latent covariance is not that observed covariance), exact scalar later-occasion variance of that constrained process `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v` (Driver et al., 2017, Eq. 3–4 of §4.3 / p. 16 `T0VAR`; JSS PDF re-opened 2026-08-22T23:12Z; trait and `addedTIPREDVAR` do not enter `Q_Δt`; under stationarity that composition equals contemporaneous `T0VAR`; evolving the constrained total as if it were all state is not that later map; the lagged covariance omits `Q_Δt` and is not that later map; `Q_Δt` is not that later map), exact scalar Eq. 5 of that later-occasion variance `λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ` (the lagged observed covariance omits `Q_Δt` and `θ`; `MANIFESTVAR` is not that later observed variance; the later-occasion latent variance is not that observed variance)), CWC-then-event-time residual lag, irregular already-centered residual log-rate, and strong/strict-gated two-group OLS latent-mean difference are implemented on the stacked psychometric PR and are not implemented-main until exact-head checks, review, and protected-main integration complete; full ESEM/set-ESEM, formative composites, DSEM, and matrix continuous-time dynamics remain accepted-target **Date:** 2026-08-05 **Supersedes:** None. ADR 0012 governs upstream topic measurement/network coordinates; this ADR governs higher-order psychometric structure and longitudinal interpretation. @@ -15,12 +15,19 @@ TEPP also needs to distinguish stable between-unit differences from within-unit Topic proportions are not treated as error-free ordinary indicators. Compositional parts are not ordinary Euclidean measurements (Aitchison, 1982). TEPP uses logistic-normal latent coordinates or valid orthonormal log-ratio coordinates and propagates topic posterior uncertainty through plausible values or a joint text-measurement/structural model. + +Topic proportions are not treated as error-free ordinary indicators. TEPP uses logistic-normal latent coordinates or valid orthonormal log-ratio coordinates and propagates topic posterior uncertainty through plausible values or a joint text-measurement/structural model. +The current executable slice averages loading point estimates across posterior draws and, separately, combines those complete-data OLS loadings with Rubin total variance. The point-estimate helper still does not by itself satisfy the full posterior-propagation decision. The Rubin helper uses complete-data OLS sampling variances; it does not treat the draws as Mislevy person-level plausible values. + + Before ESEM/SEM interpretation, each higher-order construct is classified as reflective, formative/composite, network, or unresolved. Reflective indicators may use exploratory structural equation modeling (Asparouhov & Muthén, 2009; Marsh et al., 2014); formative structures use composite/formative models; interacting structures use network models. A good global fit statistic is not authority to reinterpret a formative/network structure as reflective. Longitudinal analysis evaluates measurement invariance at the level needed for the claimed comparison (American Educational Research Association, American Psychological Association, & National Council on Measurement in Education, 2014), supports partial/approximate or time-varying loadings where scientifically justified, separates stable between-unit components from within-unit temporal change, and handles irregular intervals through dynamic structural equation models (Asparouhov et al., 2018). This ADR remains **accepted-target**. Naming ESEM/DSEM and compositional coordinates as the model-family contract is not a protected-main implementation claim. +The executable multilevel slice is cluster-mean centering (CWC) plus within/between OLS, the CWC contextual effect (`between − within`), and Kish-weighted slopes. Enders and Tofighi (2007, Table 2, pp. 124–127) show that the CWC cluster-mean coefficient is the contextual effect, not the between-cluster effect. It is not DSEM and not RI-CLPM. The executable temporal slice maps a discrete lag through the exact scalar exponential `a = ln(φ) / Δt` on event time only (Voelkle, Oud, Davidov, & Schmidt, 2012, Eq. 7; Driver, Oud, & Voelkle, 2017, Eq. 3), recovers the forward map `φ(Δt) = exp(a Δt)`, remaps a discrete lag onto another event interval through that log-rate, recovers the exact scalar discrete effect of a constant predictor (Voelkle et al., 2012, Eq. 12) as `a_yx (expm1(z) / a_xx)` with `z = a_xx Δt` so a finite result is not lost when `z` overflows to `-∞` or when `a_yx Δt` overflows, and in log space when `expm1(z)` overflows at a finite `z` (`a_yx = 0` is exactly zero; an overflowing `a_yx/a_xx` rewrite term fails closed), recovers the first-order discrete effect of a time-varying predictor with matched sampling and constancy intervals as `a_yx Δt` (Voelkle et al., 2012, Eq. 14; ZORA accepted manuscript p. 21; not Eq. 12; unmatched intervals fail closed because Oud & Jansen, 2000, is unread), recovers the exact scalar discrete process noise `Q_Δt = 0.5 q (expm1(z) / a)` with `z = 2 (a Δt)` and `q = G G⊤ ≥ 0` (Driver et al., 2017, Eq. 3; JSS PDF re-opened 2026-08-18T07:06Z, p. 4; scalar `L = 1`; do not form `2 a` first; `a = 0` and `z → 0` recover `q Δt`; `z → −∞` keeps `−0.5 q / a`; a zero diffusion is exactly zero; `z → +∞` and an overflowing rewrite scale `0.5 q / a` fail closed), recovers the exact scalar lagged latent covariance `exp(a Δt) p` and the law-of-total-variance map `Var(η_t) = exp(2 a Δt) p + Q_Δt` (Driver et al., 2017, Eq. 3–4, pp. 4–5; JSS PDF re-opened 2026-08-18T18:03Z; JSS has no numbered §2.2; `Q_Δt` is `cov(η_t | η_{t-1})` and is refused as the unconditional variance; a zero diffusion whose `2 (a Δt)` overflows to `+∞` fails closed), recovers the exact scalar stationary within-subject variance `-q / (2 a)` as the `Δt → ∞` limit of Eq. 4 for stable `a < 0` (JSS p. 16 `asymDIFFUSION`; §4.3 T0VAR stationarity; when `2 a` is finite, form `q / -(2 a)` so `q / a` overflow does not lose a finite result (`q = MAX`, `a = -0.75` → `MAX / 1.5`; CodeRabbit on `75ecdd3`); when `2 a` overflows, form `(q / a) * -0.5`; do not form `0.5 q` first (`q = from_bits(1)`, `a = -from_bits(1)` → `0.5`); `a ≥ 0` and finite-interval `Q_Δt` fail closed as that limit), recovers the exact scalar trait-plus-state variance `trait + state` and lagged covariance `trait + exp(a Δt) p` (Driver et al., 2017, §4.3, p. 9; JSS PDF re-opened 2026-08-18T21:07Z; a stable trait has `DRIFT` and `DIFFUSION` fixed to zero; `TRAITVAR` is not process noise and not `asymDIFFUSION`; evolving the summed variance as if it were all state fails the claim boundary; this is not RI-CLPM), recovers the exact scalar observed-indicator variance `λ² Var(η) + θ` when `MANIFESTTRAITVAR` is zero and `λ² Var(η) + θ + ψ` otherwise, and the lagged observed covariance `λ² cov(η_t, η_{t-1}) + ψ` (Driver et al., 2017, Eq. 5, p. 5; Table 2, p. 12; JSS PDF re-opened 2026-08-19T04:18Z; Equation 1 is the latent SDE; form `(λ p) λ` then add `θ`, then add `ψ`; do not form `λ²` first; `MANIFESTVAR` is `Θ`, not `Var(y)` and does not enter lagged observed covariance; `MANIFESTTRAITVAR` is `Ψ_τ`, not `Θ`; `TRAITVAR` is latent and scaled by `λ²`; `Var(η)` is not `Var(y)`), recovers the exact scalar observed-indicator mean `τ + λ μ` (Driver et al., 2017, Eq. 5, p. 5; Table 2, p. 12; JSS PDF re-opened 2026-08-19T14:08Z; `MANIFESTMEANS` is `τ`, not `E(y)`; `CINT` is not `MANIFESTMEANS`; `T0MEANS` is not `E(y)`), recovers the exact scalar discrete latent mean `exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ` (Driver et al., 2017, Eq. 3, p. 4; Table 2, p. 12; JSS PDF re-opened 2026-08-19T18:10Z; `T0MEANS` is not `μ_t`; `CINT` is not the discrete increment; a zero drift is `κ Δt`; underflow of `exp(a Δt)` to `+0` drops the carried `T0MEANS` and keeps `−κ / a`), recovers the exact scalar evolved observed-indicator mean `τ + λ μ_t` (Driver et al., 2017, Eq. 5 of the Eq. 3 expected-value map; JSS PDF re-opened 2026-08-19T22:10Z; the first-occasion map `τ + λ μ_0` is not `E(y_t)`; `MANIFESTMEANS` is not `E(y_t)`; `μ_t` is not `E(y_t)`), recovers the exact scalar contemporaneous time-dependent predictor impulse `m x` (Driver et al., 2017, Eq. 1–3, pp. 4–5; Table 2, p. 12; §7.2, pp. 20–21; JSS PDF re-opened 2026-08-20T07:10Z; `TDPREDEFFECT` is `M`, not `CINT`; `M x` is not `A^{-1}[e^{A Δt} − I] B z`; `M x` is not Voelkle et al., 2012, Eq. 14; the level-change form is not that impulse), recovers the exact scalar observed mean of that contemporaneous impulse `τ + λ(μ_t + m x)` (Driver et al., 2017, Eq. 5 of the Eq. 3 fourth-summand composition; JSS PDF re-opened 2026-08-20T09:01Z; the evolved map `τ + λ μ_t` is not that observed mean; the carry map `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t`; `MANIFESTMEANS` is not `E(y_t)`; the evolved-plus-impulse latent mean is not `E(y_t)`), recovers the exact scalar time-independent predictor increment `A^{-1}[e^{A Δt} − I] B z` (Driver et al., 2017, Eq. 1–3, pp. 4–5; Table 2, p. 12; JSS PDF re-opened 2026-08-20T10:13Z; `TIPREDEFFECT` is `B`, not `κ`; form `B z` first, then the discrete intercept map; a zero drift is `B z Δt`; `B` is not the discrete increment; `A^{-1}[e^{A Δt} − I] B z` is not `CINT`, not `M x`, and not Voelkle Eq. 14), recovers the exact scalar observed mean of that increment `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` (Driver et al., 2017, Eq. 5 of the Eq. 3 printed addend after the `T0MEANS` carry and the `CINT` increment; JSS PDF re-opened 2026-08-20T12:12Z; the evolved map `τ + λ μ_t` is not that observed mean; the contemporaneous map `τ + λ(μ_t + m x)` is not that observed mean; the carry map `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t`; `MANIFESTMEANS` is not `E(y_t)`; the evolved-plus-increment latent mean is not `E(y_t)`), recovers the exact scalar within-interval time-dependent impulse carry `e^{A(t−u)} M x` for `t0 < u < t` (Driver et al., 2017, Eq. 1–2, pp. 4–5; Eq. 3 exponential map; §7.2, pp. 20–21; JSS PDF re-opened 2026-08-20T10:33Z; form `m x` first, then `e^{a(t−u)} m x`; a zero drift is `m x`; underflow of `e^{a(t−u)}` to `+0` is vanishing dissipation and is kept; `e^{A(t−u)} M x` is not the contemporaneous Dirac, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; an impulse at `u = t` is the contemporaneous map; an impulse at `u ≤ t0` is already in `η(t0)`), recovers the exact scalar observed mean of that carry `τ + λ(μ_t + e^{a(t−u)} m x)` (Driver et al., 2017, Eq. 5 of the Eq. 1–2 carried latent mean; JSS PDF re-opened 2026-08-20T05:12Z; the evolved map `τ + λ μ_t` is not that observed mean; the contemporaneous map `τ + λ(μ_t + m x)` is not that observed mean when `u ≠ t`; `MANIFESTMEANS` is not `E(y_t)`; the carried latent mean is not `E(y_t)`), recovers the exact scalar first-occasion `T0TIPREDEFFECT` shift `t0_b z` and its Eq. 3 first-summand carry `e^{A Δt} t0_b z` (Driver et al., 2017, Table 3, p. 13; Eq. 3, p. 5; JSS PDF opened 2026-08-20T15:14Z; form `t0_b z` first, then `e^{a Δt} t0_b z`; a zero drift is `t0_b z`; underflow of `e^{a Δt}` to `+0` is a vanishing carry and is kept; `t0_b z` is not `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, and not `M x`; `e^{A Δt} t0_b z` is not `t0_b z`; `T0TIPREDEFFECT` is the coefficient, not the shift), recovers the exact scalar observed mean of that first-occasion carry `τ + λ(μ_t + e^{a Δt} t0_b z)` (Driver et al., 2017, Eq. 5 of the Table 3 / Eq. 3 first-summand composition; JSS PDF re-opened 2026-08-20T15:28Z; the evolved map `τ + λ μ_t` is not that observed mean; the process-increment map `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not that observed mean; the contemporaneous map `τ + λ(μ_t + m x)` is not that observed mean; the impulse-carry map `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t0`; `MANIFESTMEANS` is not `E(y_t)`; the evolved-plus-carry latent mean is not `E(y_t)`), recovers the exact scalar first-occasion `T0TDPREDEFFECT` shift `t0_m x0` and its Eq. 3 first-summand carry `e^{A Δt} t0_m x0` (Driver et al., 2017, Table 3, p. 13; Eq. 3, p. 5; JSS PDF re-opened 2026-08-20T19:10Z; form `t0_m x0` first, then `e^{a Δt} t0_m x0`; a zero drift is `t0_m x0`; underflow of `e^{a Δt}` to `+0` is a vanishing carry and is kept; `t0_m x0` is not `M x`, not `e^{A(t−u)} M x` for `t0 < u < t`, not `t0_b z`, not `A^{-1}[e^{A Δt} − I] B z`, and not `CINT`; `e^{A Δt} t0_m x0` is not `t0_m x0`; `T0TDPREDEFFECT` is the coefficient, not the shift; an impulse at `u ≤ t0` that used `M` is already in `η(t0)` as `TDPREDEFFECT`, not as `T0TDPREDEFFECT`), recovers the exact scalar observed mean of that first-occasion TD carry `τ + λ(μ_t + e^{a Δt} t0_m x0)` (Driver et al., 2017, Eq. 5 of the Table 3 / Eq. 3 first-summand TD composition; JSS PDF re-opened 2026-08-20T19:07Z; the evolved map `τ + λ μ_t` is not that observed mean; the process-increment map `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not that observed mean; the contemporaneous map `τ + λ(μ_t + m x)` is not that observed mean; the impulse-carry map `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t0`; the first-occasion TI map `τ + λ(μ_t + e^{a Δt} t0_b z)` is not that observed mean; `MANIFESTMEANS` is not `E(y_t)`; the evolved-plus-carry latent mean is not `E(y_t)`), recovers the exact scalar §7.2 level-change `CINT` setting `κ = −a m x` (Driver et al., 2017, §7.2, pp. 20–21; JSS PDF re-opened 2026-08-20T19:45Z; form `m x` first, then multiply by `−a`; `a < 0` so `−κ / a = m x`; `a ≥ 0` cannot hold a new process mean; `−a m x` is not the dissipating Dirac, not a free `CINT`, and not `A^{-1}[e^{A Δt} − I] B z`; the extra near-zero-drift latent process also named in §7.2 is a different specification), recovers the exact scalar Eq. 3 increment of that setting `(1 − e^{a Δt}) m x` (JSS PDF re-opened 2026-08-20T19:50Z; form the level-change `CINT` first, then the discrete intercept map; underflow of `e^{a Δt}` to `+0` keeps `m x`; `(1 − e^{a Δt}) m x` is not `m x`, not `κ`, and not `A^{-1}[e^{A Δt} − I] B z`), recovers the exact scalar §7.2 extra near-zero-drift latent process contribution `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` (Driver et al., 2017, §7.2, pp. 22–23; JSS PDF re-opened 2026-08-20T23:10Z; form `a_{ηξ} x` first; `ε = a` is `a_{ηξ} x Δt e^{a Δt}`; a zero coupling or zero predictor is exactly zero; `ε ≥ 0` cannot hold a lasting extra state; that contribution is not `κ = −a m x`, not `(1 − e^{a Δt}) m x`, and not the dissipating Dirac `m x`), recovers the exact scalar observed mean of that extra-process contribution `τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a))` (Driver et al., 2017, Eq. 5, p. 5; §7.2, pp. 22–23; JSS PDF re-opened 2026-08-21T06:12Z; the extra process has `LAMBDA` 0 and is not an observed indicator; form the evolved-plus-contribution latent mean first, then `τ + λ` of that mean; the evolved map `τ + λ μ_t` is not that observed mean; the contemporaneous map `τ + λ(μ_t + m x)` is not that observed mean; the contribution is not `E(y_t)`; the evolved-plus-contribution latent mean is not `E(y_t)`), recovers the exact scalar after-t0 extra-process contribution `a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a)` for `t0 < u < t` and its Eq. 5 observed mean `τ + λ(μ_t + a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a))` (Driver et al., 2017, §7.2, pp. 22–23; JSS PDF re-opened 2026-08-21T06:32Z; `T0TDPREDEFFECT` uses `Δt` for both the evolution and the extra drive; `TDPREDEFFECT` after `t0` uses `t − u` while `μ_t` still uses `Δt`; an impulse at `u = t0` or `u = t` is not interior; `e^{a(t−u)} m x` is a Dirac on the original process, not this `DRIFT` drive), recovers the exact scalar §7.2 `asymTIPREDEFFECT` `-B z / a` (Driver et al., 2017, §7.2, pp. 20–21; JSS PDF opened 2026-08-21T13:08Z; form `B z` first, then divide by `-a`; `a < 0`; a zero coefficient or zero predictor is exactly zero; `a ≥ 0` cannot hold a finite process-mean change; `-B z / a` is not the coefficient `B`, not `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, and not `M x`), recovers the exact scalar §7.2 `addedTIPREDVAR` `(B / a)² v` (Driver et al., 2017, §7.2, pp. 20–21; form the unit asymptotic effect first, then square, then multiply by `v`; `v ≥ 0`; a zero coefficient or zero predictor variance is exactly zero; `(B / a)² v` is not `TRAITVAR`, not `asymDIFFUSION`, and not `-B z / a`), recovers the exact scalar Table 2 `asymCINT` `-κ / a` (Driver et al., 2017, Table 2, p. 12; Eq. 3 as `Δt → ∞`; JSS PDF opened 2026-08-21T16:13Z; form `κ` first, then divide by `-a`; `a < 0`; a zero intercept is exactly zero; `-κ / a` is not `κ`, not `A^{-1}[e^{A Δt} − I] κ`, not `T0MEANS`, and not `-B z / a`), recovers the exact scalar p. 16 stationary `T0MEANS` `-κ / a + −B z / a` (Driver et al., 2017, p. 16; form the intercept contribution first, then include the TI extra effect, then add; not free `T0MEANS`, not `asymCINT` alone, not `asymTIPREDEFFECT` alone, and not the finite-interval discrete latent mean), recovers the exact scalar Eq. 5 of that constrained mean `τ + λ(−κ / a + −B z / a)` (Driver et al., 2017, §4.3, pp. 9–10; Eq. 5, p. 5; JSS PDF re-opened 2026-08-21T20:07Z; form the stationary latent mean first, then `τ + λ` of that mean; `τ + λ μ_0` is not that observed mean; `τ + λ(−κ / a)` is not that observed mean when `B z ≠ 0`; `τ + λ μ_t` is not that observed mean; `MANIFESTMEANS` is not `E(y_0)`; the constrained latent mean is not `E(y_0)`), recovers the exact scalar §4.3 / p. 16 stationary `T0VAR` `trait + −q / (2 a) + (B / a)² v` (Driver et al., 2017, §4.3, pp. 9–10; p. 16; JSS PDF re-opened 2026-08-22T03:07Z; form the within-subject contribution first, then include the trait, then include the TI extra variance, then add; not free `T0VAR`, not `asymDIFFUSION` alone, not `TRAITVAR` alone, not `addedTIPREDVAR` alone, and not the finite-interval discrete latent variance), recovers the exact scalar Eq. 5 of that constrained variance `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` (Driver et al., 2017, §4.3, pp. 9–10; Eq. 5, p. 5; Table 2, p. 12; JSS PDF re-opened 2026-08-22T03:20Z; form the stationary latent variance first, then `λ² p + θ + ψ`; `λ² p_0` is not that observed variance; `λ²(−q / (2 a)) + θ` is not that observed variance when `TRAITVAR` or `addedTIPREDVAR` is nonzero; `MANIFESTVAR` is not `Var(y_0)`; the constrained latent variance is not `Var(y_0)`), recovers the exact scalar lagged covariance of that constrained process `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` (Driver et al., 2017, Eq. 3–4 of §4.3 / p. 16 `T0VAR`; JSS PDF re-opened 2026-08-22T19:13Z; trait and `addedTIPREDVAR` do not decay with `e^{a Δt}`; contemporaneous `T0VAR` is not that lagged map; decaying the constrained total as if it were all state is not that lagged map), recovers the exact scalar Eq. 5 of that lagged covariance `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ` (`Θ` does not enter; contemporaneous `Var(y_0)` is not that lagged observed covariance; the lagged latent covariance is not that observed covariance), recovers the exact scalar later-occasion variance of that constrained process `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v` (Driver et al., 2017, Eq. 3–4 of §4.3 / p. 16 `T0VAR`; JSS PDF re-opened 2026-08-22T23:12Z; form the evolved within-subject variance first, then include the trait, then include the TI extra variance, then add; trait and `addedTIPREDVAR` do not enter `Q_Δt`; under stationarity that composition equals contemporaneous `T0VAR`; evolving the constrained total as if it were all state is not that later map; the lagged covariance omits `Q_Δt` and is not that later map; `Q_Δt` is not that later map), recovers the exact scalar Eq. 5 of that later-occasion variance `λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ` (the lagged observed covariance omits `Q_Δt` and `θ`; `MANIFESTVAR` is not that later observed variance; the later-occasion latent variance is not that observed variance), and refuses the difference quotient, pooling discrete lags from unequal intervals, and a binary64 underflow of that exponential to `+0` (not a discrete lag). The first-order product `a_yx Δt` is Eq. 14 and the underflow limit of Eq. 12, not the general constant-predictor discrete effect. Already-centered residuals may have irregular event intervals. Subtracting the person-specific mean from a raw autoregressive series is not the lagged within-person residual (Curran & Bauer, 2011, pp. 607–608). Metric/weak invariance licenses shared metric meaning only. Latent-mean comparison requires strong (equal loading and intercept) or strict invariance (Putnick & Bornstein, 2016, PMC5145197 opened 2026-08-19T22:15Z: scalar is required for latent means; residual invariance is not). Two-observation series have no residual degrees of freedom and cap at strong/scalar; identically-zero OLS residual variance is not strict. This two-group OLS gate is not MGCFA. + Input/process/intervention/outcome paths obey event-time order. Temporal precedence, document linkage, event tracking, or model prediction alone do not justify causal language. ## Non-goals diff --git a/docs/adr/README.md b/docs/adr/README.md index b1170d65..d77b1104 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -31,6 +31,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0002](0002-six-clock-temporal-semantics.md) | Six-clock temporal semantics and fail-closed historical leakage prevention | Accepted | partial | Typed clocks/intervals (merged PR #8), Allen/path-consistency (merged PR #9), the clock-identity/revision-order/document-completeness gates (`system_clock`, `event_clock`, `assertion_clock`, `cutoff_clock`, `available_clock`, `document_clocks`, `revision_order`), and the provenance/ordering gates (`citation_edge`, `support_edge`, `retrospective_edge`) are implemented-main; superseded PRs #5/#6 are historical lineage only; remaining graph/split enforcement stays accepted-target. | | [0003](0003-relational-event-multiple-membership.md) | Relational event ontology and time-varying cross-classified multiple membership | Accepted | partial | Membership network/roles, Kish ESS, nested ICC, subevent parent-window containment (`subevent_containment`), the forward-only relation graph, evidential-vs-transition identity (`support_edge`), inferred-versus-observed identity (`inferred_status`), retrospective-reporting identity (`retrospective_edge`), summary-versus-source identity (`summarizes_edge`), copy-versus-source identity (`copy_identity`), location-versus-entity/language identity (`location_membership`), and IPO event-time order (`outcome_order`) are implemented-main; typed target-kind identity in `membership_target` is on PR #131; full multilevel estimators and persistence remain accepted-target. ADR 0016 owns event-intelligence tasks. | | [0004](0004-shared-multilingual-latent-space.md) | One shared multilingual latent space with explicit invariance status | Accepted | accepted-target | ADR 0012 owns the full topic-estimator/backend/global-topic contract. | +| [0005](0005-posterior-esem-dsem.md) | Posterior-aware ESEM/DSEM and valid compositional coordinates | Accepted | partial | Input gates, posterior-draw point estimates, Rubin `T` on OLS loadings, CWC within/between OLS plus the contextual effect, event-time log-rate, constant- and time-varying-predictor discrete effects, exact scalar discrete process noise, lagged latent covariance and unconditional latent variance, stationary within-subject variance (`asymDIFFUSION`), trait-plus-state variance (`TRAITVAR`; not process noise), observed-indicator variance and lagged observed covariance (Eq. 5; Table 2 `MANIFESTVAR` is `Θ`, not `Var(y)`; `MANIFESTTRAITVAR` is not `MANIFESTVAR`; `Θ` does not enter lagged observed covariance; observed-indicator mean is `τ + λ μ`; `MANIFESTMEANS` is not `E(y)`; `CINT` is not `MANIFESTMEANS`; discrete latent mean is `exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ`; `T0MEANS` is not `μ_t`; evolved observed mean is `τ + λ μ_t`; `τ + λ μ_0` is not `E(y_t)`; Eq. 5 of the contemporaneous impulse is `τ + λ(μ_t + m x)`; `τ + λ μ_t` is not that observed mean; Eq. 5 of the time-independent predictor is `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`; `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that observed mean; `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t`; Eq. 5 of the within-interval impulse carry is `τ + λ(μ_t + e^{a(t−u)} m x)`; `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that carried observed mean when `u ≠ t`; Table 2 `asymCINT` is `-κ / a` for `a < 0` and is not `κ`, not the finite-interval increment, not `T0MEANS`, and not `-B z / a`; p. 16 stationary `T0MEANS` is `-κ / a + −B z / a` and is not free `T0MEANS`, not `asymCINT` alone, not `asymTIPREDEFFECT` alone, and not the finite-interval discrete latent mean; Eq. 5 of that constrained mean is `τ + λ(−κ / a + −B z / a)` (`τ + λ μ_0` is not that observed mean; `MANIFESTMEANS` is not `E(y_0)`); stationary `T0VAR` is `trait + −q / (2 a) + (B / a)² v` (not free `T0VAR`, not `asymDIFFUSION` alone, not `TRAITVAR` alone, not `addedTIPREDVAR` alone; Eq. 5 is `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` (`λ² p_0` is not `Var(y_0)`; `λ²(−q / (2 a)) + θ` is not `Var(y_0)` when trait or TI is nonzero; `MANIFESTVAR` is not `Var(y_0)`; the constrained latent variance is not `Var(y_0)`)); §7.2 `addedTIPREDVAR` is `(B / a)² v` and is not `TRAITVAR`, not `asymDIFFUSION`, and not `-B z / a`), irregular already-centered residual lag, and strong-gated latent means are on the stacked psychometric PR; full ESEM/DSEM estimator remains accepted-target. | | [0005](0005-posterior-esem-dsem.md) | Posterior-aware ESEM/DSEM and valid compositional coordinates | Accepted | active-PR | CPU `f64` ESEM/DSEM fit in `psychometric_fit` on the active PR; `psychometric_core` input gates remain #49; within/between decomposition in `longitudinal_core` is on the active PR; invariance/multilevel and remaining ESEM/DSEM fit remain accepted-target. ADR 0012 owns the upstream topic/network contract. | | [0006](0006-vram-gpu-nvidia-orchestration.md) | VRAM-adaptive GPU compute and model-credential boundary | Accepted | accepted-target | LLM orchestration policy superseded by ADR 0010; autonomous development authority governed by ADR 0015. | | [0007](0007-rust-workspace-quality-gates.md) | Explicit Rust workspace, pinned toolchains, and exact quality gates | Accepted | implemented-main | ADR 0014 governs scientific/product claim promotion beyond repository-quality tooling. | diff --git a/docs/research/multilevel-event-time-recovery.md b/docs/research/multilevel-event-time-recovery.md new file mode 100644 index 00000000..f3c7b1a5 --- /dev/null +++ b/docs/research/multilevel-event-time-recovery.md @@ -0,0 +1,172 @@ +# Multilevel cluster-mean OLS and event-time log-rate + +## Scope + +This slice stays inside `psychometric_core`. It does not add a second invariance crate and does not recreate `#78` `longitudinal_core` or `#80` `irregular_time`. + +1. recover within-cluster and between-cluster OLS slopes after centering within cluster (CWC); +2. recover the CWC contextual effect as `between − within` (Enders & Tofighi, 2007, Table 2); +3. recover a Kish-weighted least-squares slope and report Kish ESS as the information diagnostic; +4. map a discrete lag-1 coefficient through the exact scalar exponential on **event time only**; +5. recover the exact scalar forward map `φ(Δt) = exp(a Δt)` and remap a discrete lag onto another event interval through that log-rate; +6. refuse a binary64 underflow of that forward map to `+0` (not a discrete lag); +7. recover the exact scalar discrete effect of a constant predictor (Voelkle et al., 2012, Eq. 12); +8. recover the first-order discrete effect of a time-varying predictor whose sampling interval equals its constancy interval (Voelkle et al., 2012, Eq. 14); +9. recover the exact scalar discrete process noise of Driver, Oud, and Voelkle (2017, Eq. 3); +10. recover the exact scalar lagged latent covariance `A_Δt cov(η_{t-1})` (Driver et al., 2017, Eq. 3–4); +11. recover the exact scalar discrete latent variance `A_Δt P A_Δt⊤ + Q_Δt` and refuse `Q_Δt` as that unconditional variance; +12. recover the exact scalar stationary within-subject variance as the `Δt → ∞` limit of Eq. 4 (`asymDIFFUSION`; §4.3 T0VAR stationarity) and refuse finite-interval `Q_Δt` as that limit; +13. recover the exact scalar trait-plus-state variance and lagged covariance (Driver et al., 2017, §4.3 `TRAITVAR`) and refuse treating trait variance as process noise or as `asymDIFFUSION`; +14. recover the exact scalar observed-indicator variance `λ² Var(η) + θ` when `MANIFESTTRAITVAR` is zero and `λ² Var(η) + θ + ψ` otherwise (Driver et al., 2017, Eq. 5; Table 2 `MANIFESTVAR` is `Θ`; `MANIFESTTRAITVAR` is not `MANIFESTVAR`) and refuse treating measurement error, latent variance, or manifest-trait variance as `Var(y)` / `Θ`; +15. recover the exact scalar lagged observed-indicator covariance `λ² cov(η_t, η_{t-1}) + ψ` (Driver et al., 2017, Eq. 5; `Θ` does not enter) and refuse treating lagged latent covariance or `MANIFESTVAR` as `cov(y_t, y_{t-1})`; +16. recover the exact scalar observed-indicator mean `τ + λ μ` (Driver et al., 2017, Eq. 5; Table 2 `MANIFESTMEANS` is `τ`, not `E(y)`; `CINT` is not `MANIFESTMEANS`; `T0MEANS` is not `E(y)`) and refuse treating the intercept, latent mean, or continuous intercept as `E(y)` / `τ`; +17. recover the exact scalar discrete latent mean `exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ` (Driver et al., 2017, Eq. 3; Table 2 `T0MEANS` is not `μ_t`; `CINT` is not the discrete increment) and refuse treating `T0MEANS` or `CINT` as `μ_t`; +18. recover the exact scalar evolved observed-indicator mean `τ + λ μ_t` (Driver et al., 2017, Eq. 5 of the Eq. 3 expected-value map; the first-occasion map `τ + λ μ_0` is not `E(y_t)`) and refuse treating that first-occasion mean as `E(y_t)`; +19. recover the exact scalar contemporaneous time-dependent predictor impulse `m x` (Driver et al., 2017, Eq. 3 fourth summand; Table 2 `TDPREDEFFECT` is `M`) and refuse treating that impulse as `CINT`, `TIPREDEFFECT`, or Voelkle et al. (2012, Eq. 14); +20. recover the exact scalar observed mean of that contemporaneous impulse `τ + λ(μ_t + m x)` (Driver et al., 2017, Eq. 5 of the Eq. 3 fourth-summand composition; the evolved map `τ + λ μ_t` is not that observed mean; the carry map `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t`) and refuse treating `τ + λ μ_t` as that observed mean; +21. recover the exact scalar time-independent predictor increment `A^{-1}[e^{A Δt} − I] B z` (Driver et al., 2017, Eq. 3 second summand; Table 2 `TIPREDEFFECT` is `B`) and refuse treating that increment as `CINT`, `TDPREDEFFECT`, Voelkle et al. (2012, Eq. 14), or the coefficient `B`; +22. recover the exact scalar observed mean of that increment `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` (Driver et al., 2017, Eq. 5 of the Eq. 3 printed addend after the `T0MEANS` carry and the `CINT` increment; the evolved map `τ + λ μ_t` is not that observed mean; the contemporaneous map `τ + λ(μ_t + m x)` is not that observed mean; the carry map `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t`) and refuse treating `τ + λ μ_t`, `τ + λ(μ_t + m x)`, or `τ + λ(μ_t + e^{a(t−u)} m x)` as that observed mean; +23. recover the exact scalar within-interval time-dependent impulse carry `e^{A(t−u)} M x` for `t0 < u < t` (Driver et al., 2017, Eq. 1–2 Green-function integral of Eq. 2; §7.2 dissipation; the printed Eq. 3 fourth summand is the contemporaneous Dirac) and refuse treating that carry as the contemporaneous impulse, `CINT`, `TIPREDEFFECT`, or Voelkle et al. (2012, Eq. 14); +24. recover the exact scalar observed mean of that carry `τ + λ(μ_t + e^{a(t−u)} m x)` (Driver et al., 2017, Eq. 5 of the Eq. 1–2 carried latent mean; the evolved map `τ + λ μ_t` is not that observed mean; the contemporaneous map `τ + λ(μ_t + m x)` is not that observed mean when `u ≠ t`) and refuse treating `τ + λ μ_t` or `τ + λ(μ_t + m x)` as that observed mean; +25. recover the exact scalar first-occasion `T0TIPREDEFFECT` shift `t0_b z` and its Eq. 3 first-summand carry `e^{A Δt} t0_b z` (Driver et al., 2017, Table 3, p. 13; Eq. 3, p. 5; JSS PDF opened 2026-08-20T15:14Z) and refuse treating `t0_b z` as `A^{-1}[e^{A Δt} − I] B z`, as `κ`, or as `M x`; refuse treating `e^{A Δt} t0_b z` as `t0_b z`; refuse treating the coefficient as the shift; +26. recover the exact scalar observed mean of that first-occasion carry `τ + λ(μ_t + e^{a Δt} t0_b z)` (Driver et al., 2017, Eq. 5 of the Table 3 / Eq. 3 first-summand composition; JSS PDF re-opened 2026-08-20T15:28Z; the evolved map `τ + λ μ_t` is not that observed mean; the process-increment map `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not that observed mean; the contemporaneous map `τ + λ(μ_t + m x)` is not that observed mean; the impulse-carry map `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t0`) and refuse treating `τ + λ μ_t`, `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`, `τ + λ(μ_t + m x)`, or `τ + λ(μ_t + e^{a(t−u)} m x)` as that observed mean; +27. recover the exact scalar first-occasion `T0TDPREDEFFECT` shift `t0_m x0` and its Eq. 3 first-summand carry `e^{A Δt} t0_m x0` (Driver et al., 2017, Table 3, p. 13; Eq. 3, p. 5; JSS PDF re-opened 2026-08-20T19:10Z) and refuse treating `t0_m x0` as `M x`, as `e^{A(t−u)} M x` for `t0 < u < t`, as `t0_b z`, as `A^{-1}[e^{A Δt} − I] B z`, or as `κ`; refuse treating `e^{A Δt} t0_m x0` as `t0_m x0` or as `e^{A(t−u)} M x`; refuse treating the coefficient as the shift; an impulse at `u ≤ t0` that used `M` is already in `η(t0)` as `TDPREDEFFECT`, not as `T0TDPREDEFFECT`; +28. recover the exact scalar observed mean of that first-occasion TD carry `τ + λ(μ_t + e^{a Δt} t0_m x0)` (Driver et al., 2017, Eq. 5 of the Table 3 / Eq. 3 first-summand TD composition; JSS PDF re-opened 2026-08-20T19:07Z; the evolved map `τ + λ μ_t` is not that observed mean; the process-increment map `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not that observed mean; the contemporaneous map `τ + λ(μ_t + m x)` is not that observed mean; the impulse-carry map `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t0`; the first-occasion TI map `τ + λ(μ_t + e^{a Δt} t0_b z)` is not that observed mean) and refuse treating `τ + λ μ_t`, `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`, `τ + λ(μ_t + m x)`, `τ + λ(μ_t + e^{a(t−u)} m x)`, or `τ + λ(μ_t + e^{a Δt} t0_b z)` as that observed mean; +29. recover the exact scalar §7.2 level-change `CINT` `κ = −a m x` (Driver et al., 2017, §7.2, pp. 20–21; JSS PDF re-opened 2026-08-20T19:45Z; form `m x` first, then multiply by `−a`; `a < 0` so `−κ / a = m x`) and refuse treating `−a m x` as the dissipating Dirac `m x`, as a free `CINT`, or as `A^{-1}[e^{A Δt} − I] B z`; `a ≥ 0` cannot hold a new process mean; the extra near-zero-drift latent process also named in §7.2 is a different specification; +30. recover the exact scalar Eq. 3 increment of that level-change `CINT` `(1 − e^{a Δt}) m x` (Driver et al., 2017, §7.2, pp. 20–21; Eq. 3, pp. 4–5; JSS PDF re-opened 2026-08-20T19:50Z; form `κ = −a m x` first, then `A^{-1}[e^{A Δt} − I] κ`; underflow of `e^{a Δt}` to `+0` keeps `m x`) and refuse treating `(1 − e^{a Δt}) m x` as `m x`, as `κ`, or as `A^{-1}[e^{A Δt} − I] B z`; +31. recover the exact scalar §7.2 extra near-zero-drift latent process contribution `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` (Driver et al., 2017, §7.2, pp. 22–23; JSS PDF re-opened 2026-08-20T23:10Z; identification `TDPREDEFFECT` on the extra process is 1; extra `DRIFT` printed as `−0.000001`; precisely 0 causes computational problems; `ε = a` is `a_{ηξ} x Δt e^{a Δt}`) and refuse treating that contribution as `κ = −a m x`, as `(1 − e^{a Δt}) m x`, or as the dissipating Dirac `m x`; `ε ≥ 0` fails closed; +32. recover the exact scalar observed mean of that extra-process contribution `τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a))` (Driver et al., 2017, Eq. 5, p. 5; §7.2, pp. 22–23; JSS PDF re-opened 2026-08-21T06:12Z; the extra process has `LAMBDA` 0 and is not an observed indicator; form the evolved-plus-contribution latent mean first, then `τ + λ` of that mean) and refuse treating `τ + λ μ_t`, `τ + λ(μ_t + m x)`, the contribution, or the evolved-plus-contribution latent mean as `E(y_t)`; +33. recover the exact scalar after-t0 extra-process contribution `a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a)` for `t0 < u < t` and its Eq. 5 observed mean `τ + λ(μ_t + a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a))` (Driver et al., 2017, Eq. 5, p. 5; §7.2, pp. 22–23; JSS PDF re-opened 2026-08-21T06:32Z; `T0TDPREDEFFECT` uses `Δt = t − t0` for both the evolution and the extra drive; `TDPREDEFFECT` after `t0` uses `t − u` for the extra drive while `μ_t` still uses `Δt`; an impulse at `u = t0` or `u = t` is not interior; `e^{a(t−u)} m x` is a Dirac on the original process, not this `DRIFT` drive) and refuse treating the first-occasion extra-process observed mean, `τ + λ μ_t`, the impulse-carry observed mean, the after-t0 contribution, or the evolved-plus-after-contribution latent mean as that `E(y_t)`; +34. recover the exact scalar §7.2 `asymTIPREDEFFECT` `-B z / a` (Driver et al., 2017, §7.2, pp. 20–21; Eq. 3, p. 5; Table 2, p. 12; JSS PDF opened 2026-08-21T13:08Z; expected total change in process means given a time-independent predictor; form `B z` first, then divide by `-a`; `a < 0`; a zero coefficient or zero predictor is exactly zero) and refuse treating `-B z / a` as the coefficient `B`, as `A^{-1}[e^{A Δt} − I] B z`, as `CINT`, or as `M x`; `a ≥ 0` cannot hold a finite process-mean change; +35. recover the exact scalar §7.2 `addedTIPREDVAR` `(B / a)² v` (Driver et al., 2017, §7.2, pp. 20–21; JSS PDF opened 2026-08-21T13:08Z; stable between-subject variance accounted for by a time-independent predictor; form the unit asymptotic effect first, then square, then multiply by `v`; `v ≥ 0`; a zero coefficient or zero predictor variance is exactly zero) and refuse treating `(B / a)² v` as `TRAITVAR`, as `asymDIFFUSION`, or as `-B z / a`; +36. recover the exact scalar Table 2 `asymCINT` `-κ / a` (Driver et al., 2017, Table 2, p. 12; Eq. 3, p. 5; §4.3 / p. 16; JSS PDF opened 2026-08-21T16:13Z; expected change in process means for a unit intercept; form `κ` first, then divide by `-a`; `a < 0`; a zero intercept is exactly zero) and refuse treating `-κ / a` as `κ`, as `A^{-1}[e^{A Δt} − I] κ`, as `T0MEANS`, or as `-B z / a`; `a ≥ 0` cannot hold a finite process-mean change; p. 16 `T0MEANS` stationarity includes TI predictors and is not this intercept-only map; +37. recover the exact scalar p. 16 stationary `T0MEANS` `-κ / a + −B z / a` (Driver et al., 2017, p. 16; Table 2, p. 12; Eq. 3, p. 5; JSS PDF opened 2026-08-21T16:13Z; constrain `T0MEANS` to model-implied values using `T0MEANSbase` / `T0MEANSfree`; form the intercept contribution first, then include the TI extra effect, then add; a zero intercept and a zero TI contribution is exactly zero) and refuse treating that composition as free `T0MEANS`, as `asymCINT` alone, as `asymTIPREDEFFECT` alone, or as the finite-interval discrete latent mean; +38. recover the exact scalar Eq. 5 of §4.3 stationary `T0MEANS` `τ + λ(−κ / a + −B z / a)` (Driver et al., 2017, §4.3, pp. 9–10; Eq. 5, p. 5; Table 2, p. 12; JSS PDF re-opened 2026-08-21T20:07Z; form the stationary latent mean first, then `τ + λ` of that mean; a zero loading is exactly `τ`; evolving from that stationary start with `CINT` and `TIPREDEFFECT` stays at the stationary mean) and refuse treating `τ + λ μ_0`, `τ + λ(−κ / a)` when `B z ≠ 0`, `τ + λ μ_t`, `MANIFESTMEANS`, or the constrained latent mean as `E(y_0)`; +39. recover the exact scalar §4.3 / p. 16 stationary `T0VAR` `trait + −q / (2 a) + (B / a)² v` (Driver et al., 2017, §4.3, pp. 9–10; p. 16; Table 2, p. 12; §7.2, pp. 20–21; Eq. 4, p. 5; JSS PDF re-opened 2026-08-22T03:07Z; form the within-subject contribution first, then include the trait, then include the TI extra variance, then add; a zero trait, a zero diffusion, and a zero TI contribution is exactly zero; a zero diffusion and a zero TI contribution is exactly the trait) and refuse treating that composition as free `T0VAR`, as `asymDIFFUSION` alone, as `TRAITVAR` alone, as `addedTIPREDVAR` alone, or as the finite-interval discrete latent variance; +40. recover the exact scalar Eq. 5 of §4.3 stationary `T0VAR` `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` (Driver et al., 2017, §4.3, pp. 9–10; Eq. 5, p. 5; Table 2, p. 12; JSS PDF re-opened 2026-08-22T03:20Z; form the stationary latent variance first, then `λ² p + θ + ψ`; a zero loading is exactly `θ + ψ`; a zero trait, a zero diffusion, and a zero TI contribution is exactly `θ + ψ`) and refuse treating `λ² p_0 + θ`, `λ²(−q / (2 a)) + θ` when `TRAITVAR` or `addedTIPREDVAR` is nonzero, `λ² Var(η_t) + θ`, `MANIFESTVAR`, or the constrained latent variance as `Var(y_0)`; +41. recover the exact scalar lagged covariance of §4.3 stationary `T0VAR` `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` (Driver et al., 2017, §4.3, pp. 9–10; Eq. 3–4, pp. 4–5; Table 2, p. 12; p. 16; §7.2, pp. 20–21; JSS PDF re-opened 2026-08-22T19:13Z; form the lagged within-subject covariance first, then include the trait, then include the TI extra variance, then add; trait and `addedTIPREDVAR` do not decay with `e^{a Δt}`; as `Δt → ∞` the state term vanishes; as `Δt → 0+` the lagged map approaches contemporaneous `T0VAR`) and refuse treating that composition as contemporaneous `T0VAR`, as `e^{a Δt}` of the constrained total, or as trait-plus-state lagged covariance when `addedTIPREDVAR` is nonzero; +42. recover the exact scalar Eq. 5 of lagged §4.3 stationary `T0VAR` `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ` (Driver et al., 2017, Eq. 5, p. 5; Eq. 3–4, pp. 4–5; Table 2, p. 12; JSS PDF re-opened 2026-08-22T19:13Z; form the lagged latent covariance first, then `λ² c + ψ`; a zero loading is exactly `ψ`; independent `ε_t` does not enter) and refuse treating `θ`, contemporaneous `Var(y_0)`, or the lagged latent covariance as `cov(y_t, y_{t-1})`; +43. recover the exact scalar later-occasion variance of §4.3 stationary `T0VAR` `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v` (Driver et al., 2017, §4.3, pp. 9–10; Eq. 3–4, pp. 4–5; Table 2, p. 12; p. 16; §7.2, pp. 20–21; JSS PDF re-opened 2026-08-22T23:12Z; form the evolved within-subject variance first, then include the trait, then include the TI extra variance, then add; trait and `addedTIPREDVAR` do not enter `Q_Δt`; under stationarity that composition equals contemporaneous `T0VAR`) and refuse treating that composition as lagged covariance, as `e^{2 a Δt}` of the constrained total plus `Q_Δt`, or as `Q_Δt` alone; +44. recover the exact scalar Eq. 5 of later-occasion §4.3 stationary `T0VAR` `λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ` (Driver et al., 2017, Eq. 5, p. 5; Eq. 3–4, pp. 4–5; Table 2, p. 12; JSS PDF re-opened 2026-08-22T23:12Z; form the later-occasion latent variance first, then `λ² p + θ + ψ`; a zero loading is exactly `θ + ψ`; under stationarity that composition equals contemporaneous `Var(y_0)`) and refuse treating `θ`, lagged `cov(y_t, y_{t-1})`, or the later-occasion latent variance as `Var(y_t)`; +45. refuse pooling discrete lags from unequal event intervals as one coefficient; +46. refuse unmatched sampling and constancy intervals for a time-varying predictor (Oud & Jansen, 2000, unread); +47. refuse the difference quotient as a continuous-time rate; +48. apply the same event-time map to CWC residuals (still not DSEM); +49. map already-centered lagged residuals with irregular event intervals without re-centering (Curran & Bauer, 2011, pp. 607–608). + +## Claim boundary + +This is two-level OLS and a noiseless scalar continuous-time map. It is not DSEM, not RI-CLPM, not a random-effects sampler, not a Kalman filter, and not a matrix `expm` implementation. The CWC cluster-mean coefficient is the **contextual** effect, not the between-cluster effect. Discrete lags from different event intervals are not one coefficient. Equation 14 is not Equation 12. Discrete process noise \(Q_{\Delta t}\) is not the continuous diffusion \(GG^{\top}\). \(Q_{\Delta t}\) is the conditional residual variance, not \(\operatorname{Var}(\eta_{t})\). Finite-interval \(Q_{\Delta t}\) is not the stationary within-subject variance. Trait variance is not process noise and not the stationary within-subject variance. Measurement-error variance is not the observed-indicator variance. Latent variance is not the observed-indicator variance. Manifest means are not the observed-indicator mean. The latent mean is not the observed-indicator mean. The continuous intercept is not the manifest mean. The first-occasion latent mean is not the evolved latent mean. The continuous intercept is not the discrete mean increment. The first-occasion observed mean is not the evolved observed mean. The contemporaneous `TDPREDEFFECT` impulse is not the continuous intercept, not the time-independent discrete effect, and not Voelkle et al. (2012, Eq. 14). The time-independent `TIPREDEFFECT` increment is not the continuous intercept, not the contemporaneous impulse, not Voelkle et al. (2012, Eq. 14), and not the coefficient `B`. The within-interval `TDPREDEFFECT` carry `e^{A(t−u)} M x` for `t0 < u < t` is not the contemporaneous Dirac, not `CINT`, not `TIPREDEFFECT`, and not Voelkle et al. (2012, Eq. 14). The evolved observed mean `τ + λ μ_t` is not the contemporaneous-impulse observed mean `τ + λ(μ_t + m x)`. The contemporaneous composition `τ + λ(μ_t + m x)` is not the impulse-carry observed mean `τ + λ(μ_t + e^{a(t−u)} m x)` when `u ≠ t`. The evolved observed mean `τ + λ μ_t` is not the impulse-carry observed mean. The carried latent mean is not `E(y_t)`. The evolved-plus-impulse latent mean is not `E(y_t)`. The evolved observed mean `τ + λ μ_t` is not the time-independent-predictor observed mean `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`. The contemporaneous composition `τ + λ(μ_t + m x)` is not that time-independent-predictor observed mean. The impulse-carry composition `τ + λ(μ_t + e^{a(t−u)} m x)` is not that time-independent-predictor observed mean when `u ≠ t`. The evolved-plus-increment latent mean is not `E(y_t)`. The Table 3 first-occasion `T0TIPREDEFFECT` shift `t0_b z` is not the Eq. 3 process increment `A^{-1}[e^{A Δt} − I] B z`, not `κ`, and not `M x`. The Eq. 3 first-summand carry `e^{A Δt} t0_b z` is not `t0_b z` and is not that process increment. `T0TIPREDEFFECT` is the coefficient, not the first-occasion shift. The evolved observed mean `τ + λ μ_t` is not the first-occasion TI-predictor observed mean `τ + λ(μ_t + e^{a Δt} t0_b z)`. The process-increment composition `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not that first-occasion observed mean. The contemporaneous composition `τ + λ(μ_t + m x)` is not that first-occasion observed mean. The impulse-carry composition `τ + λ(μ_t + e^{a(t−u)} m x)` is not that first-occasion observed mean when `u ≠ t0`. The evolved-plus-T0TIPRED latent mean is not `E(y_t)`. The Table 3 first-occasion `T0TDPREDEFFECT` shift `t0_m x0` is not `M x`, not `e^{A(t−u)} M x` for `t0 < u < t`, not `t0_b z`, not `A^{-1}[e^{A Δt} − I] B z`, and not `κ`. The Eq. 3 first-summand carry `e^{A Δt} t0_m x0` is not `t0_m x0` and is not that within-interval impulse carry. `T0TDPREDEFFECT` is the coefficient, not the first-occasion shift. An impulse at `u ≤ t0` that used `M` is already in `η(t0)` as `TDPREDEFFECT`, not as `T0TDPREDEFFECT`. The evolved observed mean `τ + λ μ_t` is not the first-occasion TD-predictor observed mean `τ + λ(μ_t + e^{a Δt} t0_m x0)`. The process-increment composition `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not that first-occasion TD observed mean. The contemporaneous composition `τ + λ(μ_t + m x)` is not that first-occasion TD observed mean. The impulse-carry composition `τ + λ(μ_t + e^{a(t−u)} m x)` is not that first-occasion TD observed mean when `u ≠ t0`. The first-occasion TI composition `τ + λ(μ_t + e^{a Δt} t0_b z)` is not that first-occasion TD observed mean. Same numbers as `T0TIPREDEFFECT` yield the same product; Table 3 names a different matrix. The evolved-plus-T0TDPRED latent mean is not `E(y_t)`. The §7.2 level-change `CINT` `κ = −a m x` is not the dissipating Dirac `m x`, not a free `CINT`, and not `A^{-1}[e^{A Δt} − I] B z`. Lasting level change via that `CINT` setting requires `a < 0`. The extra near-zero-drift latent process also named in §7.2 is a different specification and is not that `CINT` setting. The Eq. 3 increment of that setting `(1 − e^{a Δt}) m x` is not the dissipating Dirac `m x`, not `κ`, and not `A^{-1}[e^{A Δt} − I] B z`. Underflow of `e^{a Δt}` to `+0` keeps the equilibrium offset `m x`. The §7.2 extra-process contribution `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` is not `κ = −a m x`, not `(1 − e^{a Δt}) m x`, and not the dissipating Dirac `m x`. Lasting level change via that extra process requires `ε < 0`. Precisely `ε = 0` causes computational problems in the printed specification. The extra-process observed mean `τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a))` is not `τ + λ μ_t`, not `τ + λ(μ_t + m x)`, not the contribution, and not the evolved-plus-contribution latent mean. The extra process has `LAMBDA` 0 and is not an observed indicator. `T0TDPREDEFFECT` on the extra process uses `Δt = t − t0` for both the original-process evolution and the extra drive. `TDPREDEFFECT` after `t0` uses `t − u` with `t0 < u < t` for the extra drive while `μ_t` still uses `Δt`. The after-t0 extra-process observed mean is not the first-occasion extra-process observed mean when `u ≠ t0`. The impulse-carry `e^{a(t−u)} m x` is a Dirac on the original process and is not that `DRIFT` drive. The §7.2 `asymTIPREDEFFECT` `-B z / a` is the expected total change in process means given a time-independent predictor. It is not the coefficient `B`, not the finite-interval increment `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, and not `M x`. Lasting asymptotic change via that map requires `a < 0`. The §7.2 `addedTIPREDVAR` `(B / a)² v` is the stable between-subject variance accounted for by that predictor. It is not `TRAITVAR`, not `asymDIFFUSION`, and not `-B z / a`. Table 2 `asymCINT` `-κ / a` is the intercept contribution to the stationary process mean. It is not `κ`, not the finite-interval increment `A^{-1}[e^{A Δt} − I] κ`, not `T0MEANS`, and not `-B z / a`. Lasting asymptotic intercept change via that map requires `a < 0`. Page 16 notes that a `T0MEANS` stationarity constraint includes time-independent predictors; that composition is not this intercept-only map. The p. 16 constrained first-occasion mean `-κ / a + −B z / a` is not free `T0MEANS`, not `asymCINT` alone, not `asymTIPREDEFFECT` alone, and not the finite-interval discrete latent mean. Equation 5 of that constrained mean `τ + λ(−κ / a + −B z / a)` is not `τ + λ μ_0`, not `τ + λ(−κ / a)` when `B z ≠ 0`, not `τ + λ μ_t`, not `MANIFESTMEANS`, and not the constrained latent mean. The p. 16 constrained first-occasion variance `trait + −q / (2 a) + (B / a)² v` is not free `T0VAR`, not `asymDIFFUSION` alone, not `TRAITVAR` alone, not `addedTIPREDVAR` alone, and not the finite-interval discrete latent variance. Equation 5 of that constrained variance `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` is not `λ² p_0 + θ`, not `λ²(−q / (2 a)) + θ` when `TRAITVAR` or `addedTIPREDVAR` is nonzero, not `λ² Var(η_t) + θ` when the first occasion is constrained, not `MANIFESTVAR`, and not the constrained latent variance. The lagged covariance of that constrained process `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` is not contemporaneous `T0VAR`, not `e^{a Δt}` of the constrained total, and not `trait + e^{a Δt} p` when `addedTIPREDVAR` is nonzero. Trait variance and `addedTIPREDVAR` do not decay with `e^{a Δt}`. Equation 5 of that lagged covariance `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ` is not `θ`, not contemporaneous `Var(y_0)`, and not the lagged latent covariance. Independent measurement error does not enter lagged observed covariance. The later-occasion variance of that constrained process `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v` equals contemporaneous `T0VAR` under stationarity and is not the lagged covariance, not `e^{2 a Δt}` of the constrained total plus `Q_Δt`, and not `Q_Δt` alone. Trait variance and `addedTIPREDVAR` do not enter `Q_Δt`. Equation 5 of that later-occasion variance `λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ` is not `θ`, not lagged `cov(y_t, y_{t-1})`, and not the later-occasion latent variance. + +## Authoritative sources + +Enders, C. K., & Tofighi, D. (2007). Centering predictor variables in cross-sectional multilevel models: A new look at an old issue. *Psychological Methods, 12*(2), 121–138. https://doi.org/10.1037/1082-989X.12.2.121 + +Curran, P. J., & Bauer, D. J. (2011). The disaggregation of within-person and between-person effects in longitudinal models of change. *Annual Review of Psychology, 62*, 583–619. https://doi.org/10.1146/annurev.psych.093008.100356 + +Hamaker, E. L., Kuiper, R. M., & Grasman, R. P. P. P. (2015). A critique of the cross-lagged panel model. *Psychological Methods, 20*(1), 102–116. https://doi.org/10.1037/a0038889 + +Voelkle, M. C., Oud, J. H. L., Davidov, E., & Schmidt, P. (2012). An SEM approach to continuous time modeling of panel data: Relating authoritarianism and anomia. *Psychological Methods, 17*(2), 176–192. https://doi.org/10.1037/a0027543 + +Driver, C. C., Oud, J. H. L., & Voelkle, M. C. (2017). Continuous time structural equation modeling with R package ctsem. *Journal of Statistical Software, 77*(5), 1–35. https://doi.org/10.18637/jss.v077.i05 + +Kish, L. (1965). *Survey sampling*. John Wiley & Sons. + +Oud, J. H. L., & Jansen, R. A. R. G. (2000). Continuous time state space modeling of panel data by means of SEM. *Psychometrika, 65*(2), 199–215. https://doi.org/10.1007/BF02294374 (cited by Voelkle et al., 2012, Eq. 14 discussion; PDF not opened). + +The Voelkle et al. (2012) ZORA accepted manuscript was re-opened 2026-08-18T21:07Z (https://www.zora.uzh.ch/handle/20.500.14742/72792; bitstream `424f9082-0eeb-4a67-b687-9845a4ed892f`). Page 16 writes discrete auto-effects as \(A^{*}(\Delta t)=\exp(A\Delta t)\) (Eq. 7). Introducing Intercepts (manuscript p. 20, Eq. 12) adds a continuous-time intercept \(b\) and writes the expected-value solution whose discrete increment is \(A^{-1}(\exp(A\Delta t)-I)b\); the scalar constant-predictor effect is \(b^{*}_{y.x}(\Delta t)=(a_{yx}/a_{xx})(\exp(a_{xx}\Delta t)-1)\). The next paragraph (manuscript p. 21, Eq. 14) writes the discrete effect of a **time-varying** predictor, when the sampling interval equals the constancy interval, as \(b^{*}_{y.x}(\Delta t)=a_{yx}\Delta t\). That product does not depend on the predictor auto-effect. The manuscript calls Eq. 14 a first-order approximation that deteriorates as \(\Delta t\) grows, and defers unmatched sampling/constancy intervals to Oud and Jansen (2000), which is unread. Driver, Oud, and Voelkle (2017, Eq. 3 and p. 4; JSS PDF opened 2026-08-21T13:08Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) write \(A_{\Delta t}=\operatorname{expm}(A\Delta t)\), the discrete intercept \(b_{\Delta t}=A^{-1}[A_{\Delta t}-I]b\), and the discrete process-noise covariance \(Q_{\Delta t}=\int_{0}^{\Delta t}\operatorname{expm}(A(\Delta t-\tau))LGG^{\top}L^{\top}\operatorname{expm}(A(\Delta t-\tau))^{\top}\,d\tau\). Equation 4 (p. 5) writes that the integral exhibits covariance \(Q_{\Delta t}=\operatorname{irow}(A^{\#^{-1}}[e^{A^{\#}\Delta t}-I]\operatorname{row}(Q))\) with \(A^{\#}=A\otimes I+I\otimes A\). The homogeneous-process consequence (`ξ`, `z` given) is \(Q_{\Delta t}=\operatorname{cov}(\eta_{ti}\mid\eta_{t-1,i})\) and \(\operatorname{cov}(\eta_{ti},\eta_{t-1,i})=A_{\Delta t}\operatorname{cov}(\eta_{t-1,i})\). The law of total variance on that pair is \(\operatorname{Var}(\eta_{ti})=A_{\Delta t}\operatorname{Var}(\eta_{t-1,i})A_{\Delta t}^{\top}+Q_{\Delta t}\). As \(\Delta t\to\infty\) with stable \(a<0\), Eq. 4 becomes \(-q/(2a)\). The JSS summary names that limit `asymDIFFUSION` and takes it as the total within-subject variance (p. 16). Section 4.3 (pp. 9–10) constrains a stationary `T0VAR` to that model-predicted variance and distinguishes it from a predetermined first occasion. The same section (p. 9) adds a stable trait process with `DRIFT` and `DIFFUSION` fixed to zero; `TRAITVAR` is that time-invariant between-subject variance. Table 3 (p. 13) names `T0TIPREDEFFECT` the effect of time-independent predictors on latents at `T0` and names `TIPREDEFFECT` `B` separately. The JSS article has no numbered §2.2 (2.1 is Continuous time and SEM; §3 follows). This slice takes scalar \(L=1\) (every latent subject to system noise); it does not implement the 0/1 selector matrix and is not a Kalman filter. Discrete auto-effects are strictly positive for finite real drift and interval; the inverse \(a=\ln\varphi/\Delta t\) therefore requires \(\varphi>0\). Binary64 `exp` of a large negative argument is `+0` and is refused as a discrete lag; the same underflow is a vanishing lagged covariance and is kept. When `exp` of a finite `a Δt` overflows on the Table 3 first-summand carry `e^{A Δt} t0_b z` / `e^{A Δt} t0_m x0`, rewrite as `sign(shift) exp(ln|shift| + a Δt)`; an overflowing `a Δt` product fails closed. Integration tests execute those overflow-rewrite arms on the non-`cfg(test)` instantiation (nightly branch coverage on #49 head `d634f58` was 1718/1720 at those two `if !drift_interval.is_finite()` sites). Equations 3–4 of Voelkle et al. (2012) are the discouraged difference-quotient approximation. Meredith (1993) remains unread (Unpaywall/OpenAlex 2026-08-22T23:12Z: `is_oa: false`; Springer `content/pdf` is HTML 200, not a PDF; archive.org title search empty). Mislevy (1991, *Psychometrika, 56*, 177–196, DOI 10.1007/bf02294457) remains unread (Unpaywall/OpenAlex 2026-08-22T23:12Z: `is_oa: false`; Springer `content/pdf` is HTML 200; ETS landing page is HTML; ETS RR-88-45 PDF 404; Wiley PDF 403). ERIC ED334221 is Singer and Willett (1991), *From whether to when*, not the 1991 journal article. ERIC ED333032 is Mislevy, Sheehan, and Wingersky (1990), ETS RR-90-17-ONR, not the 1991 journal article. The 1988 ETS RR-88-45 / DTIC ADA200179 technical report of the same title is not the 1991 journal article. Oud and Jansen (2000) remains unread (Unpaywall/OpenAlex 2026-08-18T21:07Z: closed; Radboud landing and bitstream 403). + +## Formula notes + +- **CWC.** For cluster \(i\) and occasion \(t\), \(x_{it}^{w} = x_{it}-\bar x_{i}\) and \(y_{it}^{w} = y_{it}-\bar y_{i}\). The within slope is OLS of \(y^{w}\) on \(x^{w}\). The between slope is OLS of the cluster means. A grand-mean pooled slope confounds the two. +- **Contextual effect.** Enders and Tofighi (2007, Table 2, pp. 124–127): under CWC, the cluster-mean coefficient \(\gamma_{01}\) is the contextual effect (expected difference between two people with the same individual \(X\) from groups one unit apart on \(\bar X\)). Under CGM the same symbol is the between-cluster effect. The OLS identity is \(\gamma_{01}^{\mathrm{CWC}}=\beta_{\mathrm{between}}-\beta_{\mathrm{within}}\). Adding the CWC contextual coefficient to the within slope recovers the between-cluster slope. This crate reports the OLS analogue; it does not estimate their multilevel maximum-likelihood model. +- **Kish ESS.** \(\mathrm{ESS}=(\sum w)^{2}/\sum w^{2}\) on non-negative finite weights. WLS uses the weights in the slope; ESS is not a second slope. +- **Exact scalar map.** Voelkle et al. (2012, Eq. 7) and Driver et al. (2017, Eq. 3): \(\varphi = A^{*}(\Delta t)=\exp(a\,\Delta t)\). The inverse is \(a=\ln\varphi/\Delta t\). The forward map is the same equation. The real exponential is strictly positive; a binary64 underflow to `+0` is refused because the inverse logarithm does not exist at zero. The difference quotient \((x(t+\Delta t)-x(t))/\Delta t\) is refused. +- **Unequal-interval remap.** Discrete \(\varphi(\Delta t_1)\) and \(\varphi(\Delta t_2)\) are not comparable when \(\Delta t_1\neq\Delta t_2\) (Voelkle et al., 2012, ZORA manuscript pp. 2, 16, 33). The licensed path is \(a=\ln\varphi_{\mathrm{src}}/\Delta t_{\mathrm{src}}\) then \(\varphi_{\mathrm{ref}}=\exp(a\,\Delta t_{\mathrm{ref}})\). Pooling those discrete lags fails closed. +- **Constant-predictor discrete effect.** Voelkle et al. (2012, Eq. 12; ZORA accepted manuscript re-opened 2026-08-17T13:23Z, Introducing Intercepts): for a constant predictor with \(a_{xx}\neq 0\), \(b^{*}_{y.x}(\Delta t)=(a_{yx}/a_{xx})(\exp(a_{xx}\Delta t)-1)\). Driver, Oud, and Voelkle (2017, p. 4, after Eq. 3) restate the discrete intercept as a function of \(A\) and \(\Delta t\). The algebraically identical finite-`expm1` evaluation is \(a_{yx}(\operatorname{expm1}(z)/a_{xx})\) with \(z=a_{xx}\Delta t\). Dividing the increment by the finite auto-effect keeps the equilibrium increment \(-a_{yx}/a_{xx}\) when \(z\) overflows to \(-\infty\) (the manuscript notes that the exponential vanishes as \(\Delta t\) grows) and keeps a finite result when \(a_{yx}\Delta t\) overflows. When `expm1(z)` overflows to `+\infty` at a finite \(z\), rewrite as \(\operatorname{sign}(a_{yx}/a_{xx})\exp(\ln|a_{yx}|+z-\ln|a_{xx}|)-a_{yx}/a_{xx}\). A zero continuous effect is exactly zero even if `expm1` overflows. \(z\to+\infty\) fails closed unless \(a_{yx}=0\). When binary64 \(z\) underflows to `+0`, the mathematical limit of Eq. 12 is \(a_{yx}\Delta t\). That limit is IEEE-754 evaluation of Eq. 12, not a substitution of the first-order product as the general discrete effect. \(a_{xx}=0\) fails closed. This is not DSEM. +- **Time-varying-predictor discrete effect.** Voelkle et al. (2012, Eq. 14; ZORA accepted manuscript re-opened 2026-08-17T14:20Z, Introducing Intercepts, manuscript p. 21): when the predictor can take a new value at each occasion and the sampling interval equals the constancy interval, \(b^{*}_{y.x}(\Delta t)=a_{yx}\Delta t\). The effect does not depend on \(a_{xx}\). The manuscript states that this is a first-order approximation that deteriorates as \(\Delta t\) grows. It is not Eq. 12. Unmatched sampling and constancy intervals cite Oud and Jansen (2000), which is unread, and fail closed. +- **Discrete process noise.** Driver, Oud, and Voelkle (2017, Eq. 3; JSS PDF re-opened 2026-08-17T21:03Z, p. 4): \(Q_{\Delta t}=\int_{0}^{\Delta t}\operatorname{expm}(A(\Delta t-\tau))GG^{\top}\operatorname{expm}(A(\Delta t-\tau))^{\top}\,d\tau\). The scalar closed form with continuous diffusion \(q=GG^{\top}\ge 0\) is \(q(\mathrm{e}^{2a\Delta t}-1)/(2a)\) for \(a\neq 0\) and \(q\Delta t\) for \(a=0\). The algebraically identical finite-`expm1` evaluation is \(0.5 q(\operatorname{expm1}(z)/a)\) with \(z=2(a\Delta t)\). Form \(z\) as twice the product \(a\Delta t\). Forming \(2a\) first overflows when \(|a|\) is at the binary64 extreme even if \(a\Delta t\) and \(Q_{\Delta t}\) are finite. Binary64 underflow of \(z\) to `+0` recovers \(q\Delta t\). \(z\to-\infty\) keeps the equilibrium variance \(-q/(2a)=-0.5 q/a\); `expm1(−∞)` is \(-1\), so that path stays on the finite increment. When `expm1(z)` overflows at a finite \(z\), rewrite as \(\operatorname{sign}(q/a)\exp(\ln|q|+z-\ln|a|-\ln 2)-0.5 q/a\). An overflowing rewrite scale \(0.5 q/a\) is not a finite \(Q_{\Delta t}\) and fails closed (`q=1e308`, `a=0.1`, `Δt=4000` → `z=800`; JSS PDF re-opened 2026-08-18T03:07Z, p. 4). A zero diffusion is exactly zero even if `expm1` overflows. \(z\to+\infty\) fails closed unless \(q=0\). Negative \(q\) fails closed. This is not a Kalman filter and not a matrix `expm`. +- **Lagged latent covariance.** Driver, Oud, and Voelkle (2017, Eq. 3–4, pp. 4–5; JSS PDF re-opened 2026-08-18T11:20Z): \(\operatorname{cov}(\eta_{ti},\eta_{t-1,i})=A_{\Delta t}\operatorname{cov}(\eta_{t-1,i})\) for the homogeneous process. The scalar map is \(\mathrm{e}^{a\Delta t}p\) with prior variance \(p\ge 0\). This is not \(Q_{\Delta t}\). Binary64 underflow of \(\mathrm{e}^{a\Delta t}\) to `+0` is a vanishing covariance and is kept. A zero prior variance is exactly zero. Finite-\(a\Delta t\) exponential overflow rewrites as \(\exp(\ln p+a\Delta t)\). A finite \(\mathrm{e}^{a\Delta t}\) whose product with \(p\) overflows fails closed. The JSS article has no numbered §2.2. +- **Discrete latent variance.** Equations 3–4 write \(Q_{\Delta t}\) as the covariance of the stochastic integral, so \(Q_{\Delta t}=\operatorname{cov}(\eta_{ti}\mid\eta_{t-1,i})\) when \(\xi\) and \(z\) are given. The law of total variance on that pair is \(\operatorname{Var}(\eta_{ti})=A_{\Delta t}\operatorname{Var}(\eta_{t-1,i})A_{\Delta t}^{\top}+Q_{\Delta t}\). The scalar map is \(\mathrm{e}^{2a\Delta t}p+Q_{\Delta t}\). This is not a Kalman measurement update. A zero prior variance is exactly \(Q_{\Delta t}\). Binary64 underflow of \(\mathrm{e}^{2a\Delta t}\) keeps \(Q_{\Delta t}\). Finite-\(z\) exponential overflow rewrites as \(\exp(\ln p+z)+Q_{\Delta t}\). A zero diffusion is exactly \(Q_{\Delta t}=0\); that skip does not license a non-finite carried term when \(2(a\Delta t)\) overflows to \(+\infty\). Treating \(Q_{\Delta t}\) as \(\operatorname{Var}(\eta_{t})\) fails closed. +- **Stationary within-subject variance.** Driver et al. (2017, Eq. 4 as \(\Delta t\to\infty\); §4.3 pp. 9–10; p. 16 `asymDIFFUSION`; JSS PDF re-opened 2026-08-19T04:10Z, p. 5): for stable \(a<0\), \(\lim_{\Delta t\to\infty}Q_{\Delta t}=-q/(2a)\). When \(2a\) is finite, form \(q/-(2a)\) so \(q/a\) overflow does not lose a finite Lyapunov solution (`q=MAX`, `a=-0.75` → `MAX/1.5`; CodeRabbit on `75ecdd3`). When \(2a\) overflows, form \((q/a)\times-0.5\). Forming \(2a\) as the only path overflows when \(|a|\) is at the binary64 extreme (`a=-1e308`, `q=1e308` → `0.5`). Forming \(0.5q\) first underflows at the minimum subnormal (`q=from_bits(1)`, `a=-from_bits(1)` → naive `+0`; representable solution `0.5`). Starting from that variance, \(\operatorname{Var}(\eta_{t})\) is invariant across finite event intervals. A zero diffusion is exactly zero. \(a\ge 0\) has no finite stationary variance (Brownian \(a=0\) grows as \(q\Delta t\)). An overflowing Lyapunov solution fails closed. Finite-interval \(Q_{\Delta t}\) is not that limit. This is not ctsem estimation and not a Kalman filter. +- **Trait-plus-state variance.** Driver et al. (2017, §4.3, p. 9; JSS PDF re-opened 2026-08-18T21:07Z): a stable trait process has `DRIFT` and `DIFFUSION` fixed to zero. The scalar maps are \(\operatorname{Var}=\mathrm{trait}+\mathrm{state}\) and \(\operatorname{cov}(t,t-1)=\mathrm{trait}+\mathrm{e}^{a\Delta t}p\). The ctsem `TRAITVAR` rewrite that adds the trait to `DIFFUSION` does not license treating trait variance as \(Q_{\Delta t}\). Trait variance is not `asymDIFFUSION`. Evolving the summed variance as if it were all state is not this map. A zero trait is exactly the state. A zero state is exactly the trait. An overflowing sum fails closed. This is not RI-CLPM and not ctsem estimation. +- **Observed-indicator variance.** Driver et al. (2017, Eq. 5, p. 5; Table 2, p. 12; JSS PDF re-opened 2026-08-19T04:18Z): \(y_i(t)=\tau_i+\Lambda\eta_i(t)+\varepsilon_i(t)\) with \(\varepsilon\sim N(0,\Theta)\) and \(\tau_i\sim N(\mu_{\tau},\Psi_{\tau})\). Equation 1 (p. 4) is the latent SDE. Table 2 names \(\Theta\) `MANIFESTVAR` and \(\Psi_{\tau}\) `MANIFESTTRAITVAR`; p. 16 restates those names. The scalar map is \(\operatorname{Var}(y)=\lambda^{2}\operatorname{Var}(\eta)+\theta\) when \(\Psi_{\tau}=0\) and \(\lambda^{2}\operatorname{Var}(\eta)+\theta+\psi\) otherwise. Form \((\lambda p)\lambda\) then add \(\theta\), then add \(\psi\). Forming \(\lambda^{2}\) first overflows at \(\lambda=10^{308}\), \(p=10^{-308}\). A zero loading or zero latent variance is exactly \(\theta\) (\(\Psi_{\tau}=0\)) or \(\theta+\psi\). A zero measurement error is exactly \(\lambda^{2}p+\psi\). A zero manifest trait is exactly \(\lambda^{2}p+\theta\). `MANIFESTVAR` is not \(\operatorname{Var}(y)\). `MANIFESTTRAITVAR` is not `MANIFESTVAR`. `TRAITVAR` is latent additional variance and is scaled by \(\lambda^{2}\); `MANIFESTTRAITVAR` is not. \(\operatorname{Var}(\eta)\) is not \(\operatorname{Var}(y)\). An overflowing product or sum fails closed. This is not a Kalman filter and not ctsem estimation. +- **Lagged observed-indicator covariance.** Driver et al. (2017, Eq. 5 with Eq. 3–4; JSS PDF re-opened 2026-08-19T04:18Z): independent \(\varepsilon_t\) does not enter \(\operatorname{cov}(y_t,y_{t-1})\). The scalar map is \(\lambda^{2}\operatorname{cov}(\eta_t,\eta_{t-1})+\psi\). Form \((\lambda c)\lambda\) then add \(\psi\). A zero loading or zero latent lagged covariance is exactly \(\psi\). A zero manifest trait is exactly \(\lambda^{2}c\). `MANIFESTVAR` is not lagged observed covariance. Lagged latent covariance is not lagged observed covariance. An overflowing product or sum fails closed. +- **Observed-indicator mean.** Driver et al. (2017, Eq. 5, p. 5; Table 2, p. 12; JSS PDF re-opened 2026-08-19T14:08Z): \(y_i(t)=\Gamma+\Lambda\eta_i(t)+\zeta_i(t)\) with \(\zeta\sim N(0,\Theta)\) and \(\Gamma\sim N(\tau,\Psi)\). Table 2 names \(\tau\) `MANIFESTMEANS`, \(\kappa\) `CINT`, and the first-occasion latent mean `T0MEANS`. The scalar map is \(E(y)=\tau+\lambda\mu\). Form \(\lambda\mu\) then add \(\tau\). A zero loading or zero latent mean is exactly \(\tau\). A zero intercept is exactly \(\lambda\mu\). `MANIFESTMEANS` is not \(E(y)\). \(E(\eta)\) is not \(E(y)\). `CINT` is not `MANIFESTMEANS`. `T0MEANS` is not \(E(y)\). An overflowing product or sum fails closed. This is not a Kalman filter and not ctsem estimation. +- **Discrete latent mean.** Driver et al. (2017, Eq. 3, p. 4; Table 2, p. 12; JSS PDF re-opened 2026-08-19T18:10Z): \(\eta(t)=\exp(A\Delta t)\eta(t_0)+\int\exp(A(t-s))(b+\cdots)\,ds\) plus a stochastic integral of mean zero. Table 2 names the first-occasion latent mean `T0MEANS` and \(\kappa\) `CINT`. The scalar map is \(\mu_t=\exp(a\Delta t)\mu_0+(\exp(a\Delta t)-1)/a\,\kappa\). Form the `CINT` increment first, then add the carried `T0MEANS` term. A zero drift is the Eq. 3 integral \(\kappa\Delta t\) (\(A=0\) has no inverse). A zero intercept is exactly \(\exp(a\Delta t)\mu_0\). A zero initial mean is exactly the increment. As \(\Delta t\to\infty\) with stable \(a<0\), \(\mu_t\to-\kappa/a\). Binary64 underflow of \(\exp(a\Delta t)\) to `+0` drops the carried `T0MEANS` and keeps that equilibrium increment. `T0MEANS` is not \(\mu_t\). `CINT` is not the discrete increment. `CINT` is not `T0MEANS`. An overflowing exponential, product, or sum fails closed. This is not a Kalman filter and not ctsem estimation. +- **Discrete observed-indicator mean.** Driver et al. (2017, Eq. 3, p. 5; Eq. 5, p. 5; Table 2, p. 12; JSS PDF re-opened 2026-08-19T22:10Z): `η_i(t)=exp(AΔt)η_i(t0)+A^{-1}[exp(AΔt)−I]ξ_i+…` with `ξ_i∼N(κ,φ_ξ)` and a stochastic integral of mean zero, then `y_i(t)=Γ_i+Λη_i(t)+ζ_i(t)` with `Γ∼N(τ,Ψ)`. The scalar composition is `E(y_t)=τ+λμ_t`. Form `μ_t` first, then `τ+λμ_t`. A zero loading or zero evolved latent mean is exactly `τ`. A zero intercept is exactly `λμ_t`. A zero drift is `τ+λ(μ_0+κΔt)`. Underflow of `exp(aΔt)` to `+0` keeps `τ+λ(−κ/a)`. The first-occasion map `τ+λμ_0` is not `E(y_t)`. `MANIFESTMEANS` is not `E(y_t)`. `T0MEANS` is not `E(y_t)`. `μ_t` is not `E(y_t)`. An overflowing exponential, product, or sum fails closed. This is not a Kalman filter and not ctsem estimation. +- **Time-dependent predictor impulse.** Driver et al. (2017, Eq. 1–3, pp. 4–5; Table 2, p. 12; §7.2, pp. 20–21; JSS PDF re-opened 2026-08-20T07:10Z): `χ_i(t)=Σ x_{i,u} δ(t−u)` and the fourth summand is `M Σ x_{i,u} δ(t−u)`. Table 2 names `M` `TDPREDEFFECT`. Section 7.2 calls this a sudden impulse that dissipates back to the process mean. The scalar contemporaneous jump is `m x`. Form `μ_t` first, then add `m x`. A zero effect or zero predictor is exactly zero. `TDPREDEFFECT` is not `CINT`. `M x` is not `A^{-1}[e^{AΔt}−I] B z` (`TIPREDEFFECT`). `M x` is not Voelkle et al. (2012, Eq. 14) `a_{yx}Δt`. The §7.2 lasting level change sets `CINT` to `TDPREDEFFECT * −DRIFT` (`κ=−a m x`) and is not this jump. The extra near-zero-drift latent process also named in §7.2 is a third specification. An overflowing product or sum fails closed. This is not a Kalman filter and not ctsem estimation. +- **Contemporaneous-impulse observed-indicator mean.** Driver et al. (2017, Eq. 5, p. 5; Eq. 1–3, pp. 4–5; Table 2, p. 12; §7.2, pp. 20–21; JSS PDF re-opened 2026-08-20T09:01Z): `y_i(t)=Γ+Λη_i(t)+ζ_i(t)` with `ζ∼N(0,Θ)` and `Γ∼N(τ,Ψ)`. The latent process at `t` after a contemporaneous Dirac (`u=t`) is `μ_t+mx`. The scalar composition is `E(y_t)=τ+λ(μ_t+mx)`. Form the evolved-plus-impulse latent mean first, then `τ+λ` of that mean. A zero loading is exactly `τ`. A zero evolved-plus-impulse latent mean is exactly `τ`. A zero intercept is exactly `λ(μ_t+mx)`. The evolved observed mean `τ+λμ_t` is not this composition. The carry map `τ+λ(μ_t+e^{a(t−u)}mx)` is not this composition when `u≠t`. `MANIFESTMEANS` is not `E(y_t)`. The evolved-plus-impulse latent mean is not `E(y_t)`. The §7.2 level-change form is not this map. An overflowing product or sum fails closed. This is not a Kalman filter and not ctsem estimation. +- **Time-independent predictor effect.** Driver et al. (2017, Eq. 1–3, pp. 4–5; Table 2, p. 12; JSS PDF re-opened 2026-08-20T10:13Z): Equation 1 writes `dη=(Aη+b+A_{ηξ}ξ+Bz)dt+GdW+Mdχ`. Equation 3's second summand is `A^{-1}[e^{AΔt}−I](b+A_{ηξ}ξ+Bz)`. Table 2 names `B` `TIPREDEFFECT`. The scalar map is `(e^{aΔt}−1)/a·Bz` for `a≠0`. Form `Bz` first, then the discrete intercept map. A zero drift is `BzΔt`. Form `μ_t` first, then add that increment. A zero effect or zero predictor is exactly zero. `TIPREDEFFECT` is `B`, not the discrete increment. `A^{-1}[e^{AΔt}−I]Bz` is not `CINT`, not `Mx`, and not Voelkle et al. (2012, Eq. 14) `a_{yx}Δt`. An overflowing product, increment, or sum fails closed. This is not a Kalman filter and not ctsem estimation. +- **Time-independent-predictor observed-indicator mean.** Driver et al. (2017, Eq. 5, p. 5; Eq. 3, p. 5; Table 2, p. 12; JSS PDF re-opened 2026-08-20T12:12Z): `y_i(t)=Γ+Λη_i(t)+ζ_i(t)` with `ζ∼N(0,Θ)` and `Γ∼N(τ,Ψ)`. Equation 3 prints the `TIPREDEFFECT` increment as the addend `A^{-1}[e^{A(t−t0)}−I]Bz_i` after the `T0MEANS` carry and the `CINT` increment. The scalar composition is `E(y_t)=τ+λ(μ_t+A^{-1}[e^{AΔt}−I]Bz)`. Form the evolved-plus-increment latent mean first, then `τ+λ` of that mean. A zero loading is exactly `τ`. A zero evolved-plus-increment latent mean is exactly `τ`. A zero intercept is exactly `λ(μ_t+increment)`. The evolved observed mean `τ+λμ_t` is not this composition. The contemporaneous map `τ+λ(μ_t+mx)` is not this composition. The carry map `τ+λ(μ_t+e^{a(t−u)}mx)` is not this composition when `u≠t`. `MANIFESTMEANS` is not `E(y_t)`. The evolved-plus-increment latent mean is not `E(y_t)`. `TIPREDEFFECT` is `B`, not that observed mean. An overflowing product or sum fails closed. This is not a Kalman filter and not ctsem estimation. +- **Time-dependent predictor impulse carry.** Driver et al. (2017, Eq. 1–2, pp. 4–5; Eq. 3 exponential map; Table 2, p. 12; §7.2, pp. 20–21; JSS PDF re-opened 2026-08-20T10:33Z): Equation 1 writes `dη=(Aη+ξ+Bz+Mχ(t))dt+GdW`. Equation 2 writes `χ_i(t)=Σ x_{i,u} δ(t−u)`. The Green-function integral of that Dirac on `(t0,t)` is `e^{A(t−u)}Mx`. The printed Eq. 3 fourth summand is the contemporaneous jump `Mx` at `u=t`. This map is the strictly within-interval case `t00\). + +## Verification + +- the combined mean recovers a known loading with machine-scale computed RMSE and matches the point-estimate mean; +- reported \(T\) equals \(\bar U+(1+1/m)B\) on the same draws; +- raw-proportion, singleton-draw, and singular designs fail closed. diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index 70ce245f..50a6d9db 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -16,12 +16,41 @@ Fox, J.-P., & Glas, C. A. W. (2001). Bayesian estimation of a multilevel IRT mod Marsh, H. W., Morin, A. J. S., Parker, P. D., & Kaur, G. (2014). Exploratory structural equation modeling: An integration of the best features of exploratory and confirmatory factor analysis. *Annual Review of Clinical Psychology, 10*, 85–110. https://doi.org/10.1146/annurev-clinpsy-032813-153700 +Bollen, K., & Lennox, R. (1991). Conventional wisdom on measurement: A structural equation perspective. *Psychological Bulletin, 110*(2), 305–314. https://doi.org/10.1037/0033-2909.110.2.305 + +Driver, C. C., Oud, J. H. L., & Voelkle, M. C. (2017). Continuous time structural equation modeling with R package ctsem. *Journal of Statistical Software, 77*(5), 1–35. https://doi.org/10.18637/jss.v077.i05 + +Enders, C. K., & Tofighi, D. (2007). Centering predictor variables in cross-sectional multilevel models: A new look at an old issue. *Psychological Methods, 12*(2), 121–138. https://doi.org/10.1037/1082-989X.12.2.121 + +Curran, P. J., & Bauer, D. J. (2011). The disaggregation of within-person and between-person effects in longitudinal models of change. *Annual Review of Psychology, 62*, 583–619. https://doi.org/10.1146/annurev.psych.093008.100356 + +Hamaker, E. L., Kuiper, R. M., & Grasman, R. P. P. P. (2015). A critique of the cross-lagged panel model. *Psychological Methods, 20*(1), 102–116. https://doi.org/10.1037/a0038889 + +Voelkle, M. C., Oud, J. H. L., Davidov, E., & Schmidt, P. (2012). An SEM approach to continuous time modeling of panel data: Relating authoritarianism and anomia. *Psychological Methods, 17*(2), 176–192. https://doi.org/10.1037/a0027543 + +Rubin, D. B. (1996). Multiple imputation after 18+ years. *Journal of the American Statistical Association, 91*(434), 473–489. https://doi.org/10.1080/01621459.1996.10476908 + +Mislevy, R. J. (1988). *Randomization-based inferences about latent variables from complex samples* (ETS Research Report No. RR-88-45; DTIC ADA200179). Educational Testing Service. https://doi.org/10.1002/j.2330-8516.1988.tb00310.x + +Mislevy, R. J. (1991). Randomization-based inference about latent variables from complex samples. *Psychometrika, 56*(2), 177–196. https://doi.org/10.1007/BF02294457 + +Meredith, W. (1993). Measurement invariance, factor analysis and factorial invariance. *Psychometrika, 58*(4), 525–543. https://doi.org/10.1007/BF02294825 + +Sörbom, D. (1974). A general method for studying differences in factor means and factor structure between groups. *British Journal of Mathematical and Statistical Psychology, 27*(2), 229–239. https://doi.org/10.1111/j.2044-8317.1974.tb00543.x + +Putnick, D. L., & Bornstein, M. H. (2016). Measurement invariance conventions and reporting: The state of the art and future directions for psychological research. *Developmental Review, 41*, 71–90. https://doi.org/10.1016/j.dr.2016.06.004 + +Holland, P. W. (1986). Statistics and causal inference. *Journal of the American Statistical Association, 81*(396), 945–960. https://doi.org/10.1080/01621459.1986.10478354 + TEPP applies these sources to construct definition, score interpretation, reliability, validity evidence, uncertainty, consequences, longitudinal invariance, ESEM cross-loadings, and DSEM. Topic outputs are treated as fallible indicators or components only after their construct role is evaluated. `psychometric_fit` recovers those cross-loadings and event-time lagged paths on a CPU `f64` OLS path; see `docs/research/esem-dsem-fit.md`. Browne, W. J., Goldstein, H., & Rasbash, J. (2001). Multiple membership multiple classification (MMMC) models. *Statistical Modelling, 1*(2), 103–124. https://doi.org/10.1177/1471082X0100100202 Jones, K. (1991). Specifying and estimating multi-level models for geographical research. *Transactions of the Institute of British Geographers, 16*(2), 148–160. https://doi.org/10.2307/622612 -TEPP applies these sources to construct definition, score interpretation, reliability, validity evidence, uncertainty, consequences, longitudinal invariance, ESEM cross-loadings, and DSEM. Topic outputs are treated as fallible indicators or components only after their construct role is evaluated. Location and market assignments remain multiple-membership classifications; they are not permanent entity identity and not language channels (Browne et al., 2001; Jones, 1991). +TEPP applies these sources to construct definition, score interpretation, reliability, validity evidence, uncertainty, consequences, longitudinal invariance, ESEM cross-loadings, and DSEM. Topic outputs are treated as fallible indicators or components only after their construct role is evaluated. Reflective, formative, and network classes remain distinct (Bollen & Lennox, 1991). Complete-data OLS loadings across posterior indicator draws are combined with Rubin (1996) \(T_m\); the arithmetic-mean helper remains a point estimate. Mislevy (1991, *Psychometrika, 56*, 177–196, DOI 10.1007/bf02294457) remains unread (Unpaywall/OpenAlex/Semantic Scholar 2026-08-18T03:07Z: closed). The 1988 ETS RR-88-45 / DTIC ADA200179 technical report of the same title was opened 2026-08-17T12:04Z from archive.org; it is not the 1991 journal article and is not used as Mislevy plausible-value authority. Temporal precedence is not causal identification (Holland, 1986). Within/between OLS follows Enders and Tofighi (2007), Curran and Bauer (2011), and Hamaker et al. (2015). Enders and Tofighi (2007, Table 2, pp. 124–127; PDF opened 2026-08-17) show that the CWC cluster-mean coefficient is the contextual effect (`between − within`), not the between-cluster effect. Curran and Bauer (2011, pp. 607–608) reject person-mean subtraction on a raw autoregressive series as the lagged within-person residual; already-centered irregular residuals use the Voelkle et al. (2012, Eq. 7) / Driver et al. (2017, Eq. 3) scalar map. Discrete lags from unequal event intervals are remapped through that log-rate (Voelkle et al., 2012, ZORA accepted manuscript re-opened 2026-08-17T13:13Z) and are not pooled. Driver, Oud, and Voelkle (2017, Eq. 3 and p. 4) write \(A_{\Delta t}=\operatorname{expm}(A\Delta t)\) and restate the discrete intercept as a function of \(A\) and \(\Delta t\). A binary64 underflow of \(\exp(a\Delta t)\) to `+0` is refused because discrete auto-effects are strictly positive. The discrete effect of a constant predictor is Voelkle et al. (2012, Eq. 12; ZORA accepted manuscript re-opened 2026-08-17T14:20Z, Introducing Intercepts, manuscript p. 20), evaluated as \(a_{yx}(\operatorname{expm1}(z)/a_{xx})\) with \(z=a_{xx}\Delta t\) so a finite result is not lost when \(z\) overflows to \(-\infty\) or when \(a_{yx}\Delta t\) overflows, and in log space when `expm1(z)` overflows at a finite \(z\); a zero continuous effect is exactly zero; an overflowing \(a_{yx}/a_{xx}\) rewrite term fails closed; the first-order product is the underflow limit of that equation, not the general constant-predictor discrete effect. The discrete effect of a time-varying predictor with matched sampling and constancy intervals is Voelkle et al. (2012, Eq. 14; manuscript p. 21): \(b^{*}_{y.x}(\Delta t)=a_{yx}\Delta t\). That product is not Eq. 12. Unmatched intervals fail closed (Oud & Jansen, 2000, unread). The exact scalar discrete process noise is Driver, Oud, and Voelkle (2017, Eq. 3; JSS PDF re-opened 2026-08-18T14:04Z, p. 4): \(Q_{\Delta t}=0.5 q(\operatorname{expm1}(z)/a)\) with \(z=2(a\Delta t)\) for \(a\neq 0\) and \(q=GG^{\top}\ge 0\); do not form \(2a\) first; \(a=0\) recovers \(q\Delta t\); an overflowing rewrite scale \(0.5 q/a\) fails closed. This is not a Kalman filter. Driver, Oud, and Voelkle (2017, Eq. 3; JSS PDF re-opened 2026-08-18T14:04Z) write the same discrete intercept as \(A^{-1}[e^{A\Delta t}-I]\xi\). The lagged covariance is \(\mathrm{e}^{a\Delta t}p\) and the unconditional variance is \(\mathrm{e}^{2a\Delta t}p+Q_{\Delta t}\) (Driver et al., 2017, Eq. 3–4, pp. 4–5); a zero diffusion whose \(2(a\Delta t)\) overflows to \(+\infty\) fails closed. The stationary within-subject variance is the \(\Delta t\to\infty\) limit of Eq. 4: \(-q/(2a)\) for stable \(a<0\) (JSS p. 16 `asymDIFFUSION`; §4.3; PDF re-opened 2026-08-18T18:03Z). Finite-interval \(Q_{\Delta t}\) is not that limit. Trait-plus-state variance is \(\mathrm{trait}+\mathrm{state}\) and lagged covariance is \(\mathrm{trait}+\mathrm{e}^{a\Delta t}p\) (Driver et al., 2017, §4.3, p. 9; JSS PDF re-opened 2026-08-18T21:07Z). Trait variance is not process noise and not `asymDIFFUSION`. The first-occasion map `τ + λ μ_0` is not `E(y_t)`. The contemporaneous `TDPREDEFFECT` impulse is `m x` (Driver et al., 2017, Eq. 3 fourth summand; Table 2; §7.2; JSS PDF re-opened 2026-08-20T07:10Z). `TDPREDEFFECT` is not `CINT`. `M x` is not `A^{-1}[e^{A Δt} − I] B z` and is not Voelkle et al. (2012, Eq. 14). Metric/weak invariance does not license latent-mean comparison. Putnick and Bornstein (2016, PMC author manuscript PMC5145197 opened 2026-08-19T22:15Z) require scalar invariance before latent-mean comparison and state that residual invariance is not a prerequisite. Two-observation OLS residual variance is identically `0` and is not strict. Meredith (1993) remains unread (Unpaywall/OpenAlex 2026-08-22T12:15Z: `is_oa: false`; Springer `content/pdf` is HTML 200; Cambridge Core DOI 10.1007/BF02294825 redirects to a closed product page). Vandenberg and Lance (2000) remains unread. Mislevy (1991, *Psychometrika, 56*, 177–196, DOI 10.1007/bf02294457) remains unread (Unpaywall/OpenAlex 2026-08-22T12:15Z: `is_oa: false`; Springer `content/pdf` is HTML 200; NCES 404; ETS RR-91-18 404). ERIC ED334221 is Singer and Willett (1991), not the 1991 journal article. ERIC ED333032 is Mislevy, Sheehan, and Wingersky (1990), ETS RR-90-17-ONR, not the 1991 journal article. Oud and Jansen (2000) remains unread (Unpaywall/OpenAlex 2026-08-18T21:07Z: closed). + +For Meredith (1993), the Cambridge Core original-paper page and abstract were opened on 2026-08-21; Unpaywall, Springer `content/pdf`, and Cambridge Core PDF lookup were re-tried 2026-08-22T12:15Z and remain closed. The earlier `remains unread` note means that the full text was not available, not that the authoritative record was unverified. + ## Numerical precision, memory-aware computation, and causal identification diff --git a/docs/research/strong-invariance-latent-means.md b/docs/research/strong-invariance-latent-means.md new file mode 100644 index 00000000..0d0b5a48 --- /dev/null +++ b/docs/research/strong-invariance-latent-means.md @@ -0,0 +1,68 @@ +# Strong-invariance gate for two-group OLS latent means + +## Scope + +Adds a two-group OLS classification of configural / metric / strong / strict status and recovers \((\bar y_c-\bar y_r)/\lambda\) only when strong or strict holds. The boolean `compare_latent_means` helper is unchanged. + +This slice does **not** import the unpublished `measurement_invariance` crate on `#84`. Claim-boundary tests use that crate's wire names (`configural`, `metric`, `scalar`) as documented strings only. Local `strict` is Meredith-style residual invariance; `#84` has no such wire name, so `as_measurement_invariance_wire_name` returns `None` for `Strict` and `as_str` keeps `"strict"`. + +## Claim boundary + +- `#84` `metric` licenses shared **metric** meaning. It does **not** license latent means. +- `#84` `scalar` is the strong/scalar status (equal loading and intercept). That status licenses latent means. +- Strict (also equal residual variance) also licenses latent means. Residual invariance is **not** required for those means. +- Two-observation series have no residual degrees of freedom. OLS residual variance is then identically `0` and is not an estimated residual. Those series cap at strong/scalar and still license means. +- This is two-group OLS, not MGCFA, not partial invariance, and not alignment optimization. +- The weak/strong/strict labels remain conventional labels here. Meredith (1993) is the primary source for the hierarchy; its Cambridge Core original-paper page and abstract were opened, but its full-text PDF was not. Putnick and Bornstein (2016) cite Meredith for residual invariance as part of *full factorial invariance*; that specific claim is not a reading of Meredith's full text. + +## Authoritative sources used for the mean gate + +Meredith, W. (1993). Measurement invariance, factor analysis and factorial invariance. *Psychometrika, 58*(4), 525–543. https://doi.org/10.1007/BF02294825 + +The original-paper record and abstract were opened on the Cambridge Core page on 2026-08-21. Meredith defines weak measurement invariance, strong factorial invariance, and strict factorial invariance and relates factorial invariance to group differences. This is the primary source for the hierarchy used by this gate; the implementation deliberately reports the narrower local `strong`/`strict` labels rather than claiming a full multiple-group CFA. + +Sörbom, D. (1974). A general method for studying differences in factor means and factor structure between groups. *British Journal of Mathematical and Statistical Psychology, 27*(2), 229–239. https://doi.org/10.1111/j.2044-8317.1974.tb00543.x + +The original article record and abstract were opened on the Wiley Online Library page on 2026-08-21. Sörbom's primary model estimates factor means, loadings, and unique variances jointly from group observed means, variances, and covariances while allowing factorial-invariance constraints. It is the direct source for the factor-mean comparison target. The formula implemented here is the scalar two-group OLS reduction obtained by subtracting the observed-mean equation under equal loading and intercept; the source is not being presented as stating this crate-specific OLS formula. + +Putnick, D. L., & Bornstein, M. H. (2016). Measurement invariance conventions and reporting: The state of the art and future directions for psychological research. *Developmental Review, 41*, 71–90. https://doi.org/10.1016/j.dr.2016.06.004 + +PMC author manuscript (PMC5145197) opened 2026-08-19T22:15Z from https://pmc.ncbi.nlm.nih.gov/articles/PMC5145197/. The NIHMS PDF endpoints returned HTML/500 on this cycle; the PMC HTML full text is the opened copy. + +Putnick and Bornstein write that measurement invariance is a prerequisite to comparing group means. Metric invariance is equivalence of item loadings: each item contributes to the latent construct to a similar degree across groups. Scalar invariance is equivalence of item intercepts after metric: “mean differences in the latent construct capture all mean differences in the shared variance of the items.” After those steps, “the researcher is free to compare group means on the latent factors.” Residual invariance “is not a prerequisite for testing mean differences because the residuals are not part of the latent factor” (they cite Vandenberg & Lance, 2000, unread). Configural, metric, and scalar “are required prior to group mean comparisons.” This crate’s `#84` `metric` / `scalar` split follows that terminology. The executable map remains two-group OLS, not their multiple-group CFA. + +Steenkamp, J.-B. E. M., & Baumgartner, H. (1998). Assessing measurement invariance in cross-national consumer research. *Journal of Consumer Research, 25*(1), 78–90. https://doi.org/10.1086/209528 + +The Oxford Academic article page and abstract were opened 2026-08-20. Steenkamp and Baumgartner connect sequential measurement-invariance requirements to when comparisons of construct means are meaningful and illustrate the procedure with multisample factor models. This is the primary source for the gate's comparison-purpose boundary; the implementation remains a narrower two-group OLS contract. + +Baumgartner, H., & Steenkamp, J.-B. E. M. (1998). Multi-group latent variable models for varying numbers of items and factors with cross-national and longitudinal applications. *Marketing Letters, 9*, 21–35. https://doi.org/10.1023/A:1007911903032 + +The Springer Nature article page and abstract were opened 2026-08-20. Its simulation and empirical study concerns estimates of differences between latent means. In this repository, subtracting the two-group model \(y=ν+λ f+e\) under equal loading and intercept gives \((\bar y_c-\bar y_r)/\lambda\); that algebra is an explicit derivation of this OLS slice, not a claim that the source states the same implementation formula. + +Opened sources that constrain the surrounding longitudinal/invariance stance: + +Asparouhov, T., & Muthén, B. (2009). Exploratory structural equation modeling. *Structural Equation Modeling: A Multidisciplinary Journal, 16*(3), 397–438. https://doi.org/10.1080/10705510903008204 + +Hamaker, E. L., Kuiper, R. M., & Grasman, R. P. P. P. (2015). A critique of the cross-lagged panel model. *Psychological Methods, 20*(1), 102–116. https://doi.org/10.1037/a0038889 + +Vandenberg, R. J., & Lance, C. E. (2000). A review and synthesis of the measurement invariance literature: Suggestions, practices, and recommendations for organizational research. *Organizational Research Methods, 3*(1), 4–70. https://doi.org/10.1177/109442810031002 (cited by Putnick & Bornstein, 2016, for residual invariance not being required for latent means; PDF not opened). + +## Formula notes + +Per group, \(y=\nu+\lambda f+e\) is fit by OLS. Status is: + +- configural when \(|\lambda_r-\lambda_c|\) exceeds tolerance; +- metric when loadings match and intercepts differ; +- strong when loadings and intercepts match and residual variances differ, or when residual degrees of freedom are absent; +- strict when both groups have residual degrees of freedom and loadings, intercepts, and residual variances match. + +The latent-mean difference is \((\bar y_c-\bar y_r)/\lambda\) with \(\lambda\) the midpoint of the two loadings, and only after strong or strict. Meredith (1993) supplies the invariance hierarchy and Sörbom (1974) supplies the factor-mean comparison model; the displayed expression is the explicitly stated scalar OLS derivation for this implementation. + +The formula follows by subtracting the group means of \(y=\nu+\lambda f+e\) after the equal-loading/equal-intercept restrictions have been accepted; the cited multi-group latent-mean study supplies the comparison target, while this document records the narrower OLS derivation. + +## Verification + +- strong/strict series recover a known mean difference with computed RMSE; +- metric-only (equal loading, shifted intercept) and configural series return `StrongInvarianceRequired`; +- two-observation series with matching loading and intercept classify as strong, not strict, and still recover the known mean difference; +- `#84` wire-name tests: `metric` licenses shared metric meaning and refuses means; `scalar` is strong and licenses means; local `strict` is not a `#84` wire name. diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index f3432e39..36cc5f22 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -1,7 +1,7 @@ # Temporal Event Foundation — validation and release-readiness report -**Status:** Living validation ledger for the Temporal/Event foundation program -**Last reviewed:** 2026-08-20 +**Status:** Living validation ledger for the Temporal/Event foundation program +**Last reviewed:** 2026-08-21 **Authority:** ADR 0014 (claim promotion), ADR 0007 (quality gates), AGENTS.md scientific acceptance ## Scope @@ -57,6 +57,7 @@ This report tracks exact-head scientific and engineering evidence required befor | Scientific claim promotion gates | `validation_core` | active-PR | this PR | exact-head SHA + computed RMSE SE gate | ADR 0014; full release bundle remaining | | Causal-identification gate | `relation_graph` | active-PR | association ≠ cause | LeadsTo/References denied | ADR 0003; `docs/research/causal-identification-gate.md` | | Versioned API/export contracts | `tepp_api` | implemented-main | naruon HTTP interchange | unknown-field/version/limit + naruon HTTPS interchange tests | Task 12 / PR #21; live HTTP service remaining | +| Psychometric structural input gates | `psychometric_core` | partial | stacked psychometric PR | construct-class refusal + ALR/ILR boundary + true-loading RMSE + posterior-draw point-estimate mean + Rubin `T` + CWC within/between + CWC contextual effect + event-time log-rate + constant- and time-varying-predictor discrete effects + exact scalar discrete process noise + lagged latent covariance and unconditional latent variance + stationary within-subject variance + trait-plus-state variance + observed-indicator variance + discrete latent mean (`T0MEANS`/`CINT`) + evolved observed mean (`τ + λ μ_t`; `τ + λ μ_0` is not `E(y_t)`) + contemporaneous `TDPREDEFFECT` impulse (`m x`; not `CINT`, not `TIPREDEFFECT`, not Voelkle Eq. 14) + Eq. 5 of that contemporaneous impulse (`τ + λ(μ_t + m x)`; `τ + λ μ_t` is not that observed mean) + time-independent `TIPREDEFFECT` increment (`A^{-1}[e^{A Δt} − I] B z`; not `CINT`, not `M x`, not Voelkle Eq. 14, not the coefficient `B`) + Eq. 5 of that increment (`τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`; `τ + λ μ_t` is not that observed mean) + within-interval `TDPREDEFFECT` carry (`e^{A(t−u)} M x` for `t0 < u < t`; not the contemporaneous Dirac, not `CINT`, not `TIPREDEFFECT`, not Voelkle Eq. 14) + Eq. 5 of that carry (`τ + λ(μ_t + e^{a(t−u)} m x)`; `τ + λ μ_t` is not that observed mean) + §7.2 level-change `CINT` (`κ = −a m x`; Eq. 3 increment `(1 − e^{a Δt}) m x`) + §7.2 extra-process contribution (`a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)`; not `κ`, not the increment, not the Dirac; `ε ≥ 0` fails closed) + Eq. 5 of that extra-process contribution (`τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)`; extra `LAMBDA` is 0; `τ + λ μ_t` is not that observed mean) + after-t0 extra-process `TDPREDEFFECT` (`a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a)` for `t0 < u < t`; Eq. 5 `τ + λ(μ_t + contribution(t−u))`; not the first-occasion extra-process observed mean; not the impulse-carry Dirac) + §7.2 `asymTIPREDEFFECT` (`-B z / a` for `a < 0`; not `B`, not the finite-interval increment, not `CINT`, not `M x`) + §7.2 `addedTIPREDVAR` (`(B / a)² v`; not `TRAITVAR`, not `asymDIFFUSION`, not `-B z / a`) + Table 2 `asymCINT` (`-κ / a` for `a < 0`; not `κ`, not the finite-interval increment, not `T0MEANS`, not `-B z / a`) + p. 16 stationary `T0MEANS` (`-κ / a + −B z / a`; not free `T0MEANS`, not `asymCINT` alone, not `asymTIPREDEFFECT` alone, not the finite-interval discrete mean) + Eq. 5 of that constrained mean (`τ + λ(−κ / a + −B z / a)`; `τ + λ μ_0` is not that observed mean; `τ + λ(−κ / a)` is not that observed mean when `B z ≠ 0`; `τ + λ μ_t` is not that observed mean; `MANIFESTMEANS` is not `E(y_0)`; the constrained latent mean is not `E(y_0)`) + stationary `T0VAR` (`trait + −q / (2 a) + (B / a)² v`; not free `T0VAR`, not `asymDIFFUSION` alone, not `TRAITVAR` alone, not `addedTIPREDVAR` alone; Eq. 5 is `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` (`λ² p_0` is not `Var(y_0)`; `λ²(−q / (2 a)) + θ` is not `Var(y_0)` when trait or TI is nonzero; `MANIFESTVAR` is not `Var(y_0)`; the constrained latent variance is not `Var(y_0)`)) + Eq. 5 of that constrained variance (`λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ`; `MANIFESTVAR` is not `Var(y_0)`) + irregular already-centered residual lag + strong-gated latent means (n=2 residual variance is identically `0` and caps at strong/scalar; Putnick & Bornstein, 2016); full ESEM/DSEM remaining | ADR 0005; `docs/research/posterior-esem-input-gates.md`; `docs/research/multilevel-event-time-recovery.md`; `docs/research/rubin-total-variance.md`; `docs/research/strong-invariance-latent-means.md` | | Prompt-versus-unique-content identity | `prompt_source` | accepted-target | active PR | refuse prompt-as-unique/stopword + recovery vs unique-content collapse | ADR 0004/0012 | | Corpus-background-versus-unique-content identity | `corpus_background` | accepted-target | active PR | refuse background-as-unique/stopword + recovery vs unique-content collapse | ADR 0004/0012 | | Modality-versus-unique-content identity | `modality_source` | accepted-target | active PR | refuse modality-as-unique/stopword + recovery vs unique-content collapse | ADR 0004/0012 | @@ -74,9 +75,7 @@ This report tracks exact-head scientific and engineering evidence required befor | Subevent parent containment | `subevent_containment` | active-PR | this PR | containment-flag recovery vs accept-all | ADR 0003 | | Predicted-vs-observed contradiction | `prediction_contradiction` | active-PR | this PR | `refuse_promotion` requires observed coverage; `refuse_contradiction_or_adjacency` is not promotion authority; cutoff eligibility; label agreement is not RMSE recovery | ADR 0016 | | Provider-disclosure receipts | `provider_receipt` | active-PR | this PR | recovered field-code rate vs collapsed set | ADR 0009 | - | Purpose-bound provider payloads | `tepp_api` | implemented-main | provider-payload minimization | expired/not-yet-valid/inverted/cross-tenant/impossible-calendar grant, mapping refusal, audited elevated re-id replay | ADR 0009; `docs/research/provider-payload-minimization.md` | - | Adaptive orchestration router | `tepp_api` | accepted-target | active PR | mode selection, document-control denial, ablation, credential-free bind | ADR 0010; `docs/research/adaptive-orchestration-router.md` | - | Production TLS bind gates | `service_tls` | accepted-target | active PR | plaintext production, table-access host, mismatched PEM, and orchestrator loopback refusal plus recovery computed from `authorize_production_tls` / `authorize_orchestrator_live_port` | ADR 0011; rustls config is not a deployed listener | +| Production TLS bind gates | `service_tls` | accepted-target | active PR | plaintext production, table-access host, mismatched PEM, and orchestrator loopback refusal plus recovery computed from `authorize_production_tls` / `authorize_orchestrator_live_port` | ADR 0011; rustls config is not a deployed listener | | Longitudinal within/between | `longitudinal_core` | active-PR | this PR | known-truth component recovery, computed component RMSE, grand-mean pooling baseline comparison, and between-as-within refusal | ADR 0005 | | Global topic activity identity | `topic_lineage` | active-PR | this PR | dormancy/reactivation identity recovery | ADR 0012; birth/split/merge remaining | | Compositional cluster-pair gates | `network_analysis` | active-PR | this PR | raw-simplex refusal + known-truth pair precision/recall + cluster-label permutation invariance | ADR 0005/0012; `crates/network_analysis/tests/compositional_cluster_contract.rs`; graphical model remaining | diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py index 8a77a3c9..bf2ff56c 100644 --- a/scripts/check_coverage.py +++ b/scripts/check_coverage.py @@ -10,13 +10,15 @@ def load_totals(path: Path) -> Mapping[str, Any]: - """Load exact totals from LLVM coverage JSON. + """Load exact totals from LLVM coverage JSON with unique branch arms. Full LLVM branch exports can contain several instrumented copies of the - same source file when unit and integration test binaries are merged. The - source-level contract is the union of each branch coordinate's true and - false outcomes, so those copies are merged before the branch gate runs. - Summary-only reports retain the original LLVM totals fallback. + same source file when unit and integration test binaries are merged and + when generic instantiations are max-folded. The source-level contract is + the unique union of each ``(filename, coordinate)`` site's true and false + outcomes, so those copies are merged before the branch gate runs. + Summary-only reports without branch arrays keep the totals mapping and + fail closed on that summary. """ payload = json.loads(path.read_text(encoding="utf-8")) @@ -27,12 +29,124 @@ def load_totals(path: Path) -> Mapping[str, Any]: totals = report.get("totals") if not isinstance(totals, Mapping): raise ValueError("coverage JSON data entry must contain totals") - files = report.get("files") - if not isinstance(files, list) or not any( - isinstance(record, Mapping) and "branches" in record for record in files - ): + folded = fold_unique_branch_totals(report.get("files")) + if folded is None: return totals - return {**totals, "branches": load_union_branch_totals(files)} + merged = dict(totals) + merged["branches"] = folded + return merged + + +def load_union_branch_totals(files: Sequence[object]) -> Mapping[str, int | float]: + """Merge LLVM branch outcomes by source coordinate across test binaries.""" + + outcomes: dict[tuple[str, int, int, int, int], list[int]] = {} + for file_record in files: + if not isinstance(file_record, Mapping): + raise ValueError("coverage file record must be an object") + filename = file_record.get("filename") + if "branches" not in file_record: + raise ValueError("coverage file record must contain branches") + branches = file_record["branches"] + if not isinstance(filename, str) or not filename: + raise ValueError("coverage file record must contain a filename") + if not isinstance(branches, list): + raise ValueError("coverage branches must be a list") + for branch in branches: + if not isinstance(branch, list) or len(branch) < 6: + raise ValueError("coverage branch record is malformed") + coordinates = branch[:4] + counts = branch[4:6] + if not all( + isinstance(value, int) and not isinstance(value, bool) and value >= 0 + for value in coordinates + ): + raise ValueError("coverage branch coordinates are invalid") + if not all( + isinstance(value, int) and not isinstance(value, bool) and value >= 0 + for value in counts + ): + raise ValueError("coverage branch counts are invalid") + key = (filename, *coordinates) + outcome = outcomes.setdefault(key, [0, 0]) + outcome[0] += counts[0] + outcome[1] += counts[1] + count = len(outcomes) * 2 + covered = sum(outcome > 0 for counts in outcomes.values() for outcome in counts) + return {"count": count, "covered": covered} + + +def _parse_branch_record(record: object) -> tuple[tuple[int, int, int, int], int, int]: + """Return ``(site, true_count, false_count)`` from one LLVM branch tuple. + + LLVM export writes + ``[lineStart, colStart, lineEnd, colEnd, trueCount, falseCount, fileId, + expandedFileId, kind]``. + """ + + if not isinstance(record, list) or len(record) != 9: + raise ValueError("coverage JSON branch record must contain nine values") + line_start, column_start, line_end, column_end, true_count, false_count = record[:6] + coordinates = (line_start, column_start, line_end, column_end) + if any(not isinstance(value, int) or isinstance(value, bool) for value in coordinates): + raise ValueError("coverage JSON branch coordinates must be integers") + if ( + not isinstance(true_count, int) + or isinstance(true_count, bool) + or not isinstance(false_count, int) + or isinstance(false_count, bool) + or true_count < 0 + or false_count < 0 + ): + raise ValueError("coverage JSON branch counts must be non-negative integers") + return coordinates, true_count, false_count + + +def fold_unique_branch_totals(files: object) -> dict[str, int] | None: + """Return unique-site True/False arm totals, or None when arrays are absent. + + Instantiations of the same ``(filename, start, end)`` site max-fold. One + LLVM JSON total that is not in that unique set is not an uncovered + production arm. Empty ``branches`` lists are instrumentation-absent and + leave the caller on summary totals. + """ + + if not isinstance(files, list): + return None + sites: dict[tuple[str, int, int, int, int], tuple[int, int]] = {} + saw_records = False + for file_entry in files: + if not isinstance(file_entry, Mapping): + raise ValueError("coverage JSON file entry must be an object") + records = file_entry.get("branches") + if records is None: + continue + if not isinstance(records, list): + raise ValueError("coverage JSON branches must be a list") + if not records: + continue + filename = file_entry.get("filename") + if not isinstance(filename, str) or not filename: + raise ValueError("coverage JSON file entry must contain a filename") + for record in records: + site, true_count, false_count = _parse_branch_record(record) + saw_records = True + key = (filename, *site) + previous = sites.get(key, (0, 0)) + sites[key] = ( + max(previous[0], true_count), + max(previous[1], false_count), + ) + if not saw_records: + return None + count = len(sites) * 2 + covered = 0 + for true_count, false_count in sites.values(): + if true_count > 0: + covered += 1 + if false_count > 0: + covered += 1 + return {"count": count, "covered": covered} def load_union_branch_totals(files: Sequence[object]) -> Mapping[str, int | float]: diff --git a/scripts/check_workspace_contract.py b/scripts/check_workspace_contract.py index 5a3e3fef..c3fcfdb4 100644 --- a/scripts/check_workspace_contract.py +++ b/scripts/check_workspace_contract.py @@ -24,6 +24,7 @@ "tepp_simulation", "validation_core", "tepp_api", + "location_membership", "prompt_source", "corpus_background", @@ -64,7 +65,7 @@ "compute_backend", "episode_membership", "membership_target", - + "psychometric_core", ) REQUIRED_CI_SNIPPETS: tuple[str, ...] = ( diff --git a/tests/quality/test_check_coverage.py b/tests/quality/test_check_coverage.py index caabc001..e9eb46d8 100644 --- a/tests/quality/test_check_coverage.py +++ b/tests/quality/test_check_coverage.py @@ -116,6 +116,168 @@ def test_report_shape_validation(self) -> None: with self.assertRaisesRegex(ValueError, "contain totals"): coverage_contract.load_totals(path) + def test_unique_branch_fold_overrides_phantom_json_totals(self) -> None: + """Unique True/False arms, not LLVM totals, are the 100% branch contract. + + Nightly ``files[].summary.branches`` on #49 head ``1e3e2eb`` reported + ``event_time.rs`` 505/506 while every unique ``files[].branches`` site + had both arms taken after max-folding the two instantiations. Summary-only + reports without branch arrays still fail closed on totals. + """ + + with tempfile.TemporaryDirectory() as temporary: + summary_only = self.write_report( + temporary, + { + "data": [ + { + "totals": { + "lines": {"count": 1, "covered": 1}, + "branches": {"count": 4, "covered": 3}, + } + } + ] + }, + ) + with self.assertRaisesRegex(ValueError, "incomplete: 3/4"): + coverage_contract.validate_report(summary_only, ["branches"]) + + phantom_totals = self.write_report( + temporary, + { + "data": [ + { + "totals": { + "lines": {"count": 1, "covered": 1}, + "branches": {"count": 4, "covered": 3}, + }, + "files": [ + {"filename": "crates/psychometric_core/src/causality.rs"}, + { + "filename": "crates/psychometric_core/src/error.rs", + "branches": [], + }, + { + "filename": "crates/psychometric_core/src/event_time.rs", + "branches": [ + [292, 8, 292, 28, 1, 13, 0, 0, 4], + [292, 8, 292, 28, 0, 7, 0, 0, 4], + ], + } + ], + } + ] + }, + ) + self.assertEqual( + coverage_contract.validate_report(phantom_totals, ["branches"]), + ["branches coverage: PASS (2/2, 100%)"], + ) + + uncovered_true = self.write_report( + temporary, + { + "data": [ + { + "totals": { + "lines": {"count": 1, "covered": 1}, + "branches": {"count": 2, "covered": 2}, + }, + "files": [ + { + "filename": "src/lib.rs", + "branches": [ + [10, 1, 10, 8, 0, 4, 0, 0, 4], + [10, 1, 10, 8, 0, 2, 0, 0, 4], + ], + } + ], + } + ] + }, + ) + with self.assertRaisesRegex(ValueError, "incomplete: 1/2"): + coverage_contract.validate_report(uncovered_true, ["branches"]) + + uncovered_false = self.write_report( + temporary, + { + "data": [ + { + "totals": { + "lines": {"count": 1, "covered": 1}, + "branches": {"count": 2, "covered": 2}, + }, + "files": [ + { + "filename": "src/lib.rs", + "branches": [[11, 1, 11, 8, 3, 0, 0, 0, 4]], + } + ], + } + ] + }, + ) + with self.assertRaisesRegex(ValueError, "incomplete: 1/2"): + coverage_contract.validate_report(uncovered_false, ["branches"]) + + def test_malformed_unique_branch_records_fail_closed(self) -> None: + """Absent filenames, short tuples, and non-integer counts are rejected.""" + + totals = { + "lines": {"count": 1, "covered": 1}, + "branches": {"count": 2, "covered": 2}, + } + malformed = ( + ([{"branches": [[10, 1, 10, 8, 1, 1, 0, 0, 4]]}], "contain a filename"), + ( + [{"filename": "src/lib.rs", "branches": "wrong"}], + "branches must be a list", + ), + ( + [{"filename": "src/lib.rs", "branches": [[10, 1, 10, 8, 1]]}], + "branch record must contain nine values", + ), + ( + [{"filename": "src/lib.rs", "branches": [[10, 1, 10, 8, -1, 1, 0, 0, 4]]}], + "branch counts must be non-negative integers", + ), + ( + [{"filename": "src/lib.rs", "branches": [[True, 1, 10, 8, 1, 1, 0, 0, 4]]}], + "branch coordinates must be integers", + ), + ( + [{"filename": "src/lib.rs", "branches": [[10, 1, 10, 8, True, 1, 0, 0, 4]]}], + "branch counts must be non-negative integers", + ), + (["src/lib.rs"], "file entry must be an object"), + ) + with tempfile.TemporaryDirectory() as temporary: + for index, (files, message) in enumerate(malformed): + with self.subTest(message=message): + path = Path(temporary) / f"malformed-{index}.json" + path.write_text( + json.dumps({"data": [{"totals": totals, "files": files}]}), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, message): + coverage_contract.load_totals(path) + + empty_arrays = self.write_report( + temporary, + { + "data": [ + { + "totals": totals, + "files": [{"filename": "src/lib.rs", "branches": []}], + } + ] + }, + ) + self.assertEqual( + coverage_contract.load_totals(empty_arrays)["branches"], + totals["branches"], + ) def test_full_branch_reports_merge_duplicate_instrumented_copies(self) -> None: """A source branch passes when either test binary covers each outcome.""" diff --git a/tests/quality/test_ci_coverage_diagnostics.py b/tests/quality/test_ci_coverage_diagnostics.py index 3735fbfc..432a00ad 100644 --- a/tests/quality/test_ci_coverage_diagnostics.py +++ b/tests/quality/test_ci_coverage_diagnostics.py @@ -30,6 +30,14 @@ def test_line_and_branch_failures_print_exact_missing_locations(self) -> None: ) self.assertIn("steps.line-report.outcome == 'success'", workflow) self.assertIn("id: branch-report", workflow) + self.assertIn( + "cargo +nightly-2026-08-21 llvm-cov --branch --workspace --all-features --json --output-path coverage-branches.json", + workflow, + ) + self.assertNotIn( + "cargo +nightly-2026-08-21 llvm-cov --branch --workspace --all-features --json --summary-only --output-path coverage-branches.json", + workflow, + ) self.assertIn( "cargo +nightly-2026-08-21 llvm-cov report --branch --text --show-missing-lines", workflow, diff --git a/tests/quality/test_hourly_nim_product_development.py b/tests/quality/test_hourly_nim_product_development.py index aa6b41cb..96335487 100644 --- a/tests/quality/test_hourly_nim_product_development.py +++ b/tests/quality/test_hourly_nim_product_development.py @@ -216,6 +216,10 @@ def test_hourly_prompt_and_verifier_keep_commercial_quality_gates(self) -> None: 'python3 scripts/check_coverage.py "$branch_coverage" --kind branches', ): self.assertIn(command, verifier) + self.assertNotIn( + "--json --summary-only --output-path \"$branch_coverage\"", + verifier, + ) def test_parser_accepts_unicode_and_owner_only_outputs(self) -> None: """Parse realistic Korean metadata and protect trusted output files."""