diff --git a/CHANGELOG.md b/CHANGELOG.md index aad5f4ee..4726fe59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, p. 16 `asymDIFFUSIONstd`; footnote 4; Eq. 4, p. 5; Table 2, p. 12; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-26T17:20Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar standardised asymptotic within-subject variance on current main after `0ce16e8` dropped the pre-consolidation code while research notes already named the map (register items 89–90). Page 16 prints standardised matrices with the suffix `std` when appropriate, and names `asymDIFFUSION` the total within-subject variance as `Δt → ∞`. The printed example on p. 16 is `discreteDRIFTstd`, not `asymDIFFUSIONstd`. Footnote 4 standardises using only the relevant variance, not the total. The relevant variance for that named process-dynamics correlation is within-subject `asymDIFFUSION` `p = −q / (2 a)`, not free first-occasion `T0VAR`. The 2017-era source forms `asymDIFFUSIONstd` as `solve(sqrt(diag(asymDIFFUSION))) %&% asymDIFFUSION` when `verbose = TRUE`. OpenMx `%&%` is `t(A) %*% B %*% A`. The default `ridging = FALSE` adds 0, not `0.0001`; that ridge is a numerical hack and is not this exact map. The scalar correlation is `p / p = 1` after strictly positive `p`. Form strictly positive `p` first, then `1 / √p`, then `(1 / √p) p (1 / √p)`. Unstandardised `p` is defined for a zero process; standardised `asymDIFFUSION` is not. Zero `q` has no positive SD and fails closed. Lasting `p` requires stable `a < 0`. A non-event clock fails closed. Distinct positive `p` recover the same 1. `p_0 / p_0 = 1` is `T0VARstd` and recovers the same number and remains a distinct named quantity. `q / p = −2 a` is `DIFFUSIONstd` and is not this correlation. `v / v = 1` is `TIPREDVARstd` and recovers the same number and remains a distinct named quantity. Meredith (1993) remains unread (Unpaywall 2026-08-26T17:20Z: `is_oa: false`; OpenAlex closed; Springer `content/pdf` is an HTML stub). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread on the same terms (DOI `10.1007/bf02294457`; Unpaywall `is_oa: false`). 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, p. 16 `T0VARstd`; Table 2, p. 12; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-26T07:17Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar standardised initial latent variance on current main after `0ce16e8` dropped the pre-consolidation code while research notes already named the map (register items 79–80). Page 16 prints standardised matrices with the suffix `std` when appropriate. The printed example on p. 16 is `discreteDRIFTstd`, not `T0VARstd`. Footnote 4 standardises using only the relevant variance, not the total. Table 2 names `T0VAR` the latent process initial variance/covariance. The first-occasion relevant variance is free `T0VAR` `p_0`, not process-dynamics `asymDIFFUSION` `-q / (2 a)`. The 2017-era source forms `T0VARstd` as `solve(sqrt(diag(T0VAR))) %&% T0VAR` when `verbose = TRUE`. OpenMx `%&%` is `t(A) %*% B %*% A`. The default `ridging = FALSE` adds 0, not `0.0001`; that ridge is a numerical hack and is not this exact map. The scalar correlation is `p_0 / p_0 = 1` after strictly positive `p_0`. Form strictly positive `p_0` first, then `1 / √p_0`, then `(1 / √p_0) p_0 (1 / √p_0)`. A zero first-occasion variance has no positive SD and fails closed. `T0` is an event-time occasion, so a non-event clock fails closed. Free `T0VAR` does not require stable `a < 0`. Distinct positive `p_0` recover the same 1. `μ_0 / √p_0` is `T0MEANSstd` and recovers the same number when `μ_0 = √p_0` and remains a distinct named quantity. `p / p = 1` is `asymDIFFUSIONstd` and recovers the same number and remains a distinct named quantity. Meredith (1993) remains unread (Unpaywall 2026-08-26T07:17Z: `is_oa: false`; OpenAlex closed; Springer `content/pdf` is a 3038-byte HTML stub). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread on the same terms (DOI `10.1007/bf02294457`; Unpaywall `is_oa: false`). 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, p. 16 `asymCINTstd`; Eq. 3, p. 4; Table 2, p. 12; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-26T00:20Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar standardised asymptotic continuous intercept on current main after consolidation dropped the pre-consolidation `(-κ / a) / √p` slice. Page 16 prints standardised matrices with the suffix `std` when appropriate, and asymptotic values as `Δt → ∞`. Footnote 4 standardises using only the relevant variance, not the total. Table 2 names `κ` `CINT`. The relevant variance for that process intercept is within-subject `asymDIFFUSION` `p = −q / (2 a)`. The 2017-era source forms unstandardised `asymCINT` whenever `verbose = TRUE` as `-solve(DRIFT) %*% CINT` and does not form an `asymCINTstd` matrix. Form strictly positive `p` first, then the asymptotic intercept, then divide by `√p`. A zero intercept is exactly zero after that positive SD. Zero `q` has no positive process SD and fails closed. Lasting `p` requires stable `a < 0`. A non-event clock fails closed. `κ / √p` is `CINTstd` and is not this total-change map. `A^{-1}[e^{A Δt} − I] κ / √p` is `discreteCINTstd` and depends on the event interval. Meredith (1993) remains unread (Unpaywall 2026-08-25T18:22Z: `is_oa: false`; Springer `content/pdf` is a 3038-byte HTML stub). Mislevy (1991) remains unread on the same terms. 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, p. 16 `T0MEANSstd`; Table 2, p. 12; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-26T04:09Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar standardised initial latent mean on current main after `0ce16e8` dropped the pre-consolidation code while research notes already named the map. Page 16 prints standardised matrices with the suffix `std` when appropriate. Footnote 4 standardises using only the relevant variance, not the total. Table 2 names `T0MEANS` the `n.latent × 1` matrix of latent process means at the first time point `T0` and names `T0VAR` the latent process initial variance/covariance. The first-occasion relevant variance is free `T0VAR` `p_0`, not process-dynamics `asymDIFFUSION` `-q / (2 a)`. The 2017-era source forms unstandardised `T0MEANS` and does not form a `T0MEANSstd` matrix; the scalar map is `μ_0 / √p_0` after strictly positive `p_0`. A zero mean is exactly zero. Zero `p_0` has no positive SD and fails closed. `T0` is an event-time occasion, so a non-event clock fails closed. Free `T0MEANS` does not require stable `a < 0`. `p_0 / p_0 = 1` recovers the same number when `μ_0 = √p_0` and remains a distinct named quantity. `μ_0 / √asymDIFFUSION` uses process-dynamics variance and is not this first-occasion map. Meredith (1993) remains unread (Unpaywall 2026-08-26T00:22Z: `is_oa: false`; OpenAlex closed; Springer `content/pdf` is a 3038-byte HTML stub). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread on the same terms. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. diff --git a/crates/psychometric_core/src/error.rs b/crates/psychometric_core/src/error.rs index e83d1a43..142ab194 100644 --- a/crates/psychometric_core/src/error.rs +++ b/crates/psychometric_core/src/error.rs @@ -585,6 +585,33 @@ pub enum PsychometricError { /// `asymDIFFUSIONstd` is the correlation form of process- /// dynamics `asymDIFFUSION`. StandardisedAsymptoticDiffusionIsNotStandardisedInitialLatentVariance, + /// Driver p. 16 `asymDIFFUSIONstd` was requested without a + /// strictly positive `asymDIFFUSION`. Footnote 4 + /// standardisation of the 2017-era `asymDIFFUSION` matrix + /// requires strictly positive `−q / (2 a)`. + StandardisedAsymptoticDiffusionRequiresPositiveStationaryVariance, + /// Driver p. 16 unstandardised `asymDIFFUSION` `p` was treated + /// as `asymDIFFUSIONstd`. Unstandardised `p` is defined for a + /// zero process; standardised `asymDIFFUSION` is not. + UnstandardisedAsymptoticDiffusionIsNotStandardisedAsymptoticDiffusion, + /// Driver p. 16 `T0VARstd` was treated as p. 16 + /// `asymDIFFUSIONstd`. Equal numbers of 1 after a strictly + /// positive relevant variance are still distinct named + /// quantities. `asymDIFFUSIONstd` is the correlation form of + /// process-dynamics `asymDIFFUSION`; `T0VARstd` is the + /// correlation form of free `T0VAR`. + StandardisedInitialLatentVarianceIsNotStandardisedAsymptoticDiffusion, + /// Driver p. 16 `DIFFUSIONstd` `q / p = −2 a` was treated as + /// `asymDIFFUSIONstd`. The continuous-diffusion ratio is not + /// the correlation form of `asymDIFFUSION`. + StandardisedContinuousDiffusionIsNotStandardisedAsymptoticDiffusion, + /// Driver p. 16 `TIPREDVARstd` was treated as p. 16 + /// `asymDIFFUSIONstd`. Equal numbers of 1 after a strictly + /// positive relevant variance are still distinct named + /// quantities. `asymDIFFUSIONstd` is the correlation form of + /// process-dynamics `asymDIFFUSION`; `TIPREDVARstd` is the + /// correlation form of `TIPREDVAR`. + StandardisedTimeIndependentPredictorVarianceIsNotStandardisedAsymptoticDiffusion, /// Driver p. 16 `discreteCINTstd` was requested without a strictly /// positive `asymDIFFUSION`. Footnote 4 standardises using only the /// relevant variance; zero `q` has no positive process SD. @@ -1063,6 +1090,21 @@ impl fmt::Display for PsychometricError { Self::StandardisedAsymptoticDiffusionIsNotStandardisedInitialLatentVariance => { "standardised asymptotic diffusion is not standardised initial latent variance" } + Self::StandardisedAsymptoticDiffusionRequiresPositiveStationaryVariance => { + "standardised asymptotic diffusion requires strictly positive stationary within-subject variance" + } + Self::UnstandardisedAsymptoticDiffusionIsNotStandardisedAsymptoticDiffusion => { + "unstandardised asymptotic diffusion is not standardised asymptotic diffusion" + } + Self::StandardisedInitialLatentVarianceIsNotStandardisedAsymptoticDiffusion => { + "standardised initial latent variance is not standardised asymptotic diffusion" + } + Self::StandardisedContinuousDiffusionIsNotStandardisedAsymptoticDiffusion => { + "standardised continuous diffusion is not standardised asymptotic diffusion" + } + Self::StandardisedTimeIndependentPredictorVarianceIsNotStandardisedAsymptoticDiffusion => { + "standardised time-independent predictor variance is not standardised asymptotic diffusion" + } Self::StandardisedDiscreteContinuousInterceptRequiresPositiveStationaryVariance => { "standardised discrete continuous intercept requires strictly positive stationary within-subject variance" } @@ -1802,6 +1844,35 @@ mod tests { ); } + #[test] + fn standardised_asymptotic_diffusion_boundary_messages_are_stable() { + assert_eq!( + PsychometricError::StandardisedAsymptoticDiffusionRequiresPositiveStationaryVariance + .to_string(), + "standardised asymptotic diffusion requires strictly positive stationary within-subject variance" + ); + assert_eq!( + PsychometricError::UnstandardisedAsymptoticDiffusionIsNotStandardisedAsymptoticDiffusion + .to_string(), + "unstandardised asymptotic diffusion is not standardised asymptotic diffusion" + ); + assert_eq!( + PsychometricError::StandardisedInitialLatentVarianceIsNotStandardisedAsymptoticDiffusion + .to_string(), + "standardised initial latent variance is not standardised asymptotic diffusion" + ); + assert_eq!( + PsychometricError::StandardisedContinuousDiffusionIsNotStandardisedAsymptoticDiffusion + .to_string(), + "standardised continuous diffusion is not standardised asymptotic diffusion" + ); + assert_eq!( + PsychometricError::StandardisedTimeIndependentPredictorVarianceIsNotStandardisedAsymptoticDiffusion + .to_string(), + "standardised time-independent predictor variance is not standardised asymptotic diffusion" + ); + } + #[test] fn standardised_discrete_continuous_intercept_boundary_messages_are_stable() { assert_eq!( diff --git a/crates/psychometric_core/src/event_time.rs b/crates/psychometric_core/src/event_time.rs index c21460d2..6025ea96 100644 --- a/crates/psychometric_core/src/event_time.rs +++ b/crates/psychometric_core/src/event_time.rs @@ -1603,9 +1603,9 @@ pub fn recover_standardised_initial_latent_mean( /// `p_0` recover the same 1. `T0MEANSstd` `μ_0 / √p_0` recovers /// the same number when `μ_0 = √p_0` and remains a distinct named /// quantity. `asymDIFFUSIONstd` `p / p = 1` recovers the same -/// number and remains a distinct named quantity. This crate does -/// not currently export `T0MEANSstd` or `asymDIFFUSIONstd`; the -/// refuse still names those quantities. This is not a Kalman +/// number and remains a distinct named quantity. This crate +/// exports `T0MEANSstd` and `asymDIFFUSIONstd`. +/// This is not a Kalman /// filter, not a matrix `expm`, not DSEM, and not ctsem estimation. /// /// # Errors @@ -1684,8 +1684,7 @@ pub fn refuse_unstandardised_initial_latent_variance_as_standardised_initial_lat /// Both scalar maps equal 1 when `μ_0 = √p_0`. `T0VARstd` is /// the correlation form of free `T0VAR`. `T0MEANSstd` is the /// first-occasion mean. Equal numbers remain distinct named -/// quantities. This crate does not currently export -/// `T0VARstd`; the refuse still names that quantity. +/// quantities. /// /// # Errors /// @@ -1704,8 +1703,7 @@ pub fn refuse_standardised_initial_latent_variance_as_standardised_initial_laten /// Both scalar maps equal 1 when `μ_0 = √p_0`. `T0VARstd` is the /// correlation form of free `T0VAR`. `T0MEANSstd` is the /// first-occasion mean. Equal numbers remain distinct named -/// quantities. This crate does not currently export `T0MEANSstd`; -/// the refuse still names that quantity. +/// quantities. /// /// # Errors /// @@ -1742,8 +1740,7 @@ pub fn refuse_within_subject_scaled_initial_latent_mean_as_standardised_initial_ /// variance. `T0VARstd` is the correlation form of free first- /// occasion `T0VAR`. `asymDIFFUSIONstd` is the correlation form of /// process-dynamics `asymDIFFUSION`. Equal numbers remain distinct -/// named quantities. This crate does not currently export -/// `asymDIFFUSIONstd`; the refuse still names that quantity. +/// named quantities. /// /// # Errors /// @@ -1760,6 +1757,169 @@ pub fn refuse_standardised_asymptotic_diffusion_as_standardised_initial_latent_v Err(PsychometricError::StandardisedAsymptoticDiffusionIsNotStandardisedInitialLatentVariance) } +/// Exact scalar p. 16 `asymDIFFUSIONstd` after strictly positive +/// `asymDIFFUSION`. +/// +/// Driver, Oud, and Voelkle (2017, p. 16; footnote 4; Eq. 4, p. 5; +/// Table 2, p. 12; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF +/// re-opened 2026-08-26T17:20Z from +/// ) +/// name `asymDIFFUSION` the total within-subject variance as +/// `Δt → ∞`. Page 16 prints standardised matrices with the suffix +/// `std` when appropriate. The printed example on p. 16 is +/// `discreteDRIFTstd`, not `asymDIFFUSIONstd`. Footnote 4: +/// standardisations use only the relevant variance, not the total. +/// The relevant variance for that named process-dynamics +/// correlation is within-subject `asymDIFFUSION` `p = −q / (2 a)`, +/// not free first-occasion `T0VAR`. The 2017-era +/// `summary.ctsemFit.R` forms `asymDIFFUSIONstd` as +/// `solve(sqrt(diag(asymDIFFUSION))) %&% asymDIFFUSION` when +/// `verbose = TRUE`. `OpenMx` `%&%` is the quadratic form +/// `t(A) %*% B %*% A`. The default `ridging = FALSE` adds 0, not +/// `0.0001`; that ridge is a numerical hack and is not this exact +/// map. The 2017-era source assigns +/// `dimnames(asymDIFFUSIONstd)` to `latentNames`; that assignment +/// is not this scalar map. The scalar correlation is `p / p = 1` +/// after strictly positive `asymDIFFUSION`. Form strictly positive +/// `p` first, then `1 / √p`, then `(1 / √p) p (1 / √p)`. +/// Unstandardised `asymDIFFUSION` is defined for a zero process; +/// standardised `asymDIFFUSION` is not. Zero `q` has no positive +/// SD and fails closed. Lasting `p` requires stable `a < 0`. +/// `asymDIFFUSION` is an event-time process-dynamics quantity, so +/// a non-event clock fails closed. Distinct positive `p` recover +/// the same 1. `T0VARstd` `p_0 / p_0 = 1` recovers the same number +/// and remains a distinct named quantity. `DIFFUSIONstd` +/// `q / p = −2 a` is the continuous-diffusion ratio and is not this +/// correlation. `TIPREDVARstd` `v / v = 1` recovers the same number +/// and remains a distinct named quantity. This crate does not +/// currently export `DIFFUSIONstd` or `TIPREDVARstd`; the refuse +/// still names those quantities. This is not a Kalman filter, not a +/// matrix `expm`, not DSEM, and not ctsem estimation. +/// +/// # Errors +/// +/// Returns [`PsychometricError::EventTimeRequired`] for any +/// non-event clock, +/// [`PsychometricError::StationaryVarianceRequiresStableDrift`] +/// when `a ≥ 0`, +/// [`PsychometricError::StandardisedAsymptoticDiffusionRequiresPositiveStationaryVariance`] +/// when `q = 0`, and +/// [`PsychometricError::InvalidNumericInput`] when the diffusion or +/// log-rate is non-finite, the diffusion is negative, or the +/// quadratic form overflows. +pub fn recover_standardised_asymptotic_diffusion( + continuous_diffusion: f64, + log_rate: f64, + clock: LagClock, +) -> Result { + let stationary = recover_stationary_latent_variance(continuous_diffusion, log_rate, clock)?; + if stationary == 0.0 { + return Err( + PsychometricError::StandardisedAsymptoticDiffusionRequiresPositiveStationaryVariance, + ); + } + let process_sd = stationary.sqrt(); + let inverse_sd = require_finite(1.0 / process_sd)?; + let scaled = require_finite(inverse_sd * stationary)?; + require_finite(scaled * inverse_sd) +} + +/// Refuse treating unstandardised `asymDIFFUSION` as p. 16 +/// `asymDIFFUSIONstd`. +/// +/// Unstandardised `p` is defined for a zero process. Footnote 4 +/// `asymDIFFUSIONstd` requires strictly positive `asymDIFFUSION`. +/// Equal numbers when `p = 1` are still distinct named quantities. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::UnstandardisedAsymptoticDiffusionIsNotStandardisedAsymptoticDiffusion`]. +pub fn refuse_unstandardised_asymptotic_diffusion_as_standardised_asymptotic_diffusion( + unstandardised_asymptotic_diffusion: f64, + standardised_asymptotic_diffusion: f64, +) -> Result { + let _ = ( + unstandardised_asymptotic_diffusion, + standardised_asymptotic_diffusion, + ); + Err(PsychometricError::UnstandardisedAsymptoticDiffusionIsNotStandardisedAsymptoticDiffusion) +} + +/// Refuse treating p. 16 `T0VARstd` as p. 16 `asymDIFFUSIONstd`. +/// +/// Both scalar maps equal 1 after a strictly positive relevant +/// variance. `asymDIFFUSIONstd` is the correlation form of +/// process-dynamics `asymDIFFUSION`. `T0VARstd` is the correlation +/// form of free first-occasion `T0VAR`. Equal numbers remain +/// distinct named quantities. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StandardisedInitialLatentVarianceIsNotStandardisedAsymptoticDiffusion`]. +pub fn refuse_standardised_initial_latent_variance_as_standardised_asymptotic_diffusion( + standardised_initial_variance: f64, + standardised_asymptotic_diffusion: f64, +) -> Result { + let _ = ( + standardised_initial_variance, + standardised_asymptotic_diffusion, + ); + Err(PsychometricError::StandardisedInitialLatentVarianceIsNotStandardisedAsymptoticDiffusion) +} + +/// Refuse treating p. 16 `DIFFUSIONstd` as p. 16 +/// `asymDIFFUSIONstd`. +/// +/// `q / p = −2 a` is the continuous-diffusion ratio. `asymDIFFUSIONstd` +/// is the correlation form of `asymDIFFUSION`. Equal numbers when +/// `a = −0.5` remain distinct named quantities. This crate does not +/// currently export `DIFFUSIONstd`; the refuse still names that +/// quantity. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StandardisedContinuousDiffusionIsNotStandardisedAsymptoticDiffusion`]. +pub fn refuse_standardised_continuous_diffusion_as_standardised_asymptotic_diffusion( + standardised_continuous_diffusion: f64, + standardised_asymptotic_diffusion: f64, +) -> Result { + let _ = ( + standardised_continuous_diffusion, + standardised_asymptotic_diffusion, + ); + Err(PsychometricError::StandardisedContinuousDiffusionIsNotStandardisedAsymptoticDiffusion) +} + +/// Refuse treating p. 16 `TIPREDVARstd` as p. 16 +/// `asymDIFFUSIONstd`. +/// +/// Both scalar maps equal 1 after a strictly positive relevant +/// variance. `asymDIFFUSIONstd` is the correlation form of +/// process-dynamics `asymDIFFUSION`. `TIPREDVARstd` is the +/// correlation form of `TIPREDVAR`. Equal numbers remain distinct +/// named quantities. This crate does not currently export +/// `TIPREDVARstd`; the refuse still names that quantity. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StandardisedTimeIndependentPredictorVarianceIsNotStandardisedAsymptoticDiffusion`]. +pub fn refuse_standardised_time_independent_predictor_variance_as_standardised_asymptotic_diffusion( + standardised_predictor_variance: f64, + standardised_asymptotic_diffusion: f64, +) -> Result { + let _ = ( + standardised_predictor_variance, + standardised_asymptotic_diffusion, + ); + Err( + PsychometricError::StandardisedTimeIndependentPredictorVarianceIsNotStandardisedAsymptoticDiffusion, + ) +} + /// Exact scalar discrete intercept increment from Driver Equation 3. /// /// Driver, Oud, and Voelkle (2017, Eq. 3, p. 4; Table 2, p. 12; JSS @@ -6319,7 +6479,7 @@ mod tests { recover_manifest_lagged_observed_covariance, recover_manifest_observed_mean, recover_manifest_observed_variance, recover_manifest_trait_plus_state_observed_variance, recover_standardised_asymptotic_continuous_intercept, - recover_standardised_continuous_intercept, + recover_standardised_asymptotic_diffusion, recover_standardised_continuous_intercept, recover_standardised_discrete_continuous_intercept, recover_standardised_initial_latent_mean, recover_standardised_initial_latent_variance, recover_standardised_manifest_mean, recover_stationary_initial_latent_mean, @@ -6409,11 +6569,14 @@ mod tests { refuse_pooled_discrete_lag_across_unequal_intervals, refuse_process_noise_as_unconditional_variance, refuse_standardised_asymptotic_diffusion_as_standardised_initial_latent_variance, + refuse_standardised_continuous_diffusion_as_standardised_asymptotic_diffusion, refuse_standardised_continuous_intercept_as_standardised_asymptotic_continuous_intercept, refuse_standardised_continuous_intercept_as_standardised_discrete_continuous_intercept, refuse_standardised_initial_latent_mean_as_standardised_initial_latent_variance, + refuse_standardised_initial_latent_variance_as_standardised_asymptotic_diffusion, refuse_standardised_initial_latent_variance_as_standardised_initial_latent_mean, refuse_standardised_manifest_variance_as_standardised_manifest_mean, + refuse_standardised_time_independent_predictor_variance_as_standardised_asymptotic_diffusion, 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, @@ -6455,6 +6618,7 @@ mod tests { refuse_trait_variance_as_process_noise, refuse_trait_variance_as_stationary_within_subject, refuse_unmatched_time_varying_predictor_interval, refuse_unstandardised_asymptotic_continuous_intercept_as_standardised_asymptotic_continuous_intercept, + refuse_unstandardised_asymptotic_diffusion_as_standardised_asymptotic_diffusion, refuse_unstandardised_continuous_intercept_as_standardised_continuous_intercept, refuse_unstandardised_discrete_continuous_intercept_as_standardised_discrete_continuous_intercept, refuse_unstandardised_initial_latent_mean_as_standardised_initial_latent_mean, @@ -15297,6 +15461,121 @@ mod tests { ); } + #[test] + fn standardised_asymptotic_diffusion_recovers_driver_page_sixteen_correlation() { + // Driver et al. (2017, p. 16 asymDIFFUSIONstd; footnote 4; + // Eq. 4; 2017-era summary.ctsemFit.R): form strictly positive + // asymDIFFUSION p = −q/(2a), then (1/√p) p (1/√p) = 1. + let diffusion = 0.4_f64; + let log_rate = -0.25_f64; + let recovered = + recover_standardised_asymptotic_diffusion(diffusion, log_rate, LagClock::EventTime) + .expect("asymDIFFUSIONstd"); + assert!((recovered - 1.0).abs() < 1e-15); + let larger_q = + recover_standardised_asymptotic_diffusion(1.6, log_rate, LagClock::EventTime) + .expect("asymDIFFUSIONstd q=1.6"); + assert!((larger_q - recovered).abs() < 1e-15); + let steeper = + recover_standardised_asymptotic_diffusion(diffusion, -0.5, LagClock::EventTime) + .expect("asymDIFFUSIONstd a=-0.5"); + assert!((steeper - recovered).abs() < 1e-15); + let stationary = + recover_stationary_latent_variance(diffusion, log_rate, LagClock::EventTime) + .expect("asymDIFFUSION"); + assert!((stationary - 0.8).abs() < 1e-15); + assert!((stationary - recovered).abs() > 1e-3); + let t0var_std = recover_standardised_initial_latent_variance(1.6, LagClock::EventTime) + .expect("T0VARstd"); + assert!((t0var_std - recovered).abs() < 1e-15); + let continuous_diffusion_std = -2.0 * log_rate; + assert!((continuous_diffusion_std - recovered).abs() > 1e-3); + let equal_ratio = recover_standardised_asymptotic_diffusion(0.4, -0.5, LagClock::EventTime) + .expect("a=-0.5"); + assert!(((-2.0 * -0.5) - equal_ratio).abs() < 1e-15); + let tipred_std = 1.0_f64; + assert_eq!( + refuse_unstandardised_asymptotic_diffusion_as_standardised_asymptotic_diffusion( + stationary, recovered + ), + Err( + PsychometricError::UnstandardisedAsymptoticDiffusionIsNotStandardisedAsymptoticDiffusion + ) + ); + assert_eq!( + refuse_standardised_initial_latent_variance_as_standardised_asymptotic_diffusion( + t0var_std, recovered + ), + Err( + PsychometricError::StandardisedInitialLatentVarianceIsNotStandardisedAsymptoticDiffusion + ) + ); + assert_eq!( + refuse_standardised_continuous_diffusion_as_standardised_asymptotic_diffusion( + continuous_diffusion_std, + recovered + ), + Err( + PsychometricError::StandardisedContinuousDiffusionIsNotStandardisedAsymptoticDiffusion + ) + ); + assert_eq!( + refuse_standardised_continuous_diffusion_as_standardised_asymptotic_diffusion( + -2.0 * -0.5, + equal_ratio + ), + Err( + PsychometricError::StandardisedContinuousDiffusionIsNotStandardisedAsymptoticDiffusion + ) + ); + assert_eq!( + refuse_standardised_time_independent_predictor_variance_as_standardised_asymptotic_diffusion( + tipred_std, recovered + ), + Err( + PsychometricError::StandardisedTimeIndependentPredictorVarianceIsNotStandardisedAsymptoticDiffusion + ) + ); + } + + #[test] + fn standardised_asymptotic_diffusion_fails_closed_when_unstandardised_is_defined() { + assert_eq!( + recover_standardised_asymptotic_diffusion(0.0, -0.25, LagClock::EventTime), + Err( + PsychometricError::StandardisedAsymptoticDiffusionRequiresPositiveStationaryVariance + ) + ); + assert_eq!( + recover_standardised_asymptotic_diffusion(0.4, -0.25, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_standardised_asymptotic_diffusion(0.4, 0.25, LagClock::EventTime), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_standardised_asymptotic_diffusion(0.4, 0.0, LagClock::EventTime), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_standardised_asymptotic_diffusion(-0.4, -0.25, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_standardised_asymptotic_diffusion(f64::NAN, -0.25, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_standardised_asymptotic_diffusion(0.4, f64::NAN, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_standardised_asymptotic_diffusion(f64::INFINITY, -0.25, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + } + #[test] fn standardised_discrete_continuous_intercept_fails_closed_when_unstandardised_is_defined() { assert_eq!( diff --git a/crates/psychometric_core/src/lib.rs b/crates/psychometric_core/src/lib.rs index 2c9c2b0f..df36a362 100644 --- a/crates/psychometric_core/src/lib.rs +++ b/crates/psychometric_core/src/lib.rs @@ -234,6 +234,18 @@ //! `μ_0 / √p_0` is `T0MEANSstd` and is not that map even when //! `μ_0 = √p_0`; `p / p = 1` is `asymDIFFUSIONstd` and is not that //! map even when both equal 1; JSS PDF re-opened 2026-08-26T07:17Z), +//! recovers the Driver p. 16 `asymDIFFUSIONstd` as `p / p = 1` after +//! strictly positive `asymDIFFUSION` `p = −q / (2 a)` (footnote 4 +//! uses only the relevant within-subject variance; 2017-era +//! `summary.ctsemFit.R` forms `asymDIFFUSIONstd` as +//! `solve(sqrt(diag(asymDIFFUSION))) %&% asymDIFFUSION`; `OpenMx` +//! `%&%` is `t(A) %*% B %*% A`; default ridge is 0; unstandardised +//! `p` is defined for a zero process and is not that map; `p_0 / p_0 +//! = 1` is `T0VARstd` and is not that map even when both equal 1; +//! `q / p = −2 a` is `DIFFUSIONstd` and is not that map; `v / v = 1` +//! is `TIPREDVARstd` and is not that map even when both equal 1; +//! zero `q` fails closed; a non-event clock fails closed; `a ≥ 0` +//! fails closed; JSS PDF re-opened 2026-08-26T17:20Z), //! and refuses //! latent-mean comparison below strong invariance. @@ -374,6 +386,8 @@ pub use event_time::recover_manifest_observed_variance; pub use event_time::recover_manifest_trait_plus_state_observed_variance; /// Exact scalar p. 16 `asymCINTstd` `(-κ / a) / √p` after strictly positive `asymDIFFUSION`. pub use event_time::recover_standardised_asymptotic_continuous_intercept; +/// Exact scalar p. 16 `asymDIFFUSIONstd` `p / p = 1` after strictly positive `asymDIFFUSION`. +pub use event_time::recover_standardised_asymptotic_diffusion; /// Exact scalar p. 16 `CINTstd` `κ / √p`. pub use event_time::recover_standardised_continuous_intercept; /// Exact scalar p. 16 `discreteCINTstd` `A^{-1}[e^{A Δt} − I] κ / √p`. @@ -578,16 +592,22 @@ pub use event_time::refuse_pooled_discrete_lag_across_unequal_intervals; pub use event_time::refuse_process_noise_as_unconditional_variance; /// Refuse treating p. 16 `asymDIFFUSIONstd` as `T0VARstd`. pub use event_time::refuse_standardised_asymptotic_diffusion_as_standardised_initial_latent_variance; +/// Refuse treating p. 16 `DIFFUSIONstd` as `asymDIFFUSIONstd`. +pub use event_time::refuse_standardised_continuous_diffusion_as_standardised_asymptotic_diffusion; /// Refuse treating p. 16 `CINTstd` as `asymCINTstd`. pub use event_time::refuse_standardised_continuous_intercept_as_standardised_asymptotic_continuous_intercept; /// Refuse treating p. 16 `CINTstd` as `discreteCINTstd`. pub use event_time::refuse_standardised_continuous_intercept_as_standardised_discrete_continuous_intercept; /// Refuse treating p. 16 `T0MEANSstd` as `T0VARstd`. pub use event_time::refuse_standardised_initial_latent_mean_as_standardised_initial_latent_variance; +/// Refuse treating p. 16 `T0VARstd` as `asymDIFFUSIONstd`. +pub use event_time::refuse_standardised_initial_latent_variance_as_standardised_asymptotic_diffusion; /// Refuse treating p. 16 `T0VARstd` as `T0MEANSstd`. pub use event_time::refuse_standardised_initial_latent_variance_as_standardised_initial_latent_mean; /// Refuse treating `MANIFESTVARstd` as `MANIFESTMEANSstd`. pub use event_time::refuse_standardised_manifest_variance_as_standardised_manifest_mean; +/// Refuse treating p. 16 `TIPREDVARstd` as `asymDIFFUSIONstd`. +pub use event_time::refuse_standardised_time_independent_predictor_variance_as_standardised_asymptotic_diffusion; /// 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`. @@ -672,6 +692,8 @@ pub use event_time::refuse_trait_variance_as_stationary_within_subject; pub use event_time::refuse_unmatched_time_varying_predictor_interval; /// Refuse treating unstandardised `asymCINT` as `asymCINTstd`. pub use event_time::refuse_unstandardised_asymptotic_continuous_intercept_as_standardised_asymptotic_continuous_intercept; +/// Refuse treating unstandardised `asymDIFFUSION` as `asymDIFFUSIONstd`. +pub use event_time::refuse_unstandardised_asymptotic_diffusion_as_standardised_asymptotic_diffusion; /// Refuse treating unstandardised `CINT` as `CINTstd`. pub use event_time::refuse_unstandardised_continuous_intercept_as_standardised_continuous_intercept; /// Refuse treating unstandardised `discreteCINT` as `discreteCINTstd`. diff --git a/crates/psychometric_core/tests/multilevel_event_time_recovery_contract.rs b/crates/psychometric_core/tests/multilevel_event_time_recovery_contract.rs index bbc5859d..2fd606b8 100644 --- a/crates/psychometric_core/tests/multilevel_event_time_recovery_contract.rs +++ b/crates/psychometric_core/tests/multilevel_event_time_recovery_contract.rs @@ -35,16 +35,16 @@ use psychometric_core::{ recover_manifest_lagged_observed_covariance, recover_manifest_observed_mean, recover_manifest_observed_variance, recover_manifest_trait_plus_state_observed_variance, recover_standardised_asymptotic_continuous_intercept, - recover_standardised_continuous_intercept, recover_standardised_discrete_continuous_intercept, - recover_standardised_initial_latent_mean, recover_standardised_initial_latent_variance, - recover_standardised_manifest_mean, 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, + recover_standardised_asymptotic_diffusion, recover_standardised_continuous_intercept, + recover_standardised_discrete_continuous_intercept, recover_standardised_initial_latent_mean, + recover_standardised_initial_latent_variance, recover_standardised_manifest_mean, + 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, @@ -6040,6 +6040,59 @@ fn standardised_initial_latent_variance_refuses_non_event_clocks_and_does_not_ke ); } +#[test] +fn standardised_asymptotic_diffusion_recovers_driver_page_sixteen_correlation() { + let diffusion = 0.4_f64; + let log_rate = -0.25_f64; + let recovered = + recover_standardised_asymptotic_diffusion(diffusion, log_rate, LagClock::EventTime) + .expect("asymDIFFUSIONstd"); + let recovered_error = (recovered - 1.0).abs(); + assert!( + recovered_error < 1e-15, + "Driver et al. (2017, p. 16 asymDIFFUSIONstd): RMSE {recovered_error} for p / p = 1" + ); + let larger_q = recover_standardised_asymptotic_diffusion(1.6, log_rate, LagClock::EventTime) + .expect("asymDIFFUSIONstd q=1.6"); + assert!( + (larger_q - recovered).abs() < 1e-15, + "Driver et al. (2017, p. 16): distinct positive asymDIFFUSION recover the same asymDIFFUSIONstd" + ); + let stationary = + recover_stationary_latent_variance(diffusion, log_rate, LagClock::EventTime).expect("p"); + let unstandardised_error = (stationary - 1.0).abs(); + assert!( + unstandardised_error > recovered_error, + "Driver et al. (2017, p. 16): unstandardised asymDIFFUSION RMSE {unstandardised_error} must exceed asymDIFFUSIONstd RMSE {recovered_error}" + ); + let continuous_diffusion_std = -2.0 * log_rate; + let ratio_error = (continuous_diffusion_std - 1.0).abs(); + assert!( + ratio_error > recovered_error, + "Driver et al. (2017, p. 16): DIFFUSIONstd RMSE {ratio_error} must exceed asymDIFFUSIONstd RMSE {recovered_error}" + ); +} + +#[test] +fn standardised_asymptotic_diffusion_refuses_non_event_clocks_and_does_not_keep_zero_q() { + assert_eq!( + recover_standardised_asymptotic_diffusion(0.4, -0.25, LagClock::AssertionTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_standardised_asymptotic_diffusion(0.4, -0.25, LagClock::KnowledgeCutoff), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_standardised_asymptotic_diffusion(0.0, -0.25, LagClock::EventTime), + Err(PsychometricError::StandardisedAsymptoticDiffusionRequiresPositiveStationaryVariance) + ); + assert_eq!( + recover_standardised_asymptotic_diffusion(0.4, 0.25, LagClock::EventTime), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); +} + #[test] fn standardised_discrete_continuous_intercept_recovers_driver_page_sixteen_after_positive_p() { let intercept = 0.4_f64; diff --git a/crates/psychometric_core/tests/scientific_claim_boundary_contract.rs b/crates/psychometric_core/tests/scientific_claim_boundary_contract.rs index d154cf2a..e9105663 100644 --- a/crates/psychometric_core/tests/scientific_claim_boundary_contract.rs +++ b/crates/psychometric_core/tests/scientific_claim_boundary_contract.rs @@ -30,16 +30,16 @@ use psychometric_core::{ recover_manifest_lagged_observed_covariance, recover_manifest_observed_mean, recover_manifest_observed_variance, recover_manifest_trait_plus_state_observed_variance, recover_standardised_asymptotic_continuous_intercept, - recover_standardised_continuous_intercept, recover_standardised_discrete_continuous_intercept, - recover_standardised_initial_latent_mean, recover_standardised_initial_latent_variance, - recover_standardised_manifest_mean, 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, + recover_standardised_asymptotic_diffusion, recover_standardised_continuous_intercept, + recover_standardised_discrete_continuous_intercept, recover_standardised_initial_latent_mean, + recover_standardised_initial_latent_variance, recover_standardised_manifest_mean, + 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, @@ -115,11 +115,14 @@ use psychometric_core::{ refuse_observed_scaled_manifest_mean_as_standardised_manifest_mean, refuse_process_noise_as_unconditional_variance, refuse_standardised_asymptotic_diffusion_as_standardised_initial_latent_variance, + refuse_standardised_continuous_diffusion_as_standardised_asymptotic_diffusion, refuse_standardised_continuous_intercept_as_standardised_asymptotic_continuous_intercept, refuse_standardised_continuous_intercept_as_standardised_discrete_continuous_intercept, refuse_standardised_initial_latent_mean_as_standardised_initial_latent_variance, + refuse_standardised_initial_latent_variance_as_standardised_asymptotic_diffusion, refuse_standardised_initial_latent_variance_as_standardised_initial_latent_mean, refuse_standardised_manifest_variance_as_standardised_manifest_mean, + refuse_standardised_time_independent_predictor_variance_as_standardised_asymptotic_diffusion, 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, @@ -160,6 +163,7 @@ use psychometric_core::{ refuse_trait_scaled_continuous_intercept_as_standardised_continuous_intercept, refuse_trait_variance_as_process_noise, refuse_trait_variance_as_stationary_within_subject, refuse_unstandardised_asymptotic_continuous_intercept_as_standardised_asymptotic_continuous_intercept, + refuse_unstandardised_asymptotic_diffusion_as_standardised_asymptotic_diffusion, refuse_unstandardised_continuous_intercept_as_standardised_continuous_intercept, refuse_unstandardised_discrete_continuous_intercept_as_standardised_discrete_continuous_intercept, refuse_unstandardised_initial_latent_mean_as_standardised_initial_latent_mean, @@ -3290,6 +3294,90 @@ fn standardised_initial_latent_variance_is_not_unstandardised_mean_or_asymptotic ); } +#[test] +fn standardised_asymptotic_diffusion_is_not_unstandardised_t0var_or_diffusion_ratio() { + let diffusion = 0.4_f64; + let log_rate = -0.25_f64; + let recovered = + recover_standardised_asymptotic_diffusion(diffusion, log_rate, LagClock::EventTime) + .expect("asymDIFFUSIONstd"); + assert!( + (recovered - 1.0).abs() < 1e-15, + "Driver et al. (2017, p. 16 / 2017-era summary.ctsemFit.R): asymDIFFUSIONstd is p/p = 1" + ); + let larger_q = recover_standardised_asymptotic_diffusion(1.6, log_rate, LagClock::EventTime) + .expect("asymDIFFUSIONstd q=1.6"); + assert!( + (larger_q - recovered).abs() < 1e-15, + "Driver et al. (2017, p. 16): distinct positive asymDIFFUSION recover the same asymDIFFUSIONstd" + ); + let stationary = + recover_stationary_latent_variance(diffusion, log_rate, LagClock::EventTime).expect("p"); + assert!( + (recovered - stationary).abs() > 1e-3, + "Driver et al. (2017, p. 16): unstandardised asymDIFFUSION is not asymDIFFUSIONstd" + ); + let t0var_std = + recover_standardised_initial_latent_variance(1.6, LagClock::EventTime).expect("T0VARstd"); + assert!( + (t0var_std - recovered).abs() < 1e-15, + "Driver et al. (2017, p. 16): T0VARstd equals 1 after strictly positive p_0" + ); + let continuous_diffusion_std = -2.0 * log_rate; + assert!( + (continuous_diffusion_std - recovered).abs() > 1e-3, + "Driver et al. (2017, p. 16): DIFFUSIONstd −2a is not asymDIFFUSIONstd" + ); + let tipred_std = 1.0_f64; + assert_eq!( + refuse_unstandardised_asymptotic_diffusion_as_standardised_asymptotic_diffusion( + stationary, recovered + ), + Err( + psychometric_core::PsychometricError::UnstandardisedAsymptoticDiffusionIsNotStandardisedAsymptoticDiffusion + ) + ); + assert_eq!( + refuse_standardised_initial_latent_variance_as_standardised_asymptotic_diffusion( + t0var_std, recovered + ), + Err( + psychometric_core::PsychometricError::StandardisedInitialLatentVarianceIsNotStandardisedAsymptoticDiffusion + ) + ); + assert_eq!( + refuse_standardised_continuous_diffusion_as_standardised_asymptotic_diffusion( + continuous_diffusion_std, + recovered + ), + Err( + psychometric_core::PsychometricError::StandardisedContinuousDiffusionIsNotStandardisedAsymptoticDiffusion + ) + ); + assert_eq!( + refuse_standardised_time_independent_predictor_variance_as_standardised_asymptotic_diffusion( + tipred_std, recovered + ), + Err( + psychometric_core::PsychometricError::StandardisedTimeIndependentPredictorVarianceIsNotStandardisedAsymptoticDiffusion + ) + ); + assert_eq!( + recover_standardised_asymptotic_diffusion(0.0, log_rate, LagClock::EventTime), + Err( + psychometric_core::PsychometricError::StandardisedAsymptoticDiffusionRequiresPositiveStationaryVariance + ) + ); + assert_eq!( + recover_standardised_asymptotic_diffusion(diffusion, log_rate, LagClock::DocumentTime), + Err(psychometric_core::PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_standardised_asymptotic_diffusion(diffusion, 0.25, LagClock::EventTime), + Err(psychometric_core::PsychometricError::StationaryVarianceRequiresStableDrift) + ); +} + #[test] fn standardised_discrete_continuous_intercept_is_not_unstandardised_continuous_or_asymptotic() { let intercept = 0.4_f64; diff --git a/docs/adr/0005-posterior-esem-dsem.md b/docs/adr/0005-posterior-esem-dsem.md index 9fc0cb36..4308cb06 100644 --- a/docs/adr/0005-posterior-esem-dsem.md +++ b/docs/adr/0005-posterior-esem-dsem.md @@ -34,6 +34,7 @@ The executable standardised-measurement slice recovers Driver et al. (2017, p. 1 The executable standardised-asymptotic-intercept slice recovers Driver et al. (2017, p. 16 `asymCINTstd`) as `(-κ / a) / √p` after strictly positive `asymDIFFUSION` `p = −q / (2 a)` (footnote 4; Eq. 3; Table 2; 2017-era `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-26T00:20Z). Unstandardised `-κ / a` is defined for a zero process and is not that map. `κ / √p` is `CINTstd` and is not this total-change map. `A^{-1}[e^{A Δt} − I] κ / √p` is `discreteCINTstd` and is not this `Δt → ∞` map. This is not ctsem estimation. The executable standardised-initial-mean slice recovers Driver et al. (2017, p. 16 `T0MEANSstd`) as `μ_0 / √p_0` after strictly positive free `T0VAR` (footnote 4; JSS PDF re-opened 2026-08-26T04:09Z). Unstandardised `μ_0` is defined for a zero first-occasion variance and is not that map. `p_0 / p_0 = 1` is the named `T0VARstd` correlation form and is not `T0MEANSstd` even when `μ_0 = √p_0`. `μ_0 / √asymDIFFUSION` uses process-dynamics variance and is not the first-occasion map. Free `T0MEANS` does not require `a < 0`. This is not ctsem estimation. The executable standardised-initial-variance slice recovers Driver et al. (2017, p. 16 `T0VARstd`) as `p_0 / p_0 = 1` after strictly positive free `T0VAR` (footnote 4; 2017-era `summary.ctsemFit.R` `solve(sqrt(diag(T0VAR))) %&% T0VAR`; JSS PDF re-opened 2026-08-26T07:17Z). Unstandardised `p_0` is defined for a zero first-occasion variance and is not that map. `μ_0 / √p_0` is the named `T0MEANSstd` first-occasion mean and is not `T0VARstd` even when `μ_0 = √p_0`. `p / p = 1` is the named `asymDIFFUSIONstd` correlation form and is not `T0VARstd` even when both equal 1. Free `T0VAR` does not require `a < 0`. This is not ctsem estimation. +The executable standardised-asymptotic-diffusion slice recovers Driver et al. (2017, p. 16 `asymDIFFUSIONstd`) as `p / p = 1` after strictly positive `asymDIFFUSION` `p = −q / (2 a)` (footnote 4; 2017-era `summary.ctsemFit.R` `solve(sqrt(diag(asymDIFFUSION))) %&% asymDIFFUSION`; JSS PDF re-opened 2026-08-26T17:20Z). Unstandardised `p` is defined for a zero process and is not that map. `p_0 / p_0 = 1` is the named `T0VARstd` first-occasion correlation and is not `asymDIFFUSIONstd` even when both equal 1. `q / p = −2 a` is the named `DIFFUSIONstd` continuous-diffusion ratio and is not this correlation. `v / v = 1` is the named `TIPREDVARstd` predictor correlation and is not this map even when both equal 1. Zero `q` and `a ≥ 0` fail closed. This is not ctsem estimation. Input/process/intervention/outcome paths obey event-time order. Temporal precedence, document linkage, event tracking, or model prediction alone do not justify causal language. diff --git a/docs/research/multilevel-event-time-recovery.md b/docs/research/multilevel-event-time-recovery.md index 0ac29769..6069ee88 100644 --- a/docs/research/multilevel-event-time-recovery.md +++ b/docs/research/multilevel-event-time-recovery.md @@ -92,7 +92,7 @@ This slice stays inside `psychometric_core`. It does not add a second invariance 86. refuse treating unstandardised `MANIFESTVAR` as `MANIFESTVARstd`, refuse treating `MANIFESTTRAITVARstd` as `MANIFESTVARstd` even when both equal 1, and refuse treating Equation 5 `Var(y)` as `MANIFESTVARstd`; 87. recover the exact scalar p. 16 `TIPREDVARstd` as `solve(sqrt(diag(TIPREDVAR))) %&% TIPREDVAR` after forming strictly positive `TIPREDVAR` (Driver et al., 2017, Table 2, p. 12; p. 16; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-23T22:53Z; OpenMx `%&%` is `t(A) %*% B %*% A`; unlike `TRAITVARstd` the 2017-era source adds ridging; the default ridge is 0; `dimnames` are `TIpredNames`; the scalar map is `v / v = 1`; `TIPREDVAR = 0` makes `solve(sqrt(0))` fail and fails closed; a non-event clock fails closed; `TIPREDVAR` does not require `a < 0`); 88. refuse treating unstandardised `TIPREDVAR` as `TIPREDVARstd`, refuse treating `MANIFESTVARstd` as `TIPREDVARstd` even when both equal 1, and refuse treating §7.2 `addedTIPREDVAR` `(B / a)² v` as `TIPREDVARstd`; -89. recover the exact scalar p. 16 `asymDIFFUSIONstd` as `solve(sqrt(diag(asymDIFFUSION))) %&% asymDIFFUSION` after forming strictly positive `asymDIFFUSION` `−q / (2 a)` (Driver et al., 2017, p. 16; footnote 4; Eq. 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-23T23:02Z; OpenMx `%&%` is `t(A) %*% B %*% A`; the 2017-era source adds ridging; the default ridge is 0; `dimnames` are `latentNames`; the scalar map is `p / p = 1`; `q = 0` makes `solve(sqrt(0))` fail and fails closed; a non-event clock fails closed; `a ≥ 0` fails closed); +89. recover the exact scalar p. 16 `asymDIFFUSIONstd` as `solve(sqrt(diag(asymDIFFUSION))) %&% asymDIFFUSION` after forming strictly positive `asymDIFFUSION` `−q / (2 a)` (Driver et al., 2017, p. 16; footnote 4; Eq. 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-26T17:20Z; OpenMx `%&%` is `t(A) %*% B %*% A`; the 2017-era source adds ridging; the default ridge is 0; `dimnames` are `latentNames`; the scalar map is `p / p = 1`; `q = 0` makes `solve(sqrt(0))` fail and fails closed; a non-event clock fails closed; `a ≥ 0` fails closed); 90. refuse treating unstandardised `asymDIFFUSION` as `asymDIFFUSIONstd`, refuse treating `TIPREDVARstd` as `asymDIFFUSIONstd` even when both equal 1, and refuse treating `DIFFUSIONstd` `−2 a` as `asymDIFFUSIONstd`; 91. recover the exact scalar p. 16 `discreteCINTstd` as `A^{-1}[e^{A Δt} − I] κ / √p` after forming strictly positive `asymDIFFUSION` `−q / (2 a)` (Driver et al., 2017, p. 16; footnote 4; Eq. 3; Table 2, p. 12; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-24T05:20Z; the 2017-era source forms unstandardised `discreteCINT` whenever `verbose = TRUE` as `solve(DRIFT) %*% (discreteDRIFT − I) %*% CINT`; that source does not form a `discreteCINTstd` matrix; a zero intercept is exactly zero; `q = 0` fails closed; a non-event clock fails closed; a non-positive event interval fails closed; `a ≥ 0` fails closed); 92. refuse treating unstandardised `discreteCINT` as `discreteCINTstd`, refuse treating `κ / √p` as `discreteCINTstd`, and refuse treating `(-κ / a) / √p` as `discreteCINTstd`; @@ -255,7 +255,7 @@ The Voelkle et al. (2012) ZORA accepted manuscript was re-opened 2026-08-18T21:0 - Driver et al. (2017, Table 2 / §7.1 / p. 16 `MANIFESTTRAITVARstd`; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-23T22:28Z) recovers the scalar correlation \(\psi/\psi=1\) at machine-scale RMSE after strictly positive `MANIFESTTRAITVAR`, and that RMSE is smaller than treating unstandardised `MANIFESTTRAITVAR` or `MANIFESTVAR` \(\theta\) as `MANIFESTTRAITVARstd`; distinct positive \(\psi\) recover the same 1; equal 1 with `TRAITVARstd` remains a distinct named quantity; `MANIFESTTRAITVAR = 0` fails closed; a non-event clock fails closed; `MANIFESTTRAITVAR` does not require \(a<0\). - Driver et al. (2017, Table 2 / Eq. 5 / p. 16 `MANIFESTVARstd`; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-23T22:40Z) recovers the scalar correlation \(\theta/\theta=1\) at machine-scale RMSE after strictly positive `MANIFESTVAR`, and that RMSE is smaller than treating unstandardised `MANIFESTVAR` or Equation 5 \(\operatorname{Var}(y)\) as `MANIFESTVARstd`; distinct positive \(\theta\) recover the same 1; equal 1 with `MANIFESTTRAITVARstd` remains a distinct named quantity; `MANIFESTVAR = 0` fails closed; a non-event clock fails closed; `MANIFESTVAR` does not require \(a<0\). - Driver et al. (2017, Table 2 / p. 16 `TIPREDVARstd`; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-23T22:53Z) recovers the scalar correlation \(v/v=1\) at machine-scale RMSE after strictly positive `TIPREDVAR`, and that RMSE is smaller than treating unstandardised `TIPREDVAR` or §7.2 `addedTIPREDVAR` \((B/a)^{2}v\) as `TIPREDVARstd`; distinct positive \(v\) recover the same 1; equal 1 with `MANIFESTVARstd` remains a distinct named quantity; `TIPREDVAR = 0` fails closed; a non-event clock fails closed; `TIPREDVAR` does not require \(a<0\). -- Driver et al. (2017, p. 16 `asymDIFFUSIONstd`; footnote 4; Eq. 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-23T23:02Z) recovers the scalar correlation \(p/p=1\) at machine-scale RMSE after strictly positive `asymDIFFUSION` \(-q/(2a)\), and that RMSE is smaller than treating unstandardised `asymDIFFUSION` or `DIFFUSIONstd` \(-2a\) as `asymDIFFUSIONstd`; distinct positive \(p\) recover the same 1; equal 1 with `TIPREDVARstd` remains a distinct named quantity; `q = 0` fails closed; a non-event clock fails closed; \(a\ge 0\) fails closed. +- Driver et al. (2017, p. 16 `asymDIFFUSIONstd`; footnote 4; Eq. 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-26T17:20Z) recovers the scalar correlation \(p/p=1\) at machine-scale RMSE after strictly positive `asymDIFFUSION` \(-q/(2a)\), and that RMSE is smaller than treating unstandardised `asymDIFFUSION` or `DIFFUSIONstd` \(-2a\) as `asymDIFFUSIONstd`; distinct positive \(p\) recover the same 1; equal 1 with `T0VARstd` and with `TIPREDVARstd` remain distinct named quantities; `q = 0` fails closed; a non-event clock fails closed; \(a\ge 0\) fails closed. - Driver et al. (2017, p. 16 `discreteCINTstd`; footnote 4; Eq. 3; Table 2; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-24T05:20Z) recovers the scalar standardised discrete intercept \(A^{-1}[e^{A\Delta t}-I]\kappa/\sqrt{p}\) at machine-scale RMSE after strictly positive `asymDIFFUSION` \(-q/(2a)\), and that RMSE is smaller than treating unstandardised `discreteCINT`, \(\kappa/\sqrt{p}\), or \((-\kappa/a)/\sqrt{p}\) as `discreteCINTstd`; a later event interval changes the result; a zero intercept is exactly zero; `q = 0` fails closed; a non-event clock fails closed; a non-positive event interval fails closed; \(a\ge 0\) fails closed. - Driver et al. (2017, p. 16 `asymCINTstd`; footnote 4; Eq. 3; Table 2; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-26T00:20Z) recovers the scalar standardised asymptotic intercept \((-\kappa/a)/\sqrt{p}\) at machine-scale RMSE after strictly positive `asymDIFFUSION` \(-q/(2a)\), and that RMSE is smaller than treating unstandardised `asymCINT`, \(\kappa/\sqrt{p}\), or `discreteCINTstd` as `asymCINTstd`; a later event interval changes `discreteCINTstd` and not this map; a zero intercept is exactly zero; `q = 0` fails closed; a non-event clock fails closed; \(a\ge 0\) fails closed. - Driver et al. (2017, p. 16 `T0MEANSstd`; footnote 4; Table 2; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-24T22:30Z) recovers the scalar standardised initial latent mean \(\mu_0/\sqrt{p_0}\) at machine-scale RMSE after strictly positive free `T0VAR` \(p_0\), and that RMSE is smaller than treating unstandardised `T0MEANS` or \(\mu_0/\sqrt{\mathrm{asymDIFFUSION}}\) as `T0MEANSstd`; a larger positive \(p_0\) yields a smaller \(|\mathrm{std}|\); a zero mean is exactly zero; equal 1 with `T0VARstd` when \(\mu_0=\sqrt{p_0}\) remains a distinct named quantity; \(p_0=0\) fails closed; a non-event clock fails closed; free `T0MEANS` does not require \(a<0\). diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..bda02073 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.13"