diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..cf8203762 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,8 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang - `event_core` adds bounded Allen interval-consistency classification, atomic path-consistency closure, contradiction/resource refusals, and an explicit dependency-error fallback without claiming unrestricted global satisfiability. +- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, p. 16 `DIFFUSIONstd`; Eq. 4, p. 5; footnote 4; §7.1, pp. 18–19; Table 2, p. 12; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-30T16:03Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar standardised continuous `DIFFUSION` on current main after `0ce16e8` dropped the pre-consolidation code while research notes already named the map (register items 59–60). Page 16 prints continuous-time parameters (e.g., `DRIFT`, `DIFFUSION`) and, when appropriate, standardised matrices with the suffix `std`. The printed example on p. 16 is `discreteDRIFTstd`, not `DIFFUSIONstd`. Footnote 4 standardises using only the relevant variance, not the total. Process noise is within-subject stochastic input, so that relevant variance is within-subject `asymDIFFUSION` `p = −q / (2 a)`, the same footnote 4 variance used for `DRIFT`. Table 2 names `DIFFUSION` `Q` the continuous diffusion variance/covariance. Form strictly positive `asymDIFFUSION` first, then `q / p`. In the scalar stationary case that ratio equals `−2 a` and does not depend on `q` once `q > 0`. Unstandardised `q` is defined for growing `a ≥ 0` and for zero diffusion; standardised `DIFFUSION` is not. Zero `asymDIFFUSION` has no positive SD and fails closed. Lasting `p` requires stable `a < 0`. A non-event clock fails closed. Distinct positive `q` recover the same `−2 a`. `p / p = 1` is `asymDIFFUSIONstd` and recovers the same number when `a = −0.5` and remains a distinct named quantity. `Q_Δt / p = 1 − exp(2 a Δt)` is `discreteDIFFUSIONstd` and depends on the event interval. `q / (trait + p + added)` uses the total, not `asymDIFFUSION`, and is not this map when `TRAITVAR` is nonzero. `TRAITVAR` is not the standardisation variance. Meredith (1993) remains unread (Unpaywall historically `is_oa: false`; Springer `content/pdf` is an HTML stub). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread on the same terms (DOI `10.1007/bf02294457`). 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, Table 2, p. 12 `MANIFESTTRAITVAR`; §7.1, p. 19; p. 16 `MANIFESTTRAITVARstd`; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-27T14:20Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar standardised manifest-trait variance on current main after `0ce16e8` dropped the pre-consolidation code while research notes already named the map (register items 83–84). Table 2 names `MANIFESTTRAITVAR` `Ψ_τ` the additional time-invariant variance-covariance on the measurement level and sets it `NULL` when there is no manifest trait. Equation 5 writes `Γ ~ N(τ, Ψ)` and names that covariance the manifest traits. Section 7.1 names manifest traits stable individual differences in indicator levels, distinct from process-level `TRAITVAR` `φ_ξ`. Page 16 prints standardised matrices with the suffix `std` when appropriate. The printed example on p. 16 is `discreteDRIFTstd`, not `MANIFESTTRAITVARstd`. Footnote 4 standardises using only the relevant variance, not the total. The relevant variance for that named indicator-level correlation is `MANIFESTTRAITVAR`, not process-level `TRAITVAR` and not residual `MANIFESTVAR` `θ`. The 2017-era source forms `MANIFESTTRAITVARstd` only when `MANIFESTTRAITVAR != 0`, as `solve(sqrt(diag(MANIFESTTRAITVAR) + ridging)) %&% MANIFESTTRAITVAR` when `verbose = TRUE`. OpenMx `%&%` is `t(A) %*% B %*% A`. Unlike `TRAITVARstd`, that formation adds `diag(c(ridging), n.manifest)`. 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 `ψ / ψ = 1` after strictly positive `MANIFESTTRAITVAR`. Form strictly positive `ψ` first, then `1 / √ψ`, then `(1 / √ψ) ψ (1 / √ψ)`. Unstandardised `MANIFESTTRAITVAR` is defined for a zero trait; standardised `MANIFESTTRAITVAR` is not. Zero `MANIFESTTRAITVAR` skips forming `MANIFESTTRAITVARstd` in the 2017-era source and fails closed here. Indicator-level trait variance is an event-time structural quantity, so a non-event clock fails closed. `MANIFESTTRAITVAR` does not require stable `a < 0`. Distinct positive `ψ` recover the same 1. `trait / trait = 1` is `TRAITVARstd` and recovers the same number and remains a distinct named quantity. `θ` is `MANIFESTVAR` and is measurement error, not this correlation. Meredith (1993) remains unread (web search 2026-08-27T14:20Z: Springer/Cambridge Core paywalled; Unpaywall historically `is_oa: false`; Springer `content/pdf` is an HTML stub). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread on the same terms (DOI `10.1007/bf02294457`). 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, Table 2, p. 12 `TRAITVAR`; §7.1, pp. 18–19; p. 16 `TRAITVARstd`; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-26T17:45Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar standardised trait variance on current main after `0ce16e8` dropped the pre-consolidation code while research notes already named the map (register items 81–82). Table 2 names `TRAITVAR` `φ_ξ` the latent trait variance/covariance and sets it `NULL` when there is no trait. Section 7.1 names traits the stable between-subject differences (unit-level unobserved heterogeneity). Page 16 prints standardised matrices with the suffix `std` when appropriate. The printed example on p. 16 is `discreteDRIFTstd`, not `TRAITVARstd`. Footnote 4 standardises using only the relevant variance, not the total. The relevant variance for that named between-subject correlation is `TRAITVAR`, not free first-occasion `T0VAR` and not process-dynamics `asymDIFFUSION`. The 2017-era source forms `TRAITVARstd` only when `TRAITVAR != 0`, as `solve(sqrt(diag(TRAITVAR))) %&% TRAITVAR` when `verbose = TRUE`. OpenMx `%&%` is `t(A) %*% B %*% A`. Unlike `T0VARstd`, that formation uses `diag(diag(TRAITVAR))` and does not add `diag(c(ridging))`. The ridge is a `T0VAR` numerical hack and is not this exact map. The scalar correlation is `trait / trait = 1` after strictly positive `TRAITVAR`. Form strictly positive `trait` first, then `1 / √trait`, then `(1 / √trait) trait (1 / √trait)`. Unstandardised `TRAITVAR` is defined for a zero trait; standardised `TRAITVAR` is not. Zero `TRAITVAR` skips forming `TRAITVARstd` in the 2017-era source and fails closed here. Between-subject variance is an event-time structural quantity, so a non-event clock fails closed. `TRAITVAR` does not require stable `a < 0`. Distinct positive `trait` recover the same 1. `p_0 / p_0 = 1` is `T0VARstd` and recovers the same number and remains a distinct named quantity. `t0_b² v` is `addedT0TIPREDVAR` and is extra first-occasion TI variance, not this correlation. 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. diff --git a/crates/psychometric_core/src/error.rs b/crates/psychometric_core/src/error.rs index 4ab2695e0..5ff814f92 100644 --- a/crates/psychometric_core/src/error.rs +++ b/crates/psychometric_core/src/error.rs @@ -605,6 +605,21 @@ pub enum PsychometricError { /// `asymDIFFUSIONstd`. The continuous-diffusion ratio is not /// the correlation form of `asymDIFFUSION`. StandardisedContinuousDiffusionIsNotStandardisedAsymptoticDiffusion, + /// Driver p. 16 `DIFFUSIONstd` was requested with a non-positive + /// within-subject variance. Footnote 4 standardises continuous + /// process noise using only strictly positive `asymDIFFUSION`. + StandardisedContinuousDiffusionRequiresPositiveWithinSubjectVariance, + /// Driver p. 16 unstandardised `DIFFUSION` `q` was treated as + /// `DIFFUSIONstd`. Unstandardised `q` is defined for growing or + /// zero-diffusion processes; standardised `DIFFUSION` is not. + UnstandardisedContinuousDiffusionIsNotStandardisedContinuousDiffusion, + /// Driver p. 16 `discreteDIFFUSIONstd` `Q_Δt / (−q / (2 a))` was + /// treated as `DIFFUSIONstd`. `1 − exp(2 a Δt)` is not `−2 a`. + StandardisedDiscreteDiffusionIsNotStandardisedContinuousDiffusion, + /// Driver §7.1 trait-contaminated continuous diffusion + /// `q / (trait + p + added)` was treated as p. 16 `DIFFUSIONstd`. + /// Footnote 4 uses only `asymDIFFUSION`, not `TRAITVAR`. + TraitContaminatedContinuousDiffusionIsNotStandardisedContinuousDiffusion, /// Driver p. 16 `TIPREDVARstd` was treated as p. 16 /// `asymDIFFUSIONstd`. Equal numbers of 1 after a strictly /// positive relevant variance are still distinct named @@ -1168,6 +1183,18 @@ impl fmt::Display for PsychometricError { Self::StandardisedContinuousDiffusionIsNotStandardisedAsymptoticDiffusion => { "standardised continuous diffusion is not standardised asymptotic diffusion" } + Self::StandardisedContinuousDiffusionRequiresPositiveWithinSubjectVariance => { + "standardised continuous diffusion requires strictly positive within-subject variance" + } + Self::UnstandardisedContinuousDiffusionIsNotStandardisedContinuousDiffusion => { + "unstandardised continuous diffusion is not standardised continuous diffusion" + } + Self::StandardisedDiscreteDiffusionIsNotStandardisedContinuousDiffusion => { + "standardised discrete diffusion is not standardised continuous diffusion" + } + Self::TraitContaminatedContinuousDiffusionIsNotStandardisedContinuousDiffusion => { + "trait-contaminated continuous diffusion is not standardised continuous diffusion" + } Self::StandardisedTimeIndependentPredictorVarianceIsNotStandardisedAsymptoticDiffusion => { "standardised time-independent predictor variance is not standardised asymptotic diffusion" } @@ -1972,6 +1999,26 @@ mod tests { .to_string(), "standardised continuous diffusion is not standardised asymptotic diffusion" ); + assert_eq!( + PsychometricError::StandardisedContinuousDiffusionRequiresPositiveWithinSubjectVariance + .to_string(), + "standardised continuous diffusion requires strictly positive within-subject variance" + ); + assert_eq!( + PsychometricError::UnstandardisedContinuousDiffusionIsNotStandardisedContinuousDiffusion + .to_string(), + "unstandardised continuous diffusion is not standardised continuous diffusion" + ); + assert_eq!( + PsychometricError::StandardisedDiscreteDiffusionIsNotStandardisedContinuousDiffusion + .to_string(), + "standardised discrete diffusion is not standardised continuous diffusion" + ); + assert_eq!( + PsychometricError::TraitContaminatedContinuousDiffusionIsNotStandardisedContinuousDiffusion + .to_string(), + "trait-contaminated continuous diffusion is not standardised continuous diffusion" + ); assert_eq!( PsychometricError::StandardisedTimeIndependentPredictorVarianceIsNotStandardisedAsymptoticDiffusion .to_string(), diff --git a/crates/psychometric_core/src/event_time.rs b/crates/psychometric_core/src/event_time.rs index a29bc5c18..1d95bb587 100644 --- a/crates/psychometric_core/src/event_time.rs +++ b/crates/psychometric_core/src/event_time.rs @@ -1929,10 +1929,11 @@ pub fn refuse_standardised_asymptotic_diffusion_as_standardised_initial_latent_v /// 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 +/// correlation. This crate already exports `DIFFUSIONstd`. +/// `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 +/// currently export `TIPREDVARstd`; the refuse still names that +/// quantity. This is not a Kalman filter, not a /// matrix `expm`, not DSEM, and not ctsem estimation. /// Exact scalar p. 16 `TRAITVARstd` after strictly positive `TRAITVAR`. /// @@ -2106,9 +2107,8 @@ pub fn refuse_standardised_initial_latent_variance_as_standardised_asymptotic_di /// /// `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. +/// `a = −0.5` remain distinct named quantities. This crate already +/// exports `DIFFUSIONstd`. /// /// # Errors /// @@ -2125,6 +2125,137 @@ pub fn refuse_standardised_continuous_diffusion_as_standardised_asymptotic_diffu Err(PsychometricError::StandardisedContinuousDiffusionIsNotStandardisedAsymptoticDiffusion) } +/// Exact scalar p. 16 `DIFFUSIONstd` after strictly positive +/// `asymDIFFUSION`. +/// +/// Driver, Oud, and Voelkle (2017, p. 16; Eq. 4, p. 5; footnote 4; +/// §7.1, pp. 18–19; Table 2, p. 12; 2017-era ctsem +/// `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-30T16:03Z from +/// ) +/// print continuous-time parameters (e.g., `DRIFT`, `DIFFUSION`) +/// and, when appropriate, standardised matrices with the suffix +/// `std`. The printed example on p. 16 is `discreteDRIFTstd`, not +/// `DIFFUSIONstd`. Footnote 4: standardisations use only the +/// relevant variance, not the total. Process noise is within-subject +/// stochastic input, so that relevant variance is within-subject +/// `asymDIFFUSION` `p = −q / (2 a)`, the same footnote 4 variance +/// used for `DRIFT`. Table 2 names `DIFFUSION` `Q` the continuous +/// diffusion variance/covariance. Form strictly positive +/// `asymDIFFUSION` first, then `q / p`. In the scalar stationary +/// case that ratio equals `−2 a` and does not depend on `q` once +/// `q > 0`. Unstandardised `q` is defined for growing `a ≥ 0` and +/// for zero diffusion; standardised `DIFFUSION` is not. Zero +/// `asymDIFFUSION` has no positive SD and fails closed. Lasting `p` +/// requires stable `a < 0`. `DIFFUSION` is an event-time +/// process-dynamics quantity, so a non-event clock fails closed. +/// Distinct positive `q` recover the same `−2 a`. +/// `asymDIFFUSIONstd` `p / p = 1` recovers the same number when +/// `a = −0.5` and remains a distinct named quantity. This crate +/// already exports `asymDIFFUSIONstd`. `discreteDIFFUSIONstd` +/// `Q_Δt / p = 1 − exp(2 a Δt)` depends on the event interval and +/// is not this continuous map. This crate does not currently export +/// `discreteDIFFUSIONstd`; the refuse still names that quantity. +/// Section 7.1 warns that omitting trait variance confounds between- +/// and within-person information. `q / (trait + p + added)` uses +/// the total, not `asymDIFFUSION`, and is not `DIFFUSIONstd` when +/// `TRAITVAR` is nonzero. `TRAITVAR` is not the standardisation +/// variance. 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::StandardisedContinuousDiffusionRequiresPositiveWithinSubjectVariance`] +/// when `q = 0`, and +/// [`PsychometricError::InvalidNumericInput`] when the diffusion or +/// log-rate is non-finite, the diffusion is negative, or the mapped +/// ratio overflows. +pub fn recover_standardised_continuous_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::StandardisedContinuousDiffusionRequiresPositiveWithinSubjectVariance, + ); + } + require_finite(continuous_diffusion / stationary) +} + +/// Refuse treating unstandardised `DIFFUSION` as p. 16 +/// `DIFFUSIONstd`. +/// +/// Unstandardised `q` is defined for growing `a ≥ 0` and for a zero +/// process. Footnote 4 `DIFFUSIONstd` requires strictly positive +/// `asymDIFFUSION`. Equal numbers when `q = −2 a` remain distinct +/// named quantities. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::UnstandardisedContinuousDiffusionIsNotStandardisedContinuousDiffusion`]. +pub fn refuse_unstandardised_continuous_diffusion_as_standardised_continuous_diffusion( + unstandardised_continuous_diffusion: f64, + standardised_continuous_diffusion: f64, +) -> Result { + let _ = ( + unstandardised_continuous_diffusion, + standardised_continuous_diffusion, + ); + Err(PsychometricError::UnstandardisedContinuousDiffusionIsNotStandardisedContinuousDiffusion) +} + +/// Refuse treating p. 16 `discreteDIFFUSIONstd` as p. 16 +/// `DIFFUSIONstd`. +/// +/// `Q_Δt / p = 1 − exp(2 a Δt)` depends on the event interval. +/// `DIFFUSIONstd` `q / p = −2 a` does not. Equal numbers at a +/// particular `Δt` remain distinct named quantities. This crate +/// does not currently export `discreteDIFFUSIONstd`; the refuse +/// still names that quantity. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::StandardisedDiscreteDiffusionIsNotStandardisedContinuousDiffusion`]. +pub fn refuse_standardised_discrete_diffusion_as_standardised_continuous_diffusion( + standardised_discrete_diffusion: f64, + standardised_continuous_diffusion: f64, +) -> Result { + let _ = ( + standardised_discrete_diffusion, + standardised_continuous_diffusion, + ); + Err(PsychometricError::StandardisedDiscreteDiffusionIsNotStandardisedContinuousDiffusion) +} + +/// Refuse treating `q / (trait + p + added)` as p. 16 +/// `DIFFUSIONstd`. +/// +/// Footnote 4 measurement of process noise uses `asymDIFFUSION`, +/// not total variance. `TRAITVAR` is not the standardisation +/// variance. +/// +/// # Errors +/// +/// Always returns +/// [`PsychometricError::TraitContaminatedContinuousDiffusionIsNotStandardisedContinuousDiffusion`]. +pub fn refuse_trait_contaminated_continuous_diffusion_as_standardised_continuous_diffusion( + trait_contaminated_diffusion: f64, + standardised_continuous_diffusion: f64, +) -> Result { + let _ = ( + trait_contaminated_diffusion, + standardised_continuous_diffusion, + ); + Err(PsychometricError::TraitContaminatedContinuousDiffusionIsNotStandardisedContinuousDiffusion) +} + /// Refuse treating p. 16 `TIPREDVARstd` as p. 16 /// `asymDIFFUSIONstd`. /// @@ -6853,8 +6984,8 @@ pub(crate) fn fit_scalar_log_rate(pairs: &[(f64, f64, f64)]) -> Result 0. JSS PDF re-opened 2026-08-30T16:03Z. + let diffusion = 0.4_f64; + let log_rate = -0.25_f64; + let recovered = + recover_standardised_continuous_diffusion(diffusion, log_rate, LagClock::EventTime) + .expect("DIFFUSIONstd"); + let expected = -2.0 * log_rate; + assert!((recovered - expected).abs() < 1e-15); + let larger_q = + recover_standardised_continuous_diffusion(1.6, log_rate, LagClock::EventTime) + .expect("DIFFUSIONstd q=1.6"); + assert!((larger_q - recovered).abs() < 1e-15); + let stationary = + recover_stationary_latent_variance(diffusion, log_rate, LagClock::EventTime) + .expect("asymDIFFUSION"); + assert!((diffusion - recovered).abs() > 1e-3); + let discrete = + recover_discrete_process_noise(diffusion, log_rate, 1.0, LagClock::EventTime) + .expect("Q_Δt") + / stationary; + assert!((discrete - recovered).abs() > 1e-3); + let trait_contaminated = diffusion / (0.5 + stationary + 0.1); + assert!((trait_contaminated - recovered).abs() > 1e-3); + let equal_ratio = recover_standardised_continuous_diffusion(0.4, -0.5, LagClock::EventTime) + .expect("a=-0.5"); + assert!((equal_ratio - 1.0).abs() < 1e-15); + let asym_std = recover_standardised_asymptotic_diffusion(0.4, -0.5, LagClock::EventTime) + .expect("asymDIFFUSIONstd a=-0.5"); + assert!((equal_ratio - asym_std).abs() < 1e-15); + assert_eq!( + refuse_unstandardised_continuous_diffusion_as_standardised_continuous_diffusion( + diffusion, recovered + ), + Err( + PsychometricError::UnstandardisedContinuousDiffusionIsNotStandardisedContinuousDiffusion + ) + ); + assert_eq!( + refuse_standardised_discrete_diffusion_as_standardised_continuous_diffusion( + discrete, recovered + ), + Err( + PsychometricError::StandardisedDiscreteDiffusionIsNotStandardisedContinuousDiffusion + ) + ); + assert_eq!( + refuse_trait_contaminated_continuous_diffusion_as_standardised_continuous_diffusion( + trait_contaminated, + recovered + ), + Err( + PsychometricError::TraitContaminatedContinuousDiffusionIsNotStandardisedContinuousDiffusion + ) + ); + assert_eq!( + refuse_standardised_continuous_diffusion_as_standardised_asymptotic_diffusion( + equal_ratio, + asym_std + ), + Err( + PsychometricError::StandardisedContinuousDiffusionIsNotStandardisedAsymptoticDiffusion + ) + ); + } + + #[test] + fn standardised_continuous_diffusion_fails_closed_when_unstandardised_is_defined() { + assert_eq!( + recover_standardised_continuous_diffusion(0.0, -0.25, LagClock::EventTime), + Err( + PsychometricError::StandardisedContinuousDiffusionRequiresPositiveWithinSubjectVariance + ) + ); + assert_eq!( + recover_standardised_continuous_diffusion(0.4, -0.25, LagClock::SystemTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_standardised_continuous_diffusion(0.4, 0.25, LagClock::EventTime), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_standardised_continuous_diffusion(0.4, 0.0, LagClock::EventTime), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_standardised_continuous_diffusion(-0.4, -0.25, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_standardised_continuous_diffusion(f64::NAN, -0.25, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_standardised_continuous_diffusion(0.4, f64::NAN, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_standardised_continuous_diffusion(f64::INFINITY, -0.25, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_standardised_continuous_diffusion(f64::MAX, -1e308, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + } } diff --git a/crates/psychometric_core/src/lib.rs b/crates/psychometric_core/src/lib.rs index c081a63f6..ef6028eea 100644 --- a/crates/psychometric_core/src/lib.rs +++ b/crates/psychometric_core/src/lib.rs @@ -261,6 +261,18 @@ //! correlation; zero `MANIFESTTRAITVAR` fails closed; a non-event //! clock fails closed; `MANIFESTTRAITVAR` does not require `a < 0`; //! JSS PDF re-opened 2026-08-27T14:20Z), +//! recovers the Driver p. 16 `DIFFUSIONstd` as `q / p = −2 a` after +//! strictly positive `asymDIFFUSION` `p = −q / (2 a)` (footnote 4 +//! uses only the relevant within-subject variance, not total +//! `trait + p + added`; form `p` first, then `q / p`; the scalar +//! stationary ratio does not depend on `q` once `q > 0`; +//! unstandardised `q` is defined for growing `a ≥ 0` and for zero +//! diffusion and is not that map; `Q_Δt / p = 1 − exp(2 a Δt)` is +//! `discreteDIFFUSIONstd` and is not that map; `p / p = 1` is +//! `asymDIFFUSIONstd` and is not that map even when `a = −0.5` so +//! both equal 1; zero `q` fails closed; a non-event clock fails +//! closed; `a ≥ 0` fails closed; JSS PDF re-opened +//! 2026-08-30T16:03Z), //! and refuses //! latent-mean comparison below strong invariance. @@ -275,40 +287,30 @@ 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; +/// A heuristic that is not causal identification. +pub use causality::CausalHeuristic; /// 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; +/// One clustered predictor–outcome pair. +pub use cluster_mean::ClusteredScore; +/// Recovered within-cluster, between-cluster, and contextual OLS slopes. +pub use cluster_mean::WithinBetweenSlopes; /// 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; +/// Higher-order construct class. +pub use construct::ConstructClass; +/// Typed invariance evidence required before a latent-mean comparison. +pub use construct::LatentMeanComparisonEvidence; /// 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`. @@ -403,6 +405,8 @@ pub use event_time::recover_manifest_trait_plus_state_observed_variance; 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 `DIFFUSIONstd` `q / p = −2 a` after strictly positive `asymDIFFUSION`. +pub use event_time::recover_standardised_continuous_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`. @@ -417,6 +421,16 @@ pub use event_time::recover_standardised_manifest_mean; pub use event_time::recover_standardised_manifest_trait_variance; /// Exact scalar p. 16 `MANIFESTVARstd` `θ/...` after strictly positive `MANIFESTVAR`. pub use event_time::recover_standardised_manifest_variance; +/// 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; /// Exact scalar p. 16 `TRAITVARstd` `trait / trait = 1` after strictly positive `TRAITVAR`. pub use event_time::recover_standardised_trait_variance; @@ -624,6 +638,8 @@ pub use event_time::refuse_standardised_continuous_diffusion_as_standardised_asy 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 `discreteDIFFUSIONstd` as `DIFFUSIONstd`. +pub use event_time::refuse_standardised_discrete_diffusion_as_standardised_continuous_diffusion; /// 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`. @@ -715,6 +731,8 @@ pub use event_time::refuse_time_independent_effect_as_time_varying_discrete_effe 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 `q / (trait + p + added)` as `DIFFUSIONstd`. +pub use event_time::refuse_trait_contaminated_continuous_diffusion_as_standardised_continuous_diffusion; /// 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 `κ / √(trait + p + added)` as `CINTstd`. @@ -729,6 +747,8 @@ pub use event_time::refuse_unmatched_time_varying_predictor_interval; 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 `DIFFUSION` as `DIFFUSIONstd`. +pub use event_time::refuse_unstandardised_continuous_diffusion_as_standardised_continuous_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`. @@ -748,35 +768,35 @@ pub use event_time::refuse_unstandardised_manifest_variance_as_standardised_mani pub use event_time::refuse_unstandardised_trait_variance_as_standardised_trait_variance; /// Refuse treating `μ_0 / √asymDIFFUSION` as `T0MEANSstd`. pub use event_time::refuse_within_subject_scaled_initial_latent_mean_as_standardised_initial_latent_mean; -/// 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; +/// Indicator coordinate kind. +pub use indicator::IndicatorKind; +/// 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; /// 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; +/// Ordinary least-squares intercept, slope, and residual variance. +pub use loading::OrdinaryLeastSquaresFit; /// 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; +/// Rubin-combined OLS loading and total variance. +pub use rubin_total::RubinCombinedLoading; 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 1c0027f44..45d5faea9 100644 --- a/crates/psychometric_core/tests/multilevel_event_time_recovery_contract.rs +++ b/crates/psychometric_core/tests/multilevel_event_time_recovery_contract.rs @@ -2,10 +2,8 @@ #![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, + 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, @@ -35,18 +33,18 @@ 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_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_standardised_manifest_trait_variance, recover_standardised_manifest_variance, - recover_standardised_trait_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, + recover_standardised_asymptotic_diffusion, recover_standardised_continuous_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_standardised_manifest_trait_variance, + recover_standardised_manifest_variance, recover_standardised_trait_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, @@ -120,6 +118,8 @@ use psychometric_core::{ refuse_observed_variance_as_standardised_manifest_variance, refuse_pooled_discrete_lag_across_unequal_intervals, refuse_process_noise_as_unconditional_variance, + refuse_standardised_continuous_diffusion_as_standardised_asymptotic_diffusion, + refuse_standardised_discrete_diffusion_as_standardised_continuous_diffusion, refuse_standardised_initial_latent_variance_as_standardised_trait_variance, refuse_standardised_manifest_trait_variance_as_standardised_manifest_variance, refuse_standardised_trait_variance_as_standardised_manifest_trait_variance, @@ -159,12 +159,16 @@ use psychometric_core::{ 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_contaminated_continuous_diffusion_as_standardised_continuous_diffusion, 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, + refuse_unstandardised_continuous_diffusion_as_standardised_continuous_diffusion, refuse_unstandardised_manifest_trait_variance_as_standardised_manifest_trait_variance, refuse_unstandardised_manifest_variance_as_standardised_manifest_variance, - refuse_unstandardised_trait_variance_as_standardised_trait_variance, + refuse_unstandardised_trait_variance_as_standardised_trait_variance, ClusteredEventScore, + ClusteredScore, EventOccasion, IndicatorKind, LagClock, LaggedWithinResidual, + PsychometricError, }; fn rmse(truth: &[f64], recovered: &[f64]) -> f64 { @@ -2239,8 +2243,8 @@ fn discrete_observed_mean_with_initial_time_independent_predictor_is_not_impulse } #[test] -fn discrete_observed_mean_with_initial_time_independent_predictor_refuses_evolved_process_impulse_and_carry() - { +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; @@ -2394,8 +2398,8 @@ fn discrete_observed_mean_with_initial_time_independent_predictor_zero_loading_i } #[test] -fn discrete_observed_mean_with_initial_time_independent_predictor_refuses_overflow_and_non_event_clocks() - { +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, @@ -3326,8 +3330,8 @@ fn discrete_observed_mean_with_initial_time_dependent_predictor_is_not_impulse_o #[test] #[allow(clippy::too_many_lines)] -fn discrete_observed_mean_with_initial_time_dependent_predictor_refuses_evolved_process_impulse_and_carry() - { +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; @@ -3500,8 +3504,8 @@ fn discrete_observed_mean_with_initial_time_dependent_predictor_zero_loading_is_ } #[test] -fn discrete_observed_mean_with_initial_time_dependent_predictor_refuses_overflow_and_non_event_clocks() - { +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, @@ -6158,6 +6162,118 @@ fn standardised_asymptotic_diffusion_refuses_non_event_clocks_and_does_not_keep_ ); } +#[test] +fn standardised_continuous_diffusion_recovers_driver_page_sixteen_minus_two_a() { + let diffusion = 0.4_f64; + let log_rate = -0.25_f64; + let recovered = + recover_standardised_continuous_diffusion(diffusion, log_rate, LagClock::EventTime) + .expect("DIFFUSIONstd"); + let expected = -2.0 * log_rate; + let recovered_error = (recovered - expected).abs(); + assert!( + recovered_error < 1e-15, + "Driver et al. (2017, p. 16 DIFFUSIONstd): RMSE {recovered_error} for q / p = −2 a" + ); + let larger_q = recover_standardised_continuous_diffusion(1.6, log_rate, LagClock::EventTime) + .expect("DIFFUSIONstd q=1.6"); + assert!( + (larger_q - recovered).abs() < 1e-15, + "Driver et al. (2017, p. 16): distinct positive DIFFUSION recover the same DIFFUSIONstd" + ); + let unstandardised_error = (diffusion - expected).abs(); + assert!( + unstandardised_error > recovered_error, + "Driver et al. (2017, p. 16): unstandardised DIFFUSION RMSE {unstandardised_error} must exceed DIFFUSIONstd RMSE {recovered_error}" + ); + let stationary = + recover_stationary_latent_variance(diffusion, log_rate, LagClock::EventTime).expect("p"); + let discrete = recover_discrete_process_noise(diffusion, log_rate, 1.0, LagClock::EventTime) + .expect("Q_Δt") + / stationary; + let discrete_error = (discrete - expected).abs(); + assert!( + discrete_error > recovered_error, + "Driver et al. (2017, p. 16): discreteDIFFUSIONstd RMSE {discrete_error} must exceed DIFFUSIONstd RMSE {recovered_error}" + ); + let trait_contaminated = diffusion / (0.5 + stationary + 0.1); + let trait_error = (trait_contaminated - expected).abs(); + assert!( + trait_error > recovered_error, + "Driver et al. (2017, §7.1): trait-contaminated RMSE {trait_error} must exceed DIFFUSIONstd RMSE {recovered_error}" + ); + let equal_ratio = + recover_standardised_continuous_diffusion(0.4, -0.5, LagClock::EventTime).expect("a=-0.5"); + let asym_std = recover_standardised_asymptotic_diffusion(0.4, -0.5, LagClock::EventTime) + .expect("asymDIFFUSIONstd a=-0.5"); + assert!( + (equal_ratio - asym_std).abs() < 1e-15, + "Driver et al. (2017, p. 16): DIFFUSIONstd equals 1 when a = −0.5" + ); + assert_eq!( + refuse_unstandardised_continuous_diffusion_as_standardised_continuous_diffusion( + diffusion, recovered + ), + Err(PsychometricError::UnstandardisedContinuousDiffusionIsNotStandardisedContinuousDiffusion) + ); + assert_eq!( + refuse_standardised_discrete_diffusion_as_standardised_continuous_diffusion( + discrete, recovered + ), + Err(PsychometricError::StandardisedDiscreteDiffusionIsNotStandardisedContinuousDiffusion) + ); + assert_eq!( + refuse_trait_contaminated_continuous_diffusion_as_standardised_continuous_diffusion( + trait_contaminated, + recovered + ), + Err( + PsychometricError::TraitContaminatedContinuousDiffusionIsNotStandardisedContinuousDiffusion + ) + ); + assert_eq!( + refuse_standardised_continuous_diffusion_as_standardised_asymptotic_diffusion( + equal_ratio, + asym_std + ), + Err(PsychometricError::StandardisedContinuousDiffusionIsNotStandardisedAsymptoticDiffusion) + ); +} + +#[test] +fn standardised_continuous_diffusion_refuses_non_event_clocks_and_does_not_keep_zero_q() { + assert_eq!( + recover_standardised_continuous_diffusion(0.4, -0.25, LagClock::AssertionTime), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_standardised_continuous_diffusion(0.4, -0.25, LagClock::KnowledgeCutoff), + Err(PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_standardised_continuous_diffusion(0.0, -0.25, LagClock::EventTime), + Err( + PsychometricError::StandardisedContinuousDiffusionRequiresPositiveWithinSubjectVariance + ) + ); + assert_eq!( + recover_standardised_continuous_diffusion(0.4, 0.25, LagClock::EventTime), + Err(PsychometricError::StationaryVarianceRequiresStableDrift) + ); + assert_eq!( + recover_standardised_continuous_diffusion(-0.4, -0.25, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_standardised_continuous_diffusion(f64::NAN, -0.25, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_standardised_continuous_diffusion(f64::INFINITY, -0.25, LagClock::EventTime), + Err(PsychometricError::InvalidNumericInput) + ); +} + #[test] fn standardised_manifest_trait_variance_recovers_driver_table_two_correlation() { let manifest_trait = 1.6_f64; @@ -6540,3 +6656,18 @@ fn manifest_variance_std_clock_path_is_runtime_opaque() { Err(PsychometricError::EventTimeRequired) ); } + +#[test] +fn continuous_diffusion_std_clock_path_is_runtime_opaque() { + let clocks = [ + LagClock::SystemTime, + LagClock::DocumentTime, + LagClock::AssertionTime, + ]; + let non_event_index = std::process::id() as usize % clocks.len(); + let non_event = clocks[non_event_index]; + assert_eq!( + recover_standardised_continuous_diffusion(0.4, -0.25, non_event), + Err(PsychometricError::EventTimeRequired) + ); +} diff --git a/crates/psychometric_core/tests/scientific_claim_boundary_contract.rs b/crates/psychometric_core/tests/scientific_claim_boundary_contract.rs index 6ccf7f38b..0eb0159fe 100644 --- a/crates/psychometric_core/tests/scientific_claim_boundary_contract.rs +++ b/crates/psychometric_core/tests/scientific_claim_boundary_contract.rs @@ -1,7 +1,6 @@ //! 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, @@ -30,18 +29,18 @@ 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_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_standardised_manifest_trait_variance, recover_standardised_manifest_variance, - recover_standardised_trait_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, + recover_standardised_asymptotic_diffusion, recover_standardised_continuous_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_standardised_manifest_trait_variance, + recover_standardised_manifest_variance, recover_standardised_trait_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, @@ -123,6 +122,7 @@ use psychometric_core::{ 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_discrete_diffusion_as_standardised_continuous_diffusion, 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, @@ -167,11 +167,13 @@ use psychometric_core::{ 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_contaminated_continuous_diffusion_as_standardised_continuous_diffusion, refuse_trait_plus_state_lagged_covariance_as_stationary_lagged_latent_covariance, 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_diffusion_as_standardised_continuous_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, @@ -181,6 +183,7 @@ use psychometric_core::{ refuse_unstandardised_manifest_variance_as_standardised_manifest_variance, refuse_unstandardised_trait_variance_as_standardised_trait_variance, refuse_within_subject_scaled_initial_latent_mean_as_standardised_initial_latent_mean, + ClusteredEventScore, ClusteredScore, IndicatorKind, LagClock, LaggedWithinResidual, }; #[test] @@ -3389,6 +3392,99 @@ fn standardised_asymptotic_diffusion_is_not_unstandardised_t0var_or_diffusion_ra ); } +#[test] +fn standardised_continuous_diffusion_is_not_unstandardised_discrete_or_trait_contaminated() { + let diffusion = 0.4_f64; + let log_rate = -0.25_f64; + let recovered = + recover_standardised_continuous_diffusion(diffusion, log_rate, LagClock::EventTime) + .expect("DIFFUSIONstd"); + let expected = -2.0 * log_rate; + assert!( + (recovered - expected).abs() < 1e-15, + "Driver et al. (2017, p. 16 / footnote 4): DIFFUSIONstd is q / p = −2 a" + ); + let larger_q = recover_standardised_continuous_diffusion(1.6, log_rate, LagClock::EventTime) + .expect("DIFFUSIONstd q=1.6"); + assert!( + (larger_q - recovered).abs() < 1e-15, + "Driver et al. (2017, p. 16): distinct positive DIFFUSION recover the same DIFFUSIONstd" + ); + assert!( + (recovered - diffusion).abs() > 1e-3, + "Driver et al. (2017, p. 16): unstandardised DIFFUSION is not DIFFUSIONstd" + ); + let stationary = + recover_stationary_latent_variance(diffusion, log_rate, LagClock::EventTime).expect("p"); + let discrete = recover_discrete_process_noise(diffusion, log_rate, 1.0, LagClock::EventTime) + .expect("Q_Δt") + / stationary; + assert!( + (discrete - recovered).abs() > 1e-3, + "Driver et al. (2017, p. 16): discreteDIFFUSIONstd is not DIFFUSIONstd" + ); + let trait_contaminated = diffusion / (0.5 + stationary + 0.1); + assert!( + (trait_contaminated - recovered).abs() > 1e-3, + "Driver et al. (2017, §7.1): q / (trait + p + added) is not DIFFUSIONstd" + ); + let equal_ratio = + recover_standardised_continuous_diffusion(0.4, -0.5, LagClock::EventTime).expect("a=-0.5"); + let asym_std = recover_standardised_asymptotic_diffusion(0.4, -0.5, LagClock::EventTime) + .expect("asymDIFFUSIONstd a=-0.5"); + assert!( + (equal_ratio - asym_std).abs() < 1e-15, + "Driver et al. (2017, p. 16): equal 1 when a = −0.5 remains a distinct named quantity" + ); + assert_eq!( + refuse_unstandardised_continuous_diffusion_as_standardised_continuous_diffusion( + diffusion, recovered + ), + Err( + psychometric_core::PsychometricError::UnstandardisedContinuousDiffusionIsNotStandardisedContinuousDiffusion + ) + ); + assert_eq!( + refuse_standardised_discrete_diffusion_as_standardised_continuous_diffusion( + discrete, recovered + ), + Err( + psychometric_core::PsychometricError::StandardisedDiscreteDiffusionIsNotStandardisedContinuousDiffusion + ) + ); + assert_eq!( + refuse_trait_contaminated_continuous_diffusion_as_standardised_continuous_diffusion( + trait_contaminated, + recovered + ), + Err( + psychometric_core::PsychometricError::TraitContaminatedContinuousDiffusionIsNotStandardisedContinuousDiffusion + ) + ); + assert_eq!( + refuse_standardised_continuous_diffusion_as_standardised_asymptotic_diffusion( + equal_ratio, asym_std + ), + Err( + psychometric_core::PsychometricError::StandardisedContinuousDiffusionIsNotStandardisedAsymptoticDiffusion + ) + ); + assert_eq!( + recover_standardised_continuous_diffusion(0.0, log_rate, LagClock::EventTime), + Err( + psychometric_core::PsychometricError::StandardisedContinuousDiffusionRequiresPositiveWithinSubjectVariance + ) + ); + assert_eq!( + recover_standardised_continuous_diffusion(diffusion, log_rate, LagClock::DocumentTime), + Err(psychometric_core::PsychometricError::EventTimeRequired) + ); + assert_eq!( + recover_standardised_continuous_diffusion(diffusion, 0.25, LagClock::EventTime), + Err(psychometric_core::PsychometricError::StationaryVarianceRequiresStableDrift) + ); +} + #[test] fn standardised_manifest_trait_variance_is_not_unstandardised_traitstd_or_measurement_error() { let manifest_trait = 1.6_f64; diff --git a/docs/adr/0005-posterior-esem-dsem.md b/docs/adr/0005-posterior-esem-dsem.md index ee1e6cf0d..59d18be66 100644 --- a/docs/adr/0005-posterior-esem-dsem.md +++ b/docs/adr/0005-posterior-esem-dsem.md @@ -35,6 +35,7 @@ The executable standardised-asymptotic-intercept slice recovers Driver et al. (2 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. +The executable standardised-continuous-diffusion slice recovers Driver et al. (2017, p. 16 `DIFFUSIONstd`) as `q / p = −2 a` after strictly positive `asymDIFFUSION` `p = −q / (2 a)` (footnote 4; Eq. 4; Table 2; 2017-era `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-30T16:03Z). Unstandardised `q` is defined for growing `a ≥ 0` and for zero diffusion and is not that map. `Q_Δt / p = 1 − exp(2 a Δt)` is `discreteDIFFUSIONstd` and is not this continuous map. `p / p = 1` is `asymDIFFUSIONstd` and is not `DIFFUSIONstd` even when `a = −0.5` so both equal 1. `q / (trait + p + added)` uses total variance and is not the residual map. Zero `q` and `a ≥ 0` fail closed. This is not ctsem estimation. The executable standardised-manifest-trait-variance slice recovers Driver et al. (2017, p. 16 `MANIFESTTRAITVARstd`) as `ψ / ψ = 1` after strictly positive `MANIFESTTRAITVAR` (Table 2 `Ψ_τ`; §7.1, p. 19; footnote 4; 2017-era `summary.ctsemFit.R` `solve(sqrt(diag(MANIFESTTRAITVAR) + ridging)) %&% MANIFESTTRAITVAR`; JSS PDF re-opened 2026-08-27T14:20Z). Unstandardised `ψ` is defined for a zero manifest trait and is not that map. `trait / trait = 1` is the named `TRAITVARstd` process-level correlation and is not `MANIFESTTRAITVARstd` even when both equal 1. `θ` is `MANIFESTVAR` and is not that correlation. `MANIFESTTRAITVAR` does not require `a < 0`. This is not ctsem estimation. The executable standardised-manifest-variance slice recovers Driver et al. (2017, p. 16 `MANIFESTVARstd`) as `θ / θ = 1` after strictly positive `MANIFESTVAR` (Table 2 measurement-error Cholesky; Eq. 5 `ε ~ N(0, Θ)`; footnote 4; 2017-era `summary.ctsemFit.R` `solve(sqrt(diag(MANIFESTVAR) + ridging)) %&% MANIFESTVAR`; default ridge 0; JSS PDF re-opened 2026-08-27T14:25Z). Unstandardised `θ` is defined for a zero residual and is not that map. `ψ / ψ = 1` is `MANIFESTTRAITVARstd` and is not `MANIFESTVARstd` even when both equal 1. `MANIFESTVAR` does not require `a < 0`. diff --git a/docs/research/multilevel-event-time-recovery.md b/docs/research/multilevel-event-time-recovery.md index 3701dcb4b..98de0f94c 100644 --- a/docs/research/multilevel-event-time-recovery.md +++ b/docs/research/multilevel-event-time-recovery.md @@ -62,7 +62,7 @@ This slice stays inside `psychometric_core`. It does not add a second invariance 56. refuse treating unstandardised `discreteDRIFT` `e^{a Δt}` as `discreteDRIFTstd`, refuse treating the §7.1 trait-plus-state autocorrelation `(trait + e^{a Δt} p + added) / (trait + p + added)` as `discreteDRIFTstd`, and refuse treating `TRAITVAR` as the footnote 4 standardisation variance; 57. recover the exact scalar p. 16 `discreteDIFFUSIONstd` `Q_Δt / (−q / (2 a))` after forming strictly positive `asymDIFFUSION` `−q / (2 a)` (Driver et al., 2017, p. 16; Eq. 3–4, pp. 4–5; footnote 4; §7.1, pp. 18–19; JSS PDF re-opened 2026-08-23T13:06Z; form the within-subject variance first, then `Q_Δt`, then the ratio; scalar stationary map is `1 − exp(2 a Δt)`; `a ≥ 0` and `q = 0` fail closed); 58. refuse treating unstandardised `discreteDIFFUSION` `Q_Δt` as `discreteDIFFUSIONstd`, refuse treating the continuous standardisation `q / (−q / (2 a)) = −2 a` as `discreteDIFFUSIONstd`, refuse treating `Q_Δt / (trait + p + added)` as `discreteDIFFUSIONstd`, and refuse treating `TRAITVAR` as the footnote 4 standardisation variance; -59. recover the exact scalar p. 16 `DIFFUSIONstd` `q / (−q / (2 a)) = −2 a` after forming strictly positive `asymDIFFUSION` `−q / (2 a)` (Driver et al., 2017, p. 16; Eq. 4, p. 5; footnote 4; §7.1, pp. 18–19; JSS PDF re-opened 2026-08-23T13:20Z; form the within-subject variance first, then `q / p`; scalar stationary map is `−2 a` and does not depend on `q` once `q > 0`; `a ≥ 0` and `q = 0` fail closed); +59. recover the exact scalar p. 16 `DIFFUSIONstd` `q / (−q / (2 a)) = −2 a` after forming strictly positive `asymDIFFUSION` `−q / (2 a)` (Driver et al., 2017, p. 16; Eq. 4, p. 5; footnote 4; §7.1, pp. 18–19; Table 2, p. 12; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-30T16:03Z; form the within-subject variance first, then `q / p`; scalar stationary map is `−2 a` and does not depend on `q` once `q > 0`; `a ≥ 0` and `q = 0` fail closed; executable on current main); 60. refuse treating unstandardised `DIFFUSION` `q` as `DIFFUSIONstd`, refuse treating the discrete standardisation `Q_Δt / (−q / (2 a)) = 1 − exp(2 a Δt)` as `DIFFUSIONstd`, refuse treating `q / (trait + p + added)` as `DIFFUSIONstd`, and refuse treating `TRAITVAR` as the footnote 4 standardisation variance; 61. recover the exact scalar p. 16 `DRIFTstd` after forming strictly positive `asymDIFFUSION` `−q / (2 a)` (Driver et al., 2017, p. 16; Eq. 1, p. 4; footnote 4; §7.1, pp. 18–19; JSS PDF re-opened 2026-08-23T13:28Z; form the within-subject variance first; scalar stationary SD ratio is 1 so the standardised auto-effect equals `a` numerically; those remain distinct named quantities; `a ≥ 0` and `q = 0` fail closed); 62. refuse treating unstandardised `DRIFT` `a` as `DRIFTstd`, refuse treating the discrete standardisation `e^{a Δt}` as `DRIFTstd`, refuse treating `a p / (trait + p + added)` as `DRIFTstd`, and refuse treating `TRAITVAR` as the footnote 4 standardisation variance; @@ -240,7 +240,7 @@ The Voelkle et al. (2012) ZORA accepted manuscript was re-opened 2026-08-18T21:0 - already-centered irregular residuals recover a known drift at machine-scale RMSE, and that RMSE is smaller than CWC of the corresponding raw autoregressive series (Curran & Bauer, 2011, pp. 607–608); - a singleton cluster is skipped; two singleton clusters yield an empty pair list and fail closed; - overflowing CWC residuals, overflowing contextual subtraction, later-only residual overflow, non-finite intervals, Newton overflow / start-skip / deriv-INF, and Pearson empty/mismatch paths fail closed. -- Driver et al. (2017, p. 16 `DIFFUSIONstd`; Eq. 4; footnote 4; JSS PDF re-opened 2026-08-23T13:20Z) recovers a known continuous standardisation \(q/(-q/(2a))=-2a\) at machine-scale RMSE, and that RMSE is smaller than treating unstandardised \(q\), discrete \(Q_{\Delta t}/p\), or \(q/(\mathrm{trait}+p+\mathrm{added})\) as `DIFFUSIONstd`; distinct positive \(q\) recover the same \(-2a\); \(q=0\) and \(a\ge 0\) fail closed; a non-event clock and an overflowing ratio fail closed. +- Driver et al. (2017, p. 16 `DIFFUSIONstd`; Eq. 4; footnote 4; JSS PDF re-opened 2026-08-30T16:03Z) recovers a known continuous standardisation \(q/(-q/(2a))=-2a\) at machine-scale RMSE, and that RMSE is smaller than treating unstandardised \(q\), discrete \(Q_{\Delta t}/p\), or \(q/(\mathrm{trait}+p+\mathrm{added})\) as `DIFFUSIONstd`; distinct positive \(q\) recover the same \(-2a\); \(q=0\) and \(a\ge 0\) fail closed; a non-event clock and an overflowing ratio fail closed. - Driver et al. (2017, p. 16 `DRIFTstd`; Eq. 1; footnote 4; JSS PDF re-opened 2026-08-23T13:28Z) recovers a known continuous auto-effect \(a\) after strictly positive `asymDIFFUSION` at machine-scale RMSE, and that RMSE is smaller than treating unstandardised \(a\), discrete \(e^{a\Delta t}\), or \(ap/(\mathrm{trait}+p+\mathrm{added})\) as `DRIFTstd`; distinct positive \(q\) recover the same \(a\); \(q=0\) and \(a\ge 0\) fail closed; a non-event clock fails closed. - Driver et al. (2017, p. 16 `asymTIPREDEFFECTstd`; §7.2; Eq. 3; footnote 4; JSS PDF re-opened 2026-08-23T14:25Z) recovers a known standardised asymptotic TI effect \((-B/a)\cdot\sqrt{v}/\sqrt{-q/(2a)}\) at machine-scale RMSE, and that RMSE is smaller than treating unstandardised \(-B/a\), finite-interval \(A^{-1}[e^{A\Delta t}-I]B\cdot\sqrt{v}/\sqrt{p}\), or \((-B/a)\cdot\sqrt{v}/\sqrt{\mathrm{trait}+p+\mathrm{added}}\) as `asymTIPREDEFFECTstd`; a larger positive \(q\) yields a smaller \(|\mathrm{std}|\); a zero coefficient with positive \(v\) and \(p\) is exactly zero; \(q=0\), \(v=0\), and \(a\ge 0\) fail closed; a non-event clock and an overflowing product fail closed. - Driver et al. (2017, p. 16 `TIPREDEFFECTstd`; §7.2; Eq. 3; footnote 4; JSS PDF re-opened 2026-08-23T16:21Z) recovers a known standardised continuous TI effect \(B\cdot\sqrt{v}/\sqrt{-q/(2a)}\) at machine-scale RMSE, and that RMSE is smaller than treating unstandardised \(B\), asymptotic \((-B/a)\cdot\sqrt{v}/\sqrt{p}\), finite-interval \(A^{-1}[e^{A\Delta t}-I]B\cdot\sqrt{v}/\sqrt{p}\), or \(B\cdot\sqrt{v}/\sqrt{\mathrm{trait}+p+\mathrm{added}}\) as `TIPREDEFFECTstd`; a larger positive \(q\) yields a smaller \(|\mathrm{std}|\); a zero coefficient with positive \(v\) and \(p\) is exactly zero; \(q=0\), \(v=0\), and \(a\ge 0\) fail closed; a non-event clock and an overflowing product fail closed.