Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang

## [Unreleased]

- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Table 2, p. 12 `TRAITVAR`; §7.1, pp. 18–19; 2017-era ctsem `ctFit.R` / `summary.ctsemFit.R` / `ctGenerate.R`; JSS PDF re-opened 2026-08-30T18:50Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar 2017-era `T0TRAITVAR` on current main (register items 106–107). 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). `T0TRAITEFFECT` is not in Table 2 or Table 3. The 2017-era `ctFit.R` (ctsem 2.5.0, lines 693–696) places it as the OpenMx `A` path from the trait latent onto the process at `T0`. When `'T0TRAITEFFECT'` is in `stationary` (lines 1494–1508) that path is the identity. The 2017-era `summary.ctsemFit.R` (lines 322–331) comments `T0TRAITVAR <- T0TRAITEFFECT %*% TRAITVAR %*% t(T0TRAITEFFECT)` with `#is this valid?` — the same comment as `addedT0TIPREDVAR`. The scalar analog of that quadratic form is `t0_trait² · trait`. Form `t0_trait` first, then square, then multiply by `trait`. A zero coefficient or zero trait is exactly zero. `trait < 0` fails closed. `T0` is an event-time occasion, so a non-event clock fails closed. Free `T0TRAITEFFECT` does not require stable `a < 0`. Stationary `T0TRAITEFFECT = I` makes `T0TRAITVAR` equal `TRAITVAR` numerically; those remain distinct named quantities. `t0_b² v` is `addedT0TIPREDVAR` and is not this trait extra. `(B / a)² v` is `addedTIPREDVAR` and is not this first-occasion map. Free `T0VAR` `p_0` is not this extra. `ctGenerate.R` (lines 113–116) draws traits from `N(0, I)` and adds `T0TRAITEFFECT %*% traits` to `T0MEANS`, so the generate extra at `T0` is `t0_trait²`, not `t0_trait² · trait`. This crate does not currently export `recover_initial_trait_effect`; form the quadratic directly. 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.

- `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, 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.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

62 changes: 62 additions & 0 deletions crates/psychometric_core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,28 @@ pub enum PsychometricError {
/// `MANIFESTVARstd`. `λ² Var(η) + θ` is `Var(y)`, not the
/// correlation form of `Θ`.
ObservedVarianceIsNotStandardisedManifestVariance,
/// Driver 2017-era `T0TRAITVAR` `t0_trait² · trait` was treated
/// as Table 2 `TRAITVAR`. When `T0TRAITEFFECT` is the
/// stationary identity those numbers equal and remain distinct
/// named quantities.
InitialTraitVarianceIsNotTraitVariance,
/// Driver 2017-era `T0TRAITVAR` `t0_trait² · trait` was treated
/// as 2017-era `addedT0TIPREDVAR` `t0_b² v`. Trait loadings
/// are not time-independent predictor effects.
InitialTraitVarianceIsNotInitialTimeIndependentVariance,
/// Driver 2017-era `T0TRAITVAR` `t0_trait² · trait` was treated
/// as §7.2 `addedTIPREDVAR` `(B / a)² v`. First-occasion trait
/// extra is not asymptotic TI extra.
InitialTraitVarianceIsNotAsymptoticTimeIndependentVariance,
/// Driver 2017-era `T0TRAITVAR` `t0_trait² · trait` was treated
/// as free `T0VAR` `p_0`. Trait extra at `T0` is not the
/// first-occasion state.
InitialTraitVarianceIsNotInitialLatentVariance,
/// Driver 2017-era `T0TRAITVAR` `t0_trait² · trait` was treated
/// as the ctGenerate unit-normal extra `t0_trait²`. Generate
/// draws traits from `N(0, I)` at `T0`; SEM path-tracing uses
/// `TRAITVAR`.
InitialTraitVarianceIsNotUnitTraitGenerateVariance,
}

impl fmt::Display for PsychometricError {
Expand Down Expand Up @@ -1235,6 +1257,21 @@ impl fmt::Display for PsychometricError {
Self::ObservedVarianceIsNotStandardisedManifestVariance => {
"observed-indicator variance is not standardised measurement-error variance"
}
Self::InitialTraitVarianceIsNotTraitVariance => {
"initial trait variance is not trait variance"
}
Self::InitialTraitVarianceIsNotInitialTimeIndependentVariance => {
"initial trait variance is not initial time-independent predictor variance"
}
Self::InitialTraitVarianceIsNotAsymptoticTimeIndependentVariance => {
"initial trait variance is not asymptotic time-independent predictor variance"
}
Self::InitialTraitVarianceIsNotInitialLatentVariance => {
"initial trait variance is not initial latent variance"
}
Self::InitialTraitVarianceIsNotUnitTraitGenerateVariance => {
"initial trait variance is not unit-trait generate variance"
}
};
formatter.write_str(message)
}
Expand Down Expand Up @@ -2073,4 +2110,29 @@ mod tests {
"measurement error is not standardised manifest-trait variance"
);
}

#[test]
fn initial_trait_variance_boundary_messages_are_stable() {
assert_eq!(
PsychometricError::InitialTraitVarianceIsNotTraitVariance.to_string(),
"initial trait variance is not trait variance"
);
assert_eq!(
PsychometricError::InitialTraitVarianceIsNotInitialTimeIndependentVariance.to_string(),
"initial trait variance is not initial time-independent predictor variance"
);
assert_eq!(
PsychometricError::InitialTraitVarianceIsNotAsymptoticTimeIndependentVariance
.to_string(),
"initial trait variance is not asymptotic time-independent predictor variance"
);
assert_eq!(
PsychometricError::InitialTraitVarianceIsNotInitialLatentVariance.to_string(),
"initial trait variance is not initial latent variance"
);
assert_eq!(
PsychometricError::InitialTraitVarianceIsNotUnitTraitGenerateVariance.to_string(),
"initial trait variance is not unit-trait generate variance"
);
}
}
156 changes: 156 additions & 0 deletions crates/psychometric_core/src/event_time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@
//! accounted for by those predictors. The scalar map is `(B / a)² v`
//! for predictor variance `v ≥ 0`. That variance is not `TRAITVAR`,
//! not `asymDIFFUSION`, and not the expected total change `-B z / a`.
//! The 2017-era `summary.ctsemFit.R` (ctsem 2.5.0, lines 322–331)
//! comments the SEM path-tracing
//! `T0TRAITVAR <- T0TRAITEFFECT %*% TRAITVAR %*% t(T0TRAITEFFECT)`
//! with `#is this valid?`. The scalar analog is `t0_trait² · trait`.
//! That first-occasion trait extra is not `TRAITVAR` even when
//! stationary `T0TRAITEFFECT = I` so they equal, not `addedT0TIPREDVAR`
//! `t0_b² v`, not `addedTIPREDVAR` `(B / a)² v`, not free `T0VAR`,
//! and not the ctGenerate unit-normal extra `t0_trait²`.
//! Table 2 (p. 12) names `asymCINT` the asymptotic (`Δt = ∞`)
//! expected change in processes for a 1 unit change in intercept
//! (`CINT`). Equation 3 maps a finite event interval as
Expand Down Expand Up @@ -6795,6 +6803,154 @@ pub fn recover_irregular_centered_residual_log_rate(
require_finite(sum / count)
}

/// Exact scalar 2017-era `T0TRAITVAR`.
///
/// Driver, Oud, and Voelkle (2017, Table 2, p. 12 `TRAITVAR`; §7.1,
/// pp. 18–19; 2017-era ctsem `ctFit.R` / `summary.ctsemFit.R` /
/// `ctGenerate.R`; JSS PDF re-opened 2026-08-30T18:50Z from
/// <https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104>)
/// name `TRAITVAR` `φ_ξ` the latent trait variance. `T0TRAITEFFECT`
/// is not in Table 2 or Table 3. The 2017-era `ctFit.R` (ctsem 2.5.0,
/// lines 693–696) places it as the `OpenMx` `A` path from the trait
/// latent onto the process at `T0`. When `'T0TRAITEFFECT'` is in
/// `stationary` (lines 1494–1508) that path is the identity. The
/// 2017-era `summary.ctsemFit.R` (lines 322–331) comments
/// `T0TRAITVAR <- T0TRAITEFFECT %*% TRAITVAR %*% t(T0TRAITEFFECT)`
/// with `#is this valid?` — the same comment as `addedT0TIPREDVAR`.
/// The scalar analog of that quadratic form is `t0_trait² · trait`.
/// Form `t0_trait` first, then square, then multiply by `trait`. A
/// zero coefficient or zero trait is exactly zero. `trait < 0` fails
/// closed. `T0` is an event-time occasion, so a non-event clock fails
/// closed. Free `T0TRAITEFFECT` does not require stable `a < 0`.
/// Stationary `T0TRAITEFFECT = I` makes `T0TRAITVAR` equal `TRAITVAR`
/// numerically; those remain distinct named quantities. `t0_b² v` is
/// `addedT0TIPREDVAR` and is not this trait extra. `(B / a)² v` is
/// `addedTIPREDVAR` and is not this first-occasion map. Free `T0VAR`
/// `p_0` is not this extra. `ctGenerate.R` (lines 113–116) draws
/// traits from `N(0, I)` and adds `T0TRAITEFFECT %*% traits` to
/// `T0MEANS`, so the generate extra at `T0` is `t0_trait²`, not
/// `t0_trait² · trait`. This crate does not currently export
/// `recover_initial_trait_effect`; form the quadratic directly. 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 and
/// [`PsychometricError::InvalidNumericInput`] when an input is
/// non-finite, the trait variance is negative, or the product
/// overflows.
pub fn recover_initial_trait_variance(
initial_trait_effect: f64,
trait_variance: f64,
clock: LagClock,
) -> Result<f64, PsychometricError> {
if !clock.admits_structural_lag() {
return Err(PsychometricError::EventTimeRequired);
}
if !initial_trait_effect.is_finite() || !trait_variance.is_finite() || trait_variance < 0.0 {
return Err(PsychometricError::InvalidNumericInput);
}
if initial_trait_effect == 0.0 || trait_variance == 0.0 {
return Ok(0.0);
}
let squared = require_finite(initial_trait_effect * initial_trait_effect)?;
require_finite(squared * trait_variance)
}

/// Refuse treating 2017-era `T0TRAITVAR` as Table 2 `TRAITVAR`.
///
/// `t0_trait² · trait` is extra first-occasion trait variance.
/// `TRAITVAR` is the between-subject process. Stationary
/// `T0TRAITEFFECT = I` makes the numbers equal; they remain
/// distinct named quantities.
///
/// # Errors
///
/// Always returns
/// [`PsychometricError::InitialTraitVarianceIsNotTraitVariance`].
pub fn refuse_initial_trait_variance_as_trait_variance(
initial_trait_variance: f64,
trait_variance: f64,
) -> Result<f64, PsychometricError> {
let _ = (initial_trait_variance, trait_variance);
Err(PsychometricError::InitialTraitVarianceIsNotTraitVariance)
}

/// Refuse treating 2017-era `T0TRAITVAR` as 2017-era `addedT0TIPREDVAR`.
///
/// `t0_trait² · trait` is extra first-occasion trait variance.
/// `t0_b² v` is extra first-occasion TI predictor variance. This
/// crate does not currently export `addedT0TIPREDVAR`; the refuse
/// still names that quantity.
///
/// # Errors
///
/// Always returns
/// [`PsychometricError::InitialTraitVarianceIsNotInitialTimeIndependentVariance`].
pub fn refuse_initial_trait_variance_as_initial_time_independent_variance(
initial_trait_variance: f64,
initial_predictor_variance: f64,
) -> Result<f64, PsychometricError> {
let _ = (initial_trait_variance, initial_predictor_variance);
Err(PsychometricError::InitialTraitVarianceIsNotInitialTimeIndependentVariance)
}

/// Refuse treating 2017-era `T0TRAITVAR` as §7.2 `addedTIPREDVAR`.
///
/// `t0_trait² · trait` is extra first-occasion trait variance.
/// `(B / a)² v` is the asymptotic TI extra. Those are not the
/// same map.
///
/// # Errors
///
/// Always returns
/// [`PsychometricError::InitialTraitVarianceIsNotAsymptoticTimeIndependentVariance`].
pub fn refuse_initial_trait_variance_as_asymptotic_time_independent_variance(
initial_trait_variance: f64,
asymptotic_predictor_variance: f64,
) -> Result<f64, PsychometricError> {
let _ = (initial_trait_variance, asymptotic_predictor_variance);
Err(PsychometricError::InitialTraitVarianceIsNotAsymptoticTimeIndependentVariance)
}

/// Refuse treating 2017-era `T0TRAITVAR` as free `T0VAR`.
///
/// `t0_trait² · trait` is extra first-occasion trait variance.
/// Free `T0VAR` `p_0` is the first-occasion state. Those are not
/// the same map.
///
/// # Errors
///
/// Always returns
/// [`PsychometricError::InitialTraitVarianceIsNotInitialLatentVariance`].
pub fn refuse_initial_trait_variance_as_initial_latent_variance(
initial_trait_variance: f64,
initial_latent_variance: f64,
) -> Result<f64, PsychometricError> {
let _ = (initial_trait_variance, initial_latent_variance);
Err(PsychometricError::InitialTraitVarianceIsNotInitialLatentVariance)
}

/// Refuse treating 2017-era `T0TRAITVAR` as the ctGenerate unit extra.
///
/// `ctGenerate.R` draws traits from `N(0, I)` and adds
/// `T0TRAITEFFECT %*% traits` to `T0MEANS`, so the generate extra
/// at `T0` is `t0_trait²`. SEM path-tracing uses `TRAITVAR`.
///
/// # Errors
///
/// Always returns
/// [`PsychometricError::InitialTraitVarianceIsNotUnitTraitGenerateVariance`].
pub fn refuse_initial_trait_variance_as_unit_trait_generate_variance(
initial_trait_variance: f64,
unit_generate_variance: f64,
) -> Result<f64, PsychometricError> {
let _ = (initial_trait_variance, unit_generate_variance);
Err(PsychometricError::InitialTraitVarianceIsNotUnitTraitGenerateVariance)
}

/// Least-squares scalar log-rate for already-formed residual pairs.
///
/// Pair-wise logs initialize Newton. This helper is crate-visible so overflow
Expand Down
12 changes: 12 additions & 0 deletions crates/psychometric_core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,8 @@ pub use event_time::recover_initial_time_dependent_predictor_effect;
pub use event_time::recover_initial_time_independent_predictor_carry;
/// Exact scalar first-occasion `T0TIPREDEFFECT` shift `t0_b z`.
pub use event_time::recover_initial_time_independent_predictor_effect;
/// Exact scalar 2017-era `T0TRAITVAR` `t0_trait² · trait`.
pub use event_time::recover_initial_trait_variance;
/// Mean exact log-rate on already-centered irregular residuals.
pub use event_time::recover_irregular_centered_residual_log_rate;
/// Exact scalar §7.2 level-change `CINT` `κ = −a m x`.
Expand Down Expand Up @@ -572,6 +574,16 @@ pub use event_time::refuse_initial_time_independent_effect_as_time_dependent_imp
pub use event_time::refuse_initial_time_independent_observed_mean_as_initial_time_dependent_observed_mean;
/// Refuse treating 2017-era `addedT0TIPREDVAR` as p. 16 `TRAITVARstd`.
pub use event_time::refuse_initial_time_independent_variance_as_standardised_trait_variance;
/// Refuse treating 2017-era `T0TRAITVAR` as §7.2 `addedTIPREDVAR`.
pub use event_time::refuse_initial_trait_variance_as_asymptotic_time_independent_variance;
/// Refuse treating 2017-era `T0TRAITVAR` as free `T0VAR`.
pub use event_time::refuse_initial_trait_variance_as_initial_latent_variance;
/// Refuse treating 2017-era `T0TRAITVAR` as 2017-era `addedT0TIPREDVAR`.
pub use event_time::refuse_initial_trait_variance_as_initial_time_independent_variance;
/// Refuse treating 2017-era `T0TRAITVAR` as Table 2 `TRAITVAR`.
pub use event_time::refuse_initial_trait_variance_as_trait_variance;
/// Refuse treating 2017-era `T0TRAITVAR` as the ctGenerate unit extra.
pub use event_time::refuse_initial_trait_variance_as_unit_trait_generate_variance;
/// Refuse treating Driver Eq. 3–4 lagged latent covariance as `cov(y_t, y_{t-1})`.
pub use event_time::refuse_latent_lagged_covariance_as_observed_covariance;
/// Refuse treating Driver Eq. 5 latent mean as `E(y)`.
Expand Down
Loading
Loading