Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion crates/longitudinal_core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "longitudinal_core"
description = "Within/between decomposition gates and component RMSE."
description = "Longitudinal modeling gates, within/between decomposition, temporal association standardization, and recovery metrics."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
Expand Down
137 changes: 137 additions & 0 deletions crates/longitudinal_core/src/association.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
//! Event-time lagged association standardization.
//!
//! This module belongs to the Longitudinal Modeling bounded context. It
//! standardizes a lagged covariance only when both marginal variances are
//! available. A one-sided covariance/initial-variance ratio is deliberately
//! not exposed as an autocorrelation.

use crate::LongitudinalError;

/// Recover a Pearson correlation for an event-time lag from its covariance and
/// both marginal variances.
///
/// For observations at event times `t` and `t + Δ`, the correlation is
///
/// `Cov(Y_t, Y_{t+Δ}) / sqrt(Var(Y_t) * Var(Y_{t+Δ}))`.
///
/// Requiring both marginals is essential for nonstationary processes. Driver,
/// Oud, and Voelkle (2017) provide the continuous-time state-transition and
/// covariance components from which occasion-specific marginals can be built;
/// they do not justify replacing the second marginal variance with the first
/// when the process is nonstationary.
///
/// `event_interval` is semantically required to be strictly positive so a
/// measurement-occasion or method facet cannot be passed as an untyped lag.
/// This function does not infer either marginal variance and does not estimate
/// a state process.
///
/// # Errors
///
/// Returns [`LongitudinalError::InvalidTemporalAssociationInput`] for
/// non-finite inputs, [`LongitudinalError::NonPositiveMarginalVariance`] when
/// either marginal variance is not strictly positive,
/// [`LongitudinalError::NonPositiveEventInterval`] for a non-positive event
/// interval, and [`LongitudinalError::CovarianceBoundViolation`] when the
/// supplied covariance is incompatible with the two marginal variances.
pub fn recover_event_time_lagged_correlation(
lagged_covariance: f64,
earlier_total_variance: f64,
later_total_variance: f64,
event_interval: f64,
) -> Result<f64, LongitudinalError> {
if !lagged_covariance.is_finite()
|| !earlier_total_variance.is_finite()
|| !later_total_variance.is_finite()
|| !event_interval.is_finite()
{
return Err(LongitudinalError::InvalidTemporalAssociationInput);
}
if earlier_total_variance <= 0.0 || later_total_variance <= 0.0 {
return Err(LongitudinalError::NonPositiveMarginalVariance);
}
if event_interval <= 0.0 {
return Err(LongitudinalError::NonPositiveEventInterval);
}
Comment thread
seonghobae marked this conversation as resolved.

// sqrt(v1) * sqrt(v2) avoids the avoidable overflow of sqrt(v1 * v2).
let denominator = earlier_total_variance.sqrt() * later_total_variance.sqrt();
if !denominator.is_finite() || denominator <= 0.0 {
return Err(LongitudinalError::InvalidTemporalAssociationInput);
}
let correlation = lagged_covariance / denominator;
if !correlation.is_finite() {
return Err(LongitudinalError::InvalidTemporalAssociationInput);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
if correlation.abs() > 1.0 {
return Err(LongitudinalError::CovarianceBoundViolation);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Outdated
}
Ok(correlation)
}

#[cfg(test)]
mod tests {
use super::recover_event_time_lagged_correlation;
use crate::LongitudinalError;

#[test]
fn nonstationary_marginals_do_not_use_the_earlier_variance_twice() {
// The retired one-sided ratio would be 1.5 and therefore impossible as
// an autocorrelation. Supplying the later marginal gives 0.75.
let recovered = recover_event_time_lagged_correlation(1.5, 1.0, 4.0, 1.0)
.expect("valid nonstationary covariance should standardize");
assert!((recovered - 0.75).abs() < f64::EPSILON * 4.0);
}

#[test]
fn covariance_bound_is_fail_closed() {
assert_eq!(
recover_event_time_lagged_correlation(2.000_000_000_1, 1.0, 4.0, 1.0),
Err(LongitudinalError::CovarianceBoundViolation)
);
assert_eq!(
recover_event_time_lagged_correlation(-2.000_000_000_1, 1.0, 4.0, 1.0),
Err(LongitudinalError::CovarianceBoundViolation)
);
}

#[test]
fn exact_boundary_correlations_are_allowed() {
assert_eq!(
recover_event_time_lagged_correlation(2.0, 1.0, 4.0, 1.0),
Ok(1.0)
);
assert_eq!(
recover_event_time_lagged_correlation(-2.0, 1.0, 4.0, 1.0),
Ok(-1.0)
);
}

#[test]
fn square_root_scaling_avoids_avoidable_variance_product_overflow() {
let variance = f64::MAX / 4.0;
let recovered = recover_event_time_lagged_correlation(
variance / 2.0,
variance,
variance,
1.0,
)
.expect("representable standardized covariance should remain representable");
assert!((recovered - 0.5).abs() < 1.0e-15);
}

#[test]
fn invalid_inputs_fail_closed() {
assert_eq!(
recover_event_time_lagged_correlation(f64::NAN, 1.0, 1.0, 1.0),
Err(LongitudinalError::InvalidTemporalAssociationInput)
);
assert_eq!(
recover_event_time_lagged_correlation(0.0, 0.0, 1.0, 1.0),
Err(LongitudinalError::NonPositiveMarginalVariance)
);
assert_eq!(
recover_event_time_lagged_correlation(0.0, 1.0, 1.0, 0.0),
Err(LongitudinalError::NonPositiveEventInterval)
);
Comment thread
seonghobae marked this conversation as resolved.
}
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
38 changes: 36 additions & 2 deletions crates/longitudinal_core/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
//! Fail-closed longitudinal within/between errors.
//! Fail-closed longitudinal modeling errors.

use std::fmt;

/// A fail-closed longitudinal-decomposition error.
/// A fail-closed longitudinal-modeling error.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum LongitudinalError {
Expand All @@ -14,6 +14,14 @@ pub enum LongitudinalError {
InvalidComponentPayload,
/// Observations were empty, sparse, duplicated, or non-finite.
InvalidObservationPayload,
/// A lagged covariance, marginal variance, or event interval was non-finite.
InvalidTemporalAssociationInput,
/// At least one marginal variance was not strictly positive.
NonPositiveMarginalVariance,
/// The event-time interval was not strictly positive.
NonPositiveEventInterval,
/// The covariance violates the Cauchy-Schwarz bound implied by the two marginals.
CovarianceBoundViolation,
}

impl fmt::Display for LongitudinalError {
Expand All @@ -23,6 +31,16 @@ impl fmt::Display for LongitudinalError {
Self::UnknownComponentLevel => "unknown component level",
Self::InvalidComponentPayload => "invalid longitudinal component payload",
Self::InvalidObservationPayload => "invalid longitudinal observation payload",
Self::InvalidTemporalAssociationInput => "invalid temporal association input",
Self::NonPositiveMarginalVariance => {
"temporal correlation requires strictly positive marginal variances"
}
Self::NonPositiveEventInterval => {
"temporal correlation requires a strictly positive event-time interval"
}
Self::CovarianceBoundViolation => {
"lagged covariance is incompatible with the supplied marginal variances"
}
};
formatter.write_str(message)
}
Expand Down Expand Up @@ -53,6 +71,22 @@ mod tests {
LongitudinalError::InvalidObservationPayload,
"invalid longitudinal observation payload",
),
(
LongitudinalError::InvalidTemporalAssociationInput,
"invalid temporal association input",
),
(
LongitudinalError::NonPositiveMarginalVariance,
"temporal correlation requires strictly positive marginal variances",
),
(
LongitudinalError::NonPositiveEventInterval,
"temporal correlation requires a strictly positive event-time interval",
),
(
LongitudinalError::CovarianceBoundViolation,
"lagged covariance is incompatible with the supplied marginal variances",
),
] {
assert_eq!(error.to_string(), message);
}
Expand Down
12 changes: 9 additions & 3 deletions crates/longitudinal_core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![allow(clippy::cast_precision_loss)]
//! Within/between decomposition for longitudinal scores.
//! Longitudinal modeling primitives for temporal psychometrics.
//!
//! Stable between-unit components cannot be scored as within-unit change.
//! Recovery reports computed component RMSE against known truth (ADR 0005).
//! Event-time lagged associations require both marginal variances before a
//! covariance can be standardized as a correlation. Recovery reports computed
//! component RMSE against known truth (ADR 0005).

mod association;
mod component;
mod decompose;
mod error;
mod level;

/// Recover a valid event-time lagged correlation from covariance and both
/// marginal variances.
pub use association::recover_event_time_lagged_correlation;
Comment thread
seonghobae marked this conversation as resolved.
/// One unit-specific within or between component.
pub use component::ComponentValue;
/// RMSE of recovered components against known truth.
Expand All @@ -19,7 +25,7 @@ pub use component::component_root_mean_square_error;
pub use decompose::OccasionObservation;
/// Decompose occasion scores into unit means and within residuals.
pub use decompose::decompose_within_between;
/// Fail-closed longitudinal-decomposition errors.
/// Fail-closed longitudinal-modeling errors.
pub use error::LongitudinalError;
/// Established longitudinal component level.
pub use level::ComponentLevel;
Expand Down
49 changes: 49 additions & 0 deletions docs/research/trait-state-lagged-association.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Trait-plus-state lagged association boundary

## Status

Research boundary note for the Longitudinal Modeling bounded context. This note does not activate a production scoring or estimator target.

## Scientific correction

A lagged covariance divided only by the earlier marginal variance is not, in general, an autocorrelation. For event times `t` and `t + Δ`, a Pearson correlation requires both marginals:

\[
\rho_{t,t+\Delta}=
\frac{\operatorname{Cov}(Y_t,Y_{t+\Delta})}
{\sqrt{\operatorname{Var}(Y_t)\operatorname{Var}(Y_{t+\Delta})}}.
\]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

When the process is nonstationary, `Var(Y_t)` and `Var(Y_{t+Δ})` need not be equal. Consequently, a one-sided ratio such as

\[
\frac{\text{stable variance}+\text{lagged state covariance}}
{\text{stable variance}+\text{earlier state variance}}
\]

can exceed one and must not be published as an autocorrelation. Driver, Oud, and Voelkle (2017) provide the continuous-time state-transition and covariance ingredients used by ctsem, but they do not print or validate that one-sided ratio as an autocorrelation.

The unmerged implementation that introduced a public `expected_autocorrelation` API is therefore retired rather than patched with an arbitrary restriction such as rejecting only positive drift. That restriction would still be insufficient for a nonstationary initial state with negative drift because the later marginal variance remains necessary.

## TEPP contract

`longitudinal_core::recover_event_time_lagged_correlation` accepts a lagged covariance, the earlier marginal variance, the later marginal variance, and a strictly positive event-time interval. It performs only the temporal association standardization and does not infer state variance, process noise, or a psychometric response kernel.

This preserves the DDD ownership boundary:

- reusable static/generalized-mixed/dependence psychometric kernels remain owned by `ContextualWisdomLab/fast-mlsirm`;
- TEPP owns event-time composition and longitudinal semantics;
- measurement occasion, rater, and method facets are not substitutes for substantive event time;
- callers must assemble occasion-specific marginals from an identified temporal model before asking for a correlation.

The function fails closed when either marginal is non-positive, the interval is non-positive, inputs are non-finite, or the supplied covariance violates the Cauchy-Schwarz bound.

## Regression evidence

The regression suite includes a nonstationary case with earlier variance `1`, later variance `4`, and lagged covariance `1.5`. The retired one-sided ratio would be `1.5`; correct standardization yields `0.75`. The suite also verifies exact `±1` boundaries, rejects incompatible covariance, rejects non-event intervals, and exercises very large representable variances without forming `Var_t * Var_t+Δ` directly.

This is intentionally narrower than a state estimator. A future production autocorrelation derived from a specific DSEM/continuous-time state model must additionally identify and recover the state transition, process-noise contribution, both marginal variances, uncertainty, and rolling-origin leakage-safe performance before activation.

## Research basis

Driver, C. C., Oud, J. H. L., & Voelkle, M. C. (2017). Continuous time structural equation modeling with R package ctsem. *Journal of Statistical Software, 77*(5), 1–35. https://doi.org/10.18637/jss.v077.i05
Loading