From 58bacd756855f6a152174cbb2826a94d53a4eb36 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 11:31:53 +0000 Subject: [PATCH 1/2] feat(analysis): bind longitudinal ESEM/DSEM composition to an analysis-run profile GAP-169 / #169 remaining operator-visible slice: compose recovered construct-class, posterior-draw, strong/strict mean, event-time, membership, and within/between gates as a cutoff-safe analysis-run output. Not an estimator, not a Driver p.16 std restore, and not implemented-main. --- ARCHITECTURE.md | 4 +- CHANGELOG.md | 2 + Cargo.lock | 2 + crates/analysis_engine/Cargo.toml | 3 + crates/analysis_engine/src/lib.rs | 96 ++- .../src/longitudinal_esem_dsem_artifact.rs | 613 ++++++++++++++++++ ...ngitudinal_esem_dsem_execution_contract.rs | 539 +++++++++++++++ docs/TRACEABILITY.md | 1 + ...0036-longitudinal-esem-dsem-composition.md | 83 +++ docs/adr/README.md | 2 + .../longitudinal-esem-dsem-composition.md | 19 + 11 files changed, 1354 insertions(+), 10 deletions(-) create mode 100644 crates/analysis_engine/src/longitudinal_esem_dsem_artifact.rs create mode 100644 crates/analysis_engine/tests/longitudinal_esem_dsem_execution_contract.rs create mode 100644 docs/adr/0036-longitudinal-esem-dsem-composition.md create mode 100644 docs/doctoring/longitudinal-esem-dsem-composition.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e6dafbcc2..a84b7e9c9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -70,7 +70,7 @@ boundaries above remain the target modular MSA architecture. | `tepp_simulation` | known-truth temporal/event data generation | | `validation_core` | RMSE, bias, coverage, graph, Monte Carlo, and exact-head claim-promotion metrics | | `tepp_api` | versioned DTO, schema, terminal-result, and export contracts | -| `analysis_engine` | bounded cutoff-safe temporal evidence readiness execution and digest-bound terminal artifacts | +| `analysis_engine` | bounded cutoff-safe temporal evidence readiness execution, digest-bound terminal artifacts, and longitudinal ESEM/DSEM engine composition | | `episode_membership` | event-time episode membership containment gate | | `prompt_source` | prompt boilerplate is not unique latent content and not stopword deletion | | `corpus_background` | corpus-background wording is not unique latent content and not stopword deletion | @@ -113,7 +113,7 @@ boundaries above remain the target modular MSA architecture. | `episode_membership` | episode membership cannot escape the episode event-time interval | | `membership_target` | language, episode, template, department, and opportunity-pool targets cannot collapse into entity or project | | `topic_measurement` | logistic-normal ALR/ILR coordinates and the CPU `f64` TRSL-TM reference estimator | -| `analysis_engine` | bounded cutoff-safe temporal evidence readiness execution and digest-bound terminal artifacts | +| `analysis_engine` | bounded cutoff-safe temporal evidence readiness execution, digest-bound terminal artifacts, and longitudinal ESEM/DSEM engine composition | | `psychometric_core` | posterior-aware structural input gates, CWC within/between OLS plus the contextual effect, event-time log-rate, unequal-interval discrete-lag remapping, constant-predictor discrete effect, time-varying-predictor discrete effect (Eq. 14), exact scalar discrete process noise (Driver et al., 2017, Eq. 3), lagged latent covariance and unconditional latent variance (Driver et al., 2017, Eq. 3–4), stationary within-subject variance (Driver et al., 2017, Eq. 4 as `Δt → ∞`; `asymDIFFUSION`), trait-plus-state variance (Driver et al., 2017, §4.3 `TRAITVAR`; not process noise), observed-indicator variance and lagged observed covariance (Driver et al., 2017, Eq. 5; Table 2 `MANIFESTVAR` is `Θ`, not `Var(y)`; `MANIFESTTRAITVAR` is not `MANIFESTVAR`; `Θ` does not enter lagged observed covariance; observed-indicator mean is `τ + λ μ`; `MANIFESTMEANS` is not `E(y)`; `CINT` is not `MANIFESTMEANS`; discrete latent mean is `exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ`; `T0MEANS` is not `μ_t`; `CINT` is not the discrete increment; evolved observed mean is `τ + λ μ_t`; `τ + λ μ_0` is not `E(y_t)`; contemporaneous `TDPREDEFFECT` impulse is `m x`, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; Eq. 5 of that contemporaneous impulse is `τ + λ(μ_t + m x)`, and `τ + λ μ_t` is not that observed mean; time-independent `TIPREDEFFECT` increment is `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, not `M x`, not Voelkle Eq. 14, and not the coefficient `B`; Eq. 5 of that increment is `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that observed mean; `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t`; within-interval `TDPREDEFFECT` carry is `e^{A(t−u)} M x` for `t0 < u < t`, not the contemporaneous Dirac, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; Eq. 5 of that carry is `τ + λ(μ_t + e^{a(t−u)} m x)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that carried observed mean when `u ≠ t`; first-occasion `T0TIPREDEFFECT` shift is `t0_b z` and Eq. 3 first-summand carry is `e^{A Δt} t0_b z` (`T0TIPREDEFFECT` is not `TIPREDEFFECT` `B`; `t0_b z` is not `A^{-1}[e^{A Δt} − I] B z`; `e^{A Δt} t0_b z` is not `t0_b z`; Eq. 5 of that carry is `τ + λ(μ_t + e^{a Δt} t0_b z)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not that observed mean), first-occasion `T0TDPREDEFFECT` shift is `t0_m x0` and Eq. 3 first-summand carry is `e^{A Δt} t0_m x0` (`T0TDPREDEFFECT` is not `TDPREDEFFECT` `M`; `t0_m x0` is not `M x`; `e^{A Δt} t0_m x0` is not `t0_m x0`; `e^{A Δt} t0_m x0` is not `e^{A(t−u)} M x` for `t0 < u < t`; `t0_m x0` is not `t0_b z`; an impulse at `u ≤ t0` that used `M` is already in `η(t0)` as `TDPREDEFFECT`, not as `T0TDPREDEFFECT`; Eq. 5 of that carry is `τ + λ(μ_t + e^{a Δt} t0_m x0)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not that observed mean; `τ + λ(μ_t + e^{a Δt} t0_b z)` is not that observed mean; §7.2 level-change `CINT` is `κ = −a m x` with `a < 0` so `−κ / a = m x` (`−a m x` is not the dissipating Dirac, not a free `CINT`, not `TIPREDEFFECT`, and not the extra near-zero-drift latent process also named in §7.2; Eq. 3 of that setting is `(1 − e^{a Δt}) m x`, which is not `m x`, not `κ`, and not `TIPREDEFFECT`; §7.2 extra-process contribution is `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` (`ε = a` is `a_{ηξ} x Δt e^{a Δt}`; identification `TDPREDEFFECT` on the extra process is 1; printed extra `DRIFT` is `−0.000001`; not `κ = −a m x`, not `(1 − e^{a Δt}) m x`, and not the dissipating Dirac `m x`; `ε ≥ 0` fails closed; Eq. 5 of that contribution is `τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)`; the extra process has `LAMBDA` 0 and is not an observed indicator; `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that observed mean; the contribution is not `E(y_t)`; the evolved-plus-contribution latent mean is not `E(y_t)`; after-t0 extra-process `TDPREDEFFECT` is `a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a)` for `t0 < u < t` while `μ_t` uses `Δt`; Eq. 5 of that after-t0 contribution is `τ + λ(μ_t + a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a)`; the first-occasion extra-process observed mean is not that observed mean when `u ≠ t0`; `e^{a(t−u)} m x` is a Dirac on the original process, not this `DRIFT` drive; §7.2 `asymTIPREDEFFECT` is `-B z / a` for `a < 0` (`-B z / a` is not the coefficient `B`, not `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, and not `M x`; §7.2 `addedTIPREDVAR` is `(B / a)² v`, not `TRAITVAR`, not `asymDIFFUSION`, and not `-B z / a`; Table 2 `asymCINT` is `-κ / a` for `a < 0` and is not `κ`, not `A^{-1}[e^{A Δt} − I] κ`, not `T0MEANS`, and not `-B z / a`; p. 16 stationary `T0MEANS` is `-κ / a + −B z / a` and is not free `T0MEANS`, not `asymCINT` alone, not `asymTIPREDEFFECT` alone, and not the finite-interval discrete latent mean; Eq. 5 of that constrained mean is `τ + λ(−κ / a + −B z / a)`; `τ + λ μ_0` is not that observed mean; `τ + λ(−κ / a)` is not that observed mean when `B z ≠ 0`; `τ + λ μ_t` is not that observed mean; `MANIFESTMEANS` is not `E(y_0)`; the constrained latent mean is not `E(y_0)`; stationary `T0VAR` is `trait + −q / (2 a) + (B / a)² v` (not free `T0VAR`, not `asymDIFFUSION` alone, not `TRAITVAR` alone, not `addedTIPREDVAR` alone, and not the finite-interval discrete latent variance. Eq. 5 of that constrained variance is `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` (JSS PDF re-opened 2026-08-22T03:20Z; form the stationary latent variance first, then `λ² p + θ + ψ`; `λ² p_0` is not that observed variance; `λ²(−q / (2 a)) + θ` is not that observed variance when `TRAITVAR` or `addedTIPREDVAR` is nonzero; `MANIFESTVAR` is not `Var(y_0)`; the constrained latent variance is not `Var(y_0)`); lagged stationary `T0VAR` is `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` (trait and `addedTIPREDVAR` do not decay; contemporaneous `T0VAR` is not that lagged map; decaying the constrained total as if it were all state is not that lagged map; Eq. 5 of that lagged covariance is `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ`; `Θ` does not enter; contemporaneous `Var(y_0)` is not that lagged observed covariance; the lagged latent covariance is not that observed covariance); later-occasion stationary `T0VAR` is `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v` (trait and `addedTIPREDVAR` do not enter `Q_Δt`; under stationarity that composition equals contemporaneous `T0VAR`; evolving the constrained total as if it were all state is not that later map; the lagged covariance omits `Q_Δt`; `Q_Δt` is not that later map; Eq. 5 of that later-occasion variance is `λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ`; lagged observed covariance omits `Q_Δt` and `θ`; `MANIFESTVAR` is not `Var(y_t)`; the later-occasion latent variance is not `Var(y_t)`))), irregular already-centered residual lag, Rubin `T` on OLS loadings, and strong-gated latent means (two-observation residual variance is identically `0` and caps at strong/scalar; Putnick & Bornstein, 2016) | | `validation_core` | RMSE, bias, coverage, graph, and Monte Carlo metrics | | `tepp_api` | versioned DTO, schema, and export contracts | diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..0a93a575e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] +- `analysis_engine` binds cutoff-safe longitudinal ESEM/DSEM engine composition as the `longitudinal_esem_dsem_composition_v1` analysis-run profile (`tepp.longitudinal_esem_dsem_composition.v1`). The executor consumes already-mapped posterior draws, refuses point estimates, non-event lag clocks, formative/network-as-reflective reinterpretation, collapsed non-nested membership, between-as-within change, metric-only latent means, OLS-as-DSEM, and causal promotion from temporal precedence, and emits a digest-bound artifact with inference status `composed_engine_not_estimator`. This is not an ESEM/DSEM sampler, not implemented-main, and not a Driver p.16 `std` restore. + - `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. diff --git a/Cargo.lock b/Cargo.lock index 454a7d612..5781054d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,7 +73,9 @@ version = "0.2.0" dependencies = [ "corpus_split", "event_core", + "longitudinal_core", "membership_core", + "psychometric_core", "relation_graph", "serde", "serde_json", diff --git a/crates/analysis_engine/Cargo.toml b/crates/analysis_engine/Cargo.toml index 7322212b2..3c547e5c5 100644 --- a/crates/analysis_engine/Cargo.toml +++ b/crates/analysis_engine/Cargo.toml @@ -15,6 +15,9 @@ publish = false [dependencies] event_core = { path = "../event_core", version = "0.2.0" } +longitudinal_core = { path = "../longitudinal_core", version = "0.2.0" } +membership_core = { path = "../membership_core", version = "0.2.0" } +psychometric_core = { path = "../psychometric_core", version = "0.2.0" } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index 72bd5854c..dfe0ccc40 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -6,15 +6,20 @@ //! was unavailable at the requested knowledge cutoff, counts multiple-membership //! assignments without collapsing them, and emits a digest-bound terminal result //! through [`tepp_api`]. It deliberately does not claim latent-variable or topic -//! estimation authority; those estimators remain separate scientific crates. //! estimation authority; it invokes estimators through their scientific crate -//! contracts and preserves their artifact meaning. +//! contracts and preserves their artifact meaning. Longitudinal ESEM/DSEM +//! engine composition is invoked through [`psychometric_core`], +//! [`longitudinal_core`], and [`membership_core`] and is not an estimator. mod case_deletion_refit; mod lineage_criterion; +mod longitudinal_esem_dsem_artifact; mod topic_context_posterior; mod topic_lineage_artifact; +use longitudinal_core::LongitudinalError; +use membership_core::MembershipError; +use psychometric_core::PsychometricError; use serde::Serialize; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; @@ -46,6 +51,13 @@ pub use lineage_criterion::{ LineageCriterionFit, LineageCriterionFitError, LineageCriterionObservation, fit_lineage_criterion_posteriors, }; +/// Longitudinal ESEM/DSEM engine composition artifact and execution contracts. +pub use longitudinal_esem_dsem_artifact::{ + LONGITUDINAL_ESEM_DSEM_ARTIFACT_BYTE_LIMIT, LONGITUDINAL_ESEM_DSEM_ARTIFACT_SCHEMA_VERSION, + LONGITUDINAL_ESEM_DSEM_MODEL_CONTRACT_VERSION, LONGITUDINAL_ESEM_DSEM_OUTPUT_PROFILE, + LongitudinalEsemDsemArtifact, LongitudinalEsemDsemDesign, LongitudinalEsemDsemExecution, + LongitudinalEsemDsemObservation, execute_longitudinal_esem_dsem_run, +}; /// Bounded posterior topic-context producer contract and record types. pub use topic_context_posterior::{ TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT, TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION, @@ -248,6 +260,14 @@ pub enum AnalysisEngineError { TopicMeasurement(TopicMeasurementError), /// A topic-lineage artifact violated its bounded schema or count invariants. InvalidTopicLineageArtifact, + /// A psychometric recovery rejected the offered coordinates. + Psychometric(PsychometricError), + /// A membership design rejected nested collapse. + Membership(MembershipError), + /// A longitudinal component identity rejected the offered level. + Longitudinal(LongitudinalError), + /// A longitudinal ESEM/DSEM artifact violated its bounded schema or counts. + InvalidLongitudinalEsemDsemArtifact, } impl fmt::Display for AnalysisEngineError { @@ -262,6 +282,12 @@ impl fmt::Display for AnalysisEngineError { Self::LimitExceeded => "analysis corpus exceeded its execution bound", Self::TopicMeasurement(error) => return error.fmt(formatter), Self::InvalidTopicLineageArtifact => "invalid topic lineage artifact", + Self::Psychometric(error) => return error.fmt(formatter), + Self::Membership(error) => return error.fmt(formatter), + Self::Longitudinal(error) => return error.fmt(formatter), + Self::InvalidLongitudinalEsemDsemArtifact => { + "invalid longitudinal ESEM/DSEM composition artifact" + } }; formatter.write_str(message) } @@ -281,6 +307,24 @@ impl From for AnalysisEngineError { } } +impl From for AnalysisEngineError { + fn from(error: PsychometricError) -> Self { + Self::Psychometric(error) + } +} + +impl From for AnalysisEngineError { + fn from(error: MembershipError) -> Self { + Self::Membership(error) + } +} + +impl From for AnalysisEngineError { + fn from(error: LongitudinalError) -> Self { + Self::Longitudinal(error) + } +} + /// Execute the cutoff-safe temporal evidence readiness analysis. /// /// Evidence whose `available_time` is later than the request cutoff is excluded @@ -412,8 +456,9 @@ fn valid_identifier(value: &str) -> bool { mod tests { use super::{ ANALYSIS_ARTIFACT_SCHEMA_VERSION, ANALYSIS_STATISTIC_COUNT, AnalysisCorpus, - AnalysisEngineError, AnalysisEvidenceUnit, MAX_ANALYSIS_IDENTIFIER_BYTES, - MAX_EVIDENCE_UNITS, TopicMeasurementError, add_membership_count, execute_analysis_run, + AnalysisEngineError, AnalysisEvidenceUnit, LongitudinalError, + MAX_ANALYSIS_IDENTIFIER_BYTES, MAX_EVIDENCE_UNITS, MembershipError, PsychometricError, + TopicMeasurementError, add_membership_count, execute_analysis_run, }; use temporal_core::{AvailableTime, EventTime}; use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState, ApiError}; @@ -617,7 +662,7 @@ mod tests { } #[test] - fn public_accessors_limits_and_error_messages_are_executable() { + fn public_accessors_and_limits_are_executable() { let evidence = unit( "evidence-accessor", "2026-07-01T00:00:00Z", @@ -647,7 +692,15 @@ mod tests { ], ); assert_eq!(oversized, Err(AnalysisEngineError::LimitExceeded)); + assert_eq!( + add_membership_count(u64::MAX, 1), + Err(AnalysisEngineError::ArithmeticOverflow) + ); + assert_eq!(add_membership_count(0, 4), Ok(4)); + } + #[test] + fn error_messages_and_from_impls_are_executable() { let messages = [ ( AnalysisEngineError::InvalidEvidence, @@ -681,6 +734,22 @@ mod tests { AnalysisEngineError::InvalidTopicLineageArtifact, "invalid topic lineage artifact", ), + ( + AnalysisEngineError::Psychometric(PsychometricError::CausalUnderidentified), + "temporal precedence is not causal identification", + ), + ( + AnalysisEngineError::Membership(MembershipError::NestedIccInapplicable), + "nested ICC is inapplicable to this membership design", + ), + ( + AnalysisEngineError::Longitudinal(LongitudinalError::BetweenIsNotWithinChange), + "between component is not within-unit change", + ), + ( + AnalysisEngineError::InvalidLongitudinalEsemDsemArtifact, + "invalid longitudinal ESEM/DSEM composition artifact", + ), ]; for (error, message) in messages { assert_eq!(error.to_string(), message); @@ -689,11 +758,22 @@ mod tests { assert_eq!(converted.to_string(), "invalid API wire payload"); let from_topic: AnalysisEngineError = TopicMeasurementError::DidNotConverge.into(); assert_eq!(from_topic.to_string(), "topic estimator did not converge"); + let from_psych: AnalysisEngineError = PsychometricError::CausalUnderidentified.into(); assert_eq!( - add_membership_count(u64::MAX, 1), - Err(AnalysisEngineError::ArithmeticOverflow) + from_psych.to_string(), + "temporal precedence is not causal identification" + ); + let from_membership: AnalysisEngineError = MembershipError::NestedIccInapplicable.into(); + assert_eq!( + from_membership.to_string(), + "nested ICC is inapplicable to this membership design" + ); + let from_longitudinal: AnalysisEngineError = + LongitudinalError::BetweenIsNotWithinChange.into(); + assert_eq!( + from_longitudinal.to_string(), + "between component is not within-unit change" ); - assert_eq!(add_membership_count(0, 4), Ok(4)); } #[test] diff --git a/crates/analysis_engine/src/longitudinal_esem_dsem_artifact.rs b/crates/analysis_engine/src/longitudinal_esem_dsem_artifact.rs new file mode 100644 index 000000000..eb82927c2 --- /dev/null +++ b/crates/analysis_engine/src/longitudinal_esem_dsem_artifact.rs @@ -0,0 +1,613 @@ +//! Digest-bound longitudinal ESEM/DSEM engine composition as an analysis-run profile. + +use longitudinal_core::{ComponentLevel, refuse_between_as_within_change}; +use membership_core::{MembershipDesign, MembershipError}; +use psychometric_core::{ + CausalHeuristic, ConstructClass, LagClock, LatentMeanComparisonEvidence, MeanInvarianceStatus, + PsychometricError, TwoGroupMeasurement, claim_causal_effect, compare_latent_means, + interpret_as_reflective, posterior_draw_point_estimate_mean, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{ + AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, +}; + +use crate::{ + AnalysisEngineError, MAX_EVIDENCE_UNITS, format_digest, require_receipt_identity, + valid_identifier, +}; + +/// Versioned schema for a completed longitudinal ESEM/DSEM composition artifact. +pub const LONGITUDINAL_ESEM_DSEM_ARTIFACT_SCHEMA_VERSION: &str = + "tepp.longitudinal_esem_dsem_composition.v1"; +/// Model contract required by the composition execution path. +pub const LONGITUDINAL_ESEM_DSEM_MODEL_CONTRACT_VERSION: &str = + "longitudinal_esem_dsem_composition_v1"; +/// Analysis-run output profile required for the composition artifact. +pub const LONGITUDINAL_ESEM_DSEM_OUTPUT_PROFILE: &str = "longitudinal_esem_dsem_composition_v1"; +/// Maximum canonical artifact JSON size. +pub const LONGITUDINAL_ESEM_DSEM_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; +const LONGITUDINAL_ESEM_DSEM_INFERENCE_STATUS: &str = "composed_engine_not_estimator"; + +/// Run-level scientific design offered to one composition execution. +#[derive(Clone, Debug, Eq, PartialEq)] +#[allow( + clippy::struct_excessive_bools, + reason = "independent fail-closed composition gates" +)] +pub struct LongitudinalEsemDsemDesign { + construct_class: ConstructClass, + membership_design: MembershipDesign, + collapse_hierarchy: bool, + component_level: ComponentLevel, + lag_clock: LagClock, + invariance_status: MeanInvarianceStatus, + comparison_scope: String, + model_version: String, + treat_ols_as_dsem: bool, + promote_causal: bool, +} + +impl LongitudinalEsemDsemDesign { + /// Bind the fail-closed composition gates for one analysis run. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidEvidence`] when comparison-scope + /// or model-version labels are empty. + #[allow( + clippy::too_many_arguments, + clippy::fn_params_excessive_bools, + reason = "audited composition gate sequence" + )] + pub fn new( + construct_class: ConstructClass, + membership_design: MembershipDesign, + collapse_hierarchy: bool, + component_level: ComponentLevel, + lag_clock: LagClock, + invariance_status: MeanInvarianceStatus, + comparison_scope: impl Into, + model_version: impl Into, + treat_ols_as_dsem: bool, + promote_causal: bool, + ) -> Result { + let comparison_scope = comparison_scope.into(); + let model_version = model_version.into(); + if comparison_scope.is_empty() || model_version.is_empty() { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok(Self { + construct_class, + membership_design, + collapse_hierarchy, + component_level, + lag_clock, + invariance_status, + comparison_scope, + model_version, + treat_ols_as_dsem, + promote_causal, + }) + } + + /// Return the classified construct class. + #[must_use] + pub const fn construct_class(&self) -> ConstructClass { + self.construct_class + } + + /// Return the membership design that must not be silently collapsed. + #[must_use] + pub const fn membership_design(&self) -> MembershipDesign { + self.membership_design + } + + /// Return whether the caller asked to collapse non-nested membership. + #[must_use] + pub const fn collapse_hierarchy(&self) -> bool { + self.collapse_hierarchy + } + + /// Return the within/between component level. + #[must_use] + pub const fn component_level(&self) -> ComponentLevel { + self.component_level + } + + /// Return the lag clock offered for structural dynamics. + #[must_use] + pub const fn lag_clock(&self) -> LagClock { + self.lag_clock + } + + /// Return the invariance status offered for mean comparison. + #[must_use] + pub const fn invariance_status(&self) -> MeanInvarianceStatus { + self.invariance_status + } + + /// Return the comparison-scope label carried in invariance evidence. + #[must_use] + pub fn comparison_scope(&self) -> &str { + &self.comparison_scope + } + + /// Return the model-version label carried in invariance evidence. + #[must_use] + pub fn model_version(&self) -> &str { + &self.model_version + } + + /// Return whether OLS recovery was offered as a DSEM estimator. + #[must_use] + pub const fn treat_ols_as_dsem(&self) -> bool { + self.treat_ols_as_dsem + } + + /// Return whether temporal precedence was offered as a causal claim. + #[must_use] + pub const fn promote_causal(&self) -> bool { + self.promote_causal + } +} + +/// One already-mapped posterior-draw observation offered to a cutoff-safe run. +#[derive(Clone, Debug, PartialEq)] +pub struct LongitudinalEsemDsemObservation { + available_time: AvailableTime, + posterior_draws: Vec, +} + +impl LongitudinalEsemDsemObservation { + /// Bind posterior draws to an availability clock. + /// + /// Point topic estimates are not admitted. Each observation must carry at + /// least two finite posterior draws. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::Psychometric`] when fewer than two draws + /// are supplied or a draw is non-finite. + pub fn new( + available_time: AvailableTime, + posterior_draws: Vec, + ) -> Result { + if posterior_draws.len() < 2 { + return Err(AnalysisEngineError::Psychometric( + PsychometricError::InsufficientDraws, + )); + } + for value in &posterior_draws { + if !value.is_finite() { + return Err(AnalysisEngineError::Psychometric( + PsychometricError::InvalidNumericInput, + )); + } + } + Ok(Self { + available_time, + posterior_draws, + }) + } + + /// Return the availability clock used for cutoff eligibility. + #[must_use] + pub const fn available_time(&self) -> AvailableTime { + self.available_time + } + + /// Return the posterior draws in source order. + #[must_use] + pub fn posterior_draws(&self) -> &[f64] { + &self.posterior_draws + } +} + +/// Completed, bounded ESEM/DSEM composition consumed by analysis-run clients. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LongitudinalEsemDsemArtifact { + /// Exact versioned schema identity. + pub schema_version: String, + /// Opaque accepted-run identity. + pub run_id: String, + /// Immutable source snapshot identity. + pub snapshot_id: String, + /// Historical evidence cutoff used by the composition. + pub knowledge_cutoff: String, + /// Eligible observations after cutoff. + pub observation_count: u64, + /// Posterior draws among eligible observations. + pub draw_count: u64, + /// Observations excluded because availability was after the cutoff. + pub excluded_after_cutoff_count: u64, + /// Arithmetic mean of eligible posterior draws. Not an ESEM/DSEM fit. + pub posterior_draw_mean: f64, + /// Classified construct class admitted as reflective. + pub construct_class: String, + /// Membership design preserved without collapse. + pub membership_design: String, + /// Within-unit component level. + pub component_level: String, + /// Event-time lag clock. + pub lag_clock: String, + /// Strong or strict invariance status that licenses means. + pub invariance_status: String, + /// Fixed claim boundary for consumer copy. + pub inference_status: String, +} + +impl LongitudinalEsemDsemArtifact { + /// Parse and fully validate a bounded artifact JSON payload. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidLongitudinalEsemDsemArtifact`] + /// when the schema, identifiers, counts, mean, or claim boundary fail. + pub fn from_json(payload: &str) -> Result { + if payload.len() > LONGITUDINAL_ESEM_DSEM_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + let artifact: Self = serde_json::from_str(payload) + .map_err(|_| AnalysisEngineError::InvalidLongitudinalEsemDsemArtifact)?; + artifact.validate()?; + Ok(artifact) + } + + /// Serialize canonical validated artifact JSON. + /// + /// # Errors + /// + /// Returns a typed validation, serialization, or size failure. + pub fn to_json(&self) -> Result { + self.validate()?; + let payload = + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure)?; + if payload.len() > LONGITUDINAL_ESEM_DSEM_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + Ok(payload) + } + + /// Return the lowercase SHA-256 digest of canonical artifact JSON. + /// + /// # Errors + /// + /// Returns a typed validation or serialization failure. + pub fn sha256(&self) -> Result { + self.to_json() + .map(|json| format_digest(Sha256::digest(json.into_bytes()))) + } + + fn validate(&self) -> Result<(), AnalysisEngineError> { + if self.schema_version != LONGITUDINAL_ESEM_DSEM_ARTIFACT_SCHEMA_VERSION + || !valid_identifier(&self.run_id) + || !valid_identifier(&self.snapshot_id) + || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() + || self.observation_count == 0 + || self.draw_count < 2 + || !self.posterior_draw_mean.is_finite() + || self.construct_class != ConstructClass::Reflective.as_str() + || membership_design_wire_name_is_unknown(&self.membership_design) + || self.component_level != ComponentLevel::Within.wire_name() + || self.lag_clock != LagClock::EventTime.as_str() + || !(self.invariance_status == MeanInvarianceStatus::Strong.as_str() + || self.invariance_status == MeanInvarianceStatus::Strict.as_str()) + || self.inference_status != LONGITUDINAL_ESEM_DSEM_INFERENCE_STATUS + { + return Err(AnalysisEngineError::InvalidLongitudinalEsemDsemArtifact); + } + Ok(()) + } +} + +fn membership_design_wire_name_is_unknown(name: &str) -> bool { + !matches!(name, "nested" | "cross_classified" | "multiple_membership") +} + +fn membership_design_wire_name( + design: MembershipDesign, +) -> Result<&'static str, AnalysisEngineError> { + match design { + MembershipDesign::Nested => Ok("nested"), + MembershipDesign::CrossClassified => Ok("cross_classified"), + MembershipDesign::MultipleMembership => Ok("multiple_membership"), + _ => Err(AnalysisEngineError::InvalidEvidence), + } +} + +/// One completed composition artifact and its request-bound terminal result. +#[derive(Clone, Debug, PartialEq)] +pub struct LongitudinalEsemDsemExecution { + /// Digest-bound completed composition artifact. + pub artifact: LongitudinalEsemDsemArtifact, + /// Terminal result carrying the artifact identity, digest, and schema. + pub terminal_result: AnalysisRunTerminalResult, +} + +struct EligibleDraws { + draws: Vec, + observation_count: u64, + excluded_after_cutoff_count: u64, +} + +fn admit_draws_at_cutoff( + observations: &[LongitudinalEsemDsemObservation], + knowledge_cutoff: KnowledgeCutoff, +) -> Result { + if observations.len() > MAX_EVIDENCE_UNITS { + return Err(AnalysisEngineError::LimitExceeded); + } + let mut draws = Vec::new(); + let mut observation_count = 0_u64; + let mut excluded_after_cutoff_count = 0_u64; + for observation in observations { + if observation.available_time.instant() <= knowledge_cutoff.instant() { + draws.extend_from_slice(&observation.posterior_draws); + observation_count = observation_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + } else { + excluded_after_cutoff_count = excluded_after_cutoff_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + } + } + if draws.is_empty() { + return Err(AnalysisEngineError::Psychometric( + PsychometricError::InvalidNumericInput, + )); + } + Ok(EligibleDraws { + draws, + observation_count, + excluded_after_cutoff_count, + }) +} + +fn apply_composition_gates(design: &LongitudinalEsemDsemDesign) -> Result<(), AnalysisEngineError> { + interpret_as_reflective(design.construct_class, true)?; + if design.collapse_hierarchy && !design.membership_design.allows_nested_icc() { + return Err(AnalysisEngineError::Membership( + MembershipError::NestedIccInapplicable, + )); + } + refuse_between_as_within_change(design.component_level)?; + if !design.lag_clock.admits_structural_lag() { + return Err(AnalysisEngineError::Psychometric( + PsychometricError::EventTimeRequired, + )); + } + let evidence = LatentMeanComparisonEvidence::from_two_group_measurement( + &TwoGroupMeasurement { + reference_intercept: 0.0, + reference_loading: 1.0, + comparison_intercept: 0.0, + comparison_loading: 1.0, + reference_residual_variance: 1.0, + comparison_residual_variance: 1.0, + status: design.invariance_status, + }, + design.comparison_scope(), + design.model_version(), + )?; + compare_latent_means(&evidence)?; + if design.treat_ols_as_dsem { + return Err(AnalysisEngineError::InvalidEvidence); + } + if design.promote_causal { + claim_causal_effect(CausalHeuristic::TemporalPrecedence)?; + } + let _ = claim_causal_effect(CausalHeuristic::TemporalPrecedence); + Ok(()) +} + +/// Execute cutoff-safe longitudinal ESEM/DSEM engine composition. +/// +/// The caller supplies already-mapped posterior draws and an explicit +/// measurement design. This executor does not invent an ESEM/DSEM sampler, +/// persist rows, treat OLS as DSEM, collapse non-nested membership, or promote +/// temporal precedence to a causal estimand. +/// +/// # Errors +/// +/// Returns a request/receipt/snapshot/cutoff/profile error, a psychometric, +/// membership, or longitudinal gate failure, or an invalid artifact error. +pub fn execute_longitudinal_esem_dsem_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + design: &LongitudinalEsemDsemDesign, + observations: &[LongitudinalEsemDsemObservation], + completed_at: impl Into, +) -> Result { + request.to_json()?; + accepted.to_json()?; + require_receipt_identity(request, accepted)?; + if request.snapshot_id != snapshot_id { + return Err(AnalysisEngineError::SnapshotMismatch); + } + if request.knowledge_cutoff != knowledge_cutoff.to_rfc3339() + || request.model_contract_version != LONGITUDINAL_ESEM_DSEM_MODEL_CONTRACT_VERSION + || request.output_profile != LONGITUDINAL_ESEM_DSEM_OUTPUT_PROFILE + { + return Err(AnalysisEngineError::InvalidEvidence); + } + + apply_composition_gates(design)?; + let eligible = admit_draws_at_cutoff(observations, knowledge_cutoff)?; + let posterior_draw_mean = posterior_draw_point_estimate_mean(&eligible.draws)?; + let draw_count = + u64::try_from(eligible.draws.len()).map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + let artifact = LongitudinalEsemDsemArtifact { + schema_version: LONGITUDINAL_ESEM_DSEM_ARTIFACT_SCHEMA_VERSION.into(), + run_id: accepted.run_id.clone(), + snapshot_id: snapshot_id.to_owned(), + knowledge_cutoff: knowledge_cutoff.to_rfc3339(), + observation_count: eligible.observation_count, + draw_count, + excluded_after_cutoff_count: eligible.excluded_after_cutoff_count, + posterior_draw_mean, + construct_class: design.construct_class.as_str().into(), + membership_design: membership_design_wire_name(design.membership_design)?.into(), + component_level: design.component_level.wire_name().into(), + lag_clock: design.lag_clock.as_str().into(), + invariance_status: design.invariance_status.as_str().into(), + inference_status: LONGITUDINAL_ESEM_DSEM_INFERENCE_STATUS.into(), + }; + let digest = artifact.sha256()?; + let summary = AnalysisResultSummary::new( + "longitudinal_esem_dsem_composition", + eligible.observation_count, + 1, + LONGITUDINAL_ESEM_DSEM_INFERENCE_STATUS, + )?; + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + format!("longitudinal_esem_dsem_artifact_{}", &digest[..16]), + digest, + LONGITUDINAL_ESEM_DSEM_ARTIFACT_SCHEMA_VERSION, + completed_at, + summary, + )?; + Ok(LongitudinalEsemDsemExecution { + artifact, + terminal_result, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + LONGITUDINAL_ESEM_DSEM_ARTIFACT_BYTE_LIMIT, LONGITUDINAL_ESEM_DSEM_ARTIFACT_SCHEMA_VERSION, + LONGITUDINAL_ESEM_DSEM_INFERENCE_STATUS, LongitudinalEsemDsemArtifact, + }; + use crate::AnalysisEngineError; + + fn artifact() -> LongitudinalEsemDsemArtifact { + LongitudinalEsemDsemArtifact { + schema_version: LONGITUDINAL_ESEM_DSEM_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + observation_count: 2, + draw_count: 4, + excluded_after_cutoff_count: 0, + posterior_draw_mean: 0.5, + construct_class: "reflective".into(), + membership_design: "nested".into(), + component_level: "within".into(), + lag_clock: "event_time".into(), + invariance_status: "strong".into(), + inference_status: LONGITUDINAL_ESEM_DSEM_INFERENCE_STATUS.into(), + } + } + + fn assert_invalid(artifact: &LongitudinalEsemDsemArtifact) { + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidLongitudinalEsemDsemArtifact) + ); + } + + #[test] + fn artifact_round_trip_and_size_bounds_fail_closed() { + let artifact = artifact(); + let payload = artifact.to_json().expect("json"); + assert_eq!( + LongitudinalEsemDsemArtifact::from_json(&payload), + Ok(artifact.clone()) + ); + assert_eq!(artifact.sha256().expect("digest").len(), 64); + assert_eq!( + LongitudinalEsemDsemArtifact::from_json("{}"), + Err(AnalysisEngineError::InvalidLongitudinalEsemDsemArtifact) + ); + assert_eq!( + LongitudinalEsemDsemArtifact::from_json( + &"x".repeat(LONGITUDINAL_ESEM_DSEM_ARTIFACT_BYTE_LIMIT + 1) + ), + Err(AnalysisEngineError::LimitExceeded) + ); + } + + #[test] + fn artifact_metadata_tampering_fails_closed() { + let artifact = artifact(); + let invalid_artifacts = [ + { + let mut value = artifact.clone(); + value.schema_version.clear(); + value + }, + { + let mut value = artifact.clone(); + value.run_id.clear(); + value + }, + { + let mut value = artifact.clone(); + value.snapshot_id.clear(); + value + }, + { + let mut value = artifact.clone(); + value.knowledge_cutoff = "invalid".into(); + value + }, + { + let mut value = artifact.clone(); + value.observation_count = 0; + value + }, + { + let mut value = artifact.clone(); + value.draw_count = 1; + value + }, + { + let mut value = artifact.clone(); + value.posterior_draw_mean = f64::NAN; + value + }, + { + let mut value = artifact.clone(); + value.construct_class = "formative".into(); + value + }, + { + let mut value = artifact.clone(); + value.membership_design = "collapsed".into(); + value + }, + { + let mut value = artifact.clone(); + value.component_level = "between".into(); + value + }, + { + let mut value = artifact.clone(); + value.lag_clock = "system_time".into(); + value + }, + { + let mut value = artifact.clone(); + value.invariance_status = "metric".into(); + value + }, + { + let mut value = artifact.clone(); + value.inference_status.clear(); + value + }, + ]; + for invalid in invalid_artifacts { + assert_invalid(&invalid); + } + } +} diff --git a/crates/analysis_engine/tests/longitudinal_esem_dsem_execution_contract.rs b/crates/analysis_engine/tests/longitudinal_esem_dsem_execution_contract.rs new file mode 100644 index 000000000..33a4fcfe7 --- /dev/null +++ b/crates/analysis_engine/tests/longitudinal_esem_dsem_execution_contract.rs @@ -0,0 +1,539 @@ +//! End-to-end contract for cutoff-safe longitudinal ESEM/DSEM composition. + +use analysis_engine::{ + AnalysisEngineError, LONGITUDINAL_ESEM_DSEM_ARTIFACT_SCHEMA_VERSION, + LONGITUDINAL_ESEM_DSEM_MODEL_CONTRACT_VERSION, LONGITUDINAL_ESEM_DSEM_OUTPUT_PROFILE, + LongitudinalEsemDsemDesign, LongitudinalEsemDsemObservation, MAX_EVIDENCE_UNITS, + execute_longitudinal_esem_dsem_run, +}; +use longitudinal_core::{ComponentLevel, LongitudinalError}; +use membership_core::{MembershipDesign, MembershipError}; +use psychometric_core::{ConstructClass, LagClock, MeanInvarianceStatus, PsychometricError}; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; + +fn available(stamp: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(stamp).expect("available") +} + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn observation(stamp: &str, draws: Vec) -> LongitudinalEsemDsemObservation { + LongitudinalEsemDsemObservation::new(available(stamp), draws).expect("observation") +} + +fn eligible_observations() -> Vec { + vec![ + observation("2026-07-01T00:00:00Z", vec![0.0, 1.0]), + observation("2026-07-15T00:00:00Z", vec![0.0, 1.0]), + ] +} + +#[allow( + clippy::too_many_arguments, + clippy::fn_params_excessive_bools, + reason = "test helper mirrors the audited design constructor" +)] +fn design( + construct_class: ConstructClass, + membership_design: MembershipDesign, + collapse_hierarchy: bool, + component_level: ComponentLevel, + lag_clock: LagClock, + invariance_status: MeanInvarianceStatus, + treat_ols_as_dsem: bool, + promote_causal: bool, +) -> LongitudinalEsemDsemDesign { + LongitudinalEsemDsemDesign::new( + construct_class, + membership_design, + collapse_hierarchy, + component_level, + lag_clock, + invariance_status, + "group-contrast", + "composition-v1", + treat_ols_as_dsem, + promote_causal, + ) + .expect("design") +} + +fn valid_design() -> LongitudinalEsemDsemDesign { + design( + ConstructClass::Reflective, + MembershipDesign::Nested, + false, + ComponentLevel::Within, + LagClock::EventTime, + MeanInvarianceStatus::Strong, + false, + false, + ) +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "longitudinal-esem-dsem-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-longitudinal-esem-dsem".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: LONGITUDINAL_ESEM_DSEM_MODEL_CONTRACT_VERSION.into(), + output_profile: LONGITUDINAL_ESEM_DSEM_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new( + "run-longitudinal-esem-dsem", + "accepted", + &request.idempotency_key, + ) + .expect("accepted") +} + +#[allow( + clippy::too_many_arguments, + clippy::fn_params_excessive_bools, + reason = "test helper mirrors the audited design constructor" +)] +fn execute( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + design: &LongitudinalEsemDsemDesign, + observations: &[LongitudinalEsemDsemObservation], +) -> Result { + execute_longitudinal_esem_dsem_run( + request, + accepted, + snapshot_id, + knowledge_cutoff, + design, + observations, + "2026-08-02T00:00:00Z", + ) +} + +fn refuse( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + observations: &[LongitudinalEsemDsemObservation], + design: &LongitudinalEsemDsemDesign, + expected: AnalysisEngineError, +) { + assert_eq!( + execute( + request, + accepted, + "snapshot-longitudinal-esem-dsem", + cutoff(), + design, + observations, + ), + Err(expected) + ); +} + +#[test] +fn composed_engine_emits_digest_bound_posterior_mean_not_an_estimator() { + let request = request(); + let accepted = accepted(&request); + let design = valid_design(); + let observations = eligible_observations(); + let execution = execute( + &request, + &accepted, + "snapshot-longitudinal-esem-dsem", + cutoff(), + &design, + &observations, + ) + .expect("execution"); + + assert_eq!( + execution.artifact.schema_version, + LONGITUDINAL_ESEM_DSEM_ARTIFACT_SCHEMA_VERSION + ); + assert_eq!(execution.artifact.observation_count, 2); + assert_eq!(execution.artifact.draw_count, 4); + assert_eq!(execution.artifact.excluded_after_cutoff_count, 0); + assert!((execution.artifact.posterior_draw_mean - 0.5).abs() < 1e-12); + assert_eq!(execution.artifact.construct_class, "reflective"); + assert_eq!(execution.artifact.membership_design, "nested"); + assert_eq!(execution.artifact.component_level, "within"); + assert_eq!(execution.artifact.lag_clock, "event_time"); + assert_eq!(execution.artifact.invariance_status, "strong"); + assert_eq!( + execution.artifact.inference_status, + "composed_engine_not_estimator" + ); + assert_eq!( + execution.terminal_result.run_state, + AnalysisRunTerminalState::Succeeded + ); + assert_eq!( + execution.terminal_result.result_sha256.as_deref(), + Some(execution.artifact.sha256().expect("digest").as_str()) + ); + assert_eq!( + execution.terminal_result.result_schema_version.as_deref(), + Some(LONGITUDINAL_ESEM_DSEM_ARTIFACT_SCHEMA_VERSION) + ); + assert_eq!(design.construct_class(), ConstructClass::Reflective); + assert_eq!(design.membership_design(), MembershipDesign::Nested); + assert!(!design.collapse_hierarchy()); + assert_eq!(design.component_level(), ComponentLevel::Within); + assert_eq!(design.lag_clock(), LagClock::EventTime); + assert_eq!(design.invariance_status(), MeanInvarianceStatus::Strong); + assert_eq!(design.comparison_scope(), "group-contrast"); + assert_eq!(design.model_version(), "composition-v1"); + assert!(!design.treat_ols_as_dsem()); + assert!(!design.promote_causal()); + assert_eq!( + observations[0].available_time(), + available("2026-07-01T00:00:00Z") + ); + assert_eq!(observations[0].posterior_draws(), &[0.0, 1.0]); +} + +#[test] +fn execution_excludes_draws_unavailable_at_the_request_cutoff() { + let request = request(); + let accepted = accepted(&request); + let mut observations = eligible_observations(); + observations.push(observation("2026-08-15T00:00:00Z", vec![9.0, 9.0])); + let execution = execute( + &request, + &accepted, + "snapshot-longitudinal-esem-dsem", + cutoff(), + &valid_design(), + &observations, + ) + .expect("execution"); + assert_eq!(execution.artifact.observation_count, 2); + assert_eq!(execution.artifact.draw_count, 4); + assert_eq!(execution.artifact.excluded_after_cutoff_count, 1); + assert!((execution.artifact.posterior_draw_mean - 0.5).abs() < 1e-12); +} + +#[test] +fn cross_classified_membership_is_preserved_without_collapse() { + let request = request(); + let accepted = accepted(&request); + let design = design( + ConstructClass::Reflective, + MembershipDesign::CrossClassified, + false, + ComponentLevel::Within, + LagClock::EventTime, + MeanInvarianceStatus::Strict, + false, + false, + ); + let execution = execute( + &request, + &accepted, + "snapshot-longitudinal-esem-dsem", + cutoff(), + &design, + &eligible_observations(), + ) + .expect("execution"); + assert_eq!(execution.artifact.membership_design, "cross_classified"); + assert_eq!(execution.artifact.invariance_status, "strict"); +} + +#[test] +fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { + let request = request(); + let accepted = accepted(&request); + let observations = eligible_observations(); + let design = valid_design(); + assert_eq!( + execute( + &request, + &accepted, + "other-snapshot", + cutoff(), + &design, + &observations, + ), + Err(AnalysisEngineError::SnapshotMismatch) + ); + for invalid_request in [ + { + let mut value = request.clone(); + value.knowledge_cutoff = "2026-08-02T00:00:00Z".into(); + value + }, + { + let mut value = request.clone(); + value.model_contract_version = "other-model".into(); + value + }, + { + let mut value = request.clone(); + value.output_profile = "other-profile".into(); + value + }, + ] { + assert_eq!( + execute( + &invalid_request, + &accepted, + "snapshot-longitudinal-esem-dsem", + cutoff(), + &design, + &observations, + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + } +} + +#[test] +fn composition_gates_refuse_formative_network_unresolved_metric_and_between() { + let request = request(); + let accepted = accepted(&request); + let observations = eligible_observations(); + refuse( + &request, + &accepted, + &observations, + &design( + ConstructClass::Formative, + MembershipDesign::Nested, + false, + ComponentLevel::Within, + LagClock::EventTime, + MeanInvarianceStatus::Strong, + false, + false, + ), + AnalysisEngineError::Psychometric(PsychometricError::FormativeReinterpretationForbidden), + ); + refuse( + &request, + &accepted, + &observations, + &design( + ConstructClass::Network, + MembershipDesign::Nested, + false, + ComponentLevel::Within, + LagClock::EventTime, + MeanInvarianceStatus::Strong, + false, + false, + ), + AnalysisEngineError::Psychometric(PsychometricError::FormativeReinterpretationForbidden), + ); + refuse( + &request, + &accepted, + &observations, + &design( + ConstructClass::Unresolved, + MembershipDesign::Nested, + false, + ComponentLevel::Within, + LagClock::EventTime, + MeanInvarianceStatus::Strong, + false, + false, + ), + AnalysisEngineError::Psychometric(PsychometricError::UnresolvedConstruct), + ); + refuse( + &request, + &accepted, + &observations, + &design( + ConstructClass::Reflective, + MembershipDesign::Nested, + false, + ComponentLevel::Within, + LagClock::EventTime, + MeanInvarianceStatus::Metric, + false, + false, + ), + AnalysisEngineError::Psychometric(PsychometricError::StrongInvarianceRequired), + ); + refuse( + &request, + &accepted, + &observations, + &design( + ConstructClass::Reflective, + MembershipDesign::Nested, + false, + ComponentLevel::Between, + LagClock::EventTime, + MeanInvarianceStatus::Strong, + false, + false, + ), + AnalysisEngineError::Longitudinal(LongitudinalError::BetweenIsNotWithinChange), + ); +} + +#[test] +fn composition_gates_refuse_clock_collapse_ols_and_causal() { + let request = request(); + let accepted = accepted(&request); + let observations = eligible_observations(); + refuse( + &request, + &accepted, + &observations, + &design( + ConstructClass::Reflective, + MembershipDesign::Nested, + false, + ComponentLevel::Within, + LagClock::SystemTime, + MeanInvarianceStatus::Strong, + false, + false, + ), + AnalysisEngineError::Psychometric(PsychometricError::EventTimeRequired), + ); + refuse( + &request, + &accepted, + &observations, + &design( + ConstructClass::Reflective, + MembershipDesign::CrossClassified, + true, + ComponentLevel::Within, + LagClock::EventTime, + MeanInvarianceStatus::Strong, + false, + false, + ), + AnalysisEngineError::Membership(MembershipError::NestedIccInapplicable), + ); + refuse( + &request, + &accepted, + &observations, + &design( + ConstructClass::Reflective, + MembershipDesign::Nested, + false, + ComponentLevel::Within, + LagClock::EventTime, + MeanInvarianceStatus::Strong, + true, + false, + ), + AnalysisEngineError::InvalidEvidence, + ); + refuse( + &request, + &accepted, + &observations, + &design( + ConstructClass::Reflective, + MembershipDesign::Nested, + false, + ComponentLevel::Within, + LagClock::EventTime, + MeanInvarianceStatus::Strong, + false, + true, + ), + AnalysisEngineError::Psychometric(PsychometricError::CausalUnderidentified), + ); +} + +#[test] +fn execution_refuses_point_estimates_empty_cutoff_receipt_mismatch_and_limits() { + let request = request(); + let accepted = accepted(&request); + assert_eq!( + LongitudinalEsemDsemObservation::new(available("2026-07-01T00:00:00Z"), vec![0.5]), + Err(AnalysisEngineError::Psychometric( + PsychometricError::InsufficientDraws + )) + ); + assert_eq!( + LongitudinalEsemDsemObservation::new( + available("2026-07-01T00:00:00Z"), + vec![0.5, f64::NAN] + ), + Err(AnalysisEngineError::Psychometric( + PsychometricError::InvalidNumericInput + )) + ); + assert_eq!( + LongitudinalEsemDsemDesign::new( + ConstructClass::Reflective, + MembershipDesign::Nested, + false, + ComponentLevel::Within, + LagClock::EventTime, + MeanInvarianceStatus::Strong, + "", + "composition-v1", + false, + false, + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let mut early_request = request.clone(); + early_request.knowledge_cutoff = "2026-06-01T00:00:00Z".into(); + let too_early = KnowledgeCutoff::parse_rfc3339("2026-06-01T00:00:00Z").expect("cutoff"); + assert_eq!( + execute( + &early_request, + &accepted, + "snapshot-longitudinal-esem-dsem", + too_early, + &valid_design(), + &eligible_observations(), + ), + Err(AnalysisEngineError::Psychometric( + PsychometricError::InvalidNumericInput + )) + ); + + let wrong_receipt = + AnalysisRunAccepted::new("run-longitudinal-esem-dsem", "accepted", "other-key") + .expect("accepted"); + assert_eq!( + execute( + &request, + &wrong_receipt, + "snapshot-longitudinal-esem-dsem", + cutoff(), + &valid_design(), + &eligible_observations(), + ) + .expect_err("receipt"), + AnalysisEngineError::Api(tepp_api::ApiError::InvalidWirePayload) + ); + + let oversized = + vec![observation("2026-07-01T00:00:00Z", vec![0.0, 1.0]); MAX_EVIDENCE_UNITS + 1]; + assert_eq!( + execute( + &request, + &accepted, + "snapshot-longitudinal-esem-dsem", + cutoff(), + &valid_design(), + &oversized, + ), + Err(AnalysisEngineError::LimitExceeded) + ); +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..506351909 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -58,6 +58,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); HTTP service remaining accepted-target | partial | | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); LineageWeave loopback contracts and request-bound terminal result are composed on the active product branch; production TLS remaining | partial | | executable cutoff-safe analysis runs | ADR 0012/0022; temporal research; API terminal-result contract | `analysis_engine` availability cutoff, snapshot binding, multiple-membership aggregation, digest-bound readiness artifact, and `tepp.trsl_topic_lineage.v1` execution through `topic_measurement`; synthetic recovery plus tamper/non-convergence tests and exact coverage on the active product branch | active-PR | +| longitudinal ESEM/DSEM engine composition as an analysis-run profile | ADR 0005/0036; GAP-169 / #169 | `analysis_engine` `longitudinal_esem_dsem_composition_v1` cutoff-filters posterior draws, preserves membership design, refuses OLS-as-DSEM and causal-from-precedence, and emits digest-bound `tepp.longitudinal_esem_dsem_composition.v1` with `composed_engine_not_estimator`; not a sampler and not implemented-main | active-PR | | immutable split/run/reproducibility manifests | ADR 0013; ERD | `tepp_api` reproducibility manifest contract on protected main; `persistence_postgres` append-only SQL insert/lookup for `reproducibility_manifest`, `corpus_split_manifest`, `model_run`, and `model_artifact` (migration `0003`); full physical ERD constraints remaining | partial | | multilingual shared latent semantic space | PRD; ADR 0004; ADR 0020 | `semantic_core` span-grounded units (active-PR); concept dictionary and shared latent estimator remaining | active-PR | | TRSL-TM temporal/relational topic posterior and backend compatibility | ADR 0012; ADR 0004 | `topic_measurement` stable ALR/ILR coordinates and bounded CPU `f64` reference estimator on protected main; `model_selection` fitted candidate-`K` scoring on this PR; calibrated posterior promotion, method effects, persistence, and accelerated backends remaining | partial | diff --git a/docs/adr/0036-longitudinal-esem-dsem-composition.md b/docs/adr/0036-longitudinal-esem-dsem-composition.md new file mode 100644 index 000000000..6dd045231 --- /dev/null +++ b/docs/adr/0036-longitudinal-esem-dsem-composition.md @@ -0,0 +1,83 @@ +# ADR 0036 — Longitudinal ESEM/DSEM engine composition as an analysis-run output profile + +**Decision status:** Accepted +**Implementation maturity:** active-PR — composed on this branch; not implemented-main +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0005 (posterior-aware ESEM/DSEM interpretation) and ADR 0022 (cutoff-safe analysis-run execution). +**Figma File ID:** N/A — this increment changes a Rust service crate and has no user-interface surface. +**Storybook inventory:** N/A — no reusable web object or interaction changed. + +## Context + +Protected main already recovers construct-class gates, posterior-draw point +estimates, strong/strict-gated latent means, event-time lag clocks, nested ICC +refusal for non-nested membership, and within/between component identity. +Operators still cannot request that composition as a digest-bound analysis-run +output. Recovery primitives and Driver p.16 `std`-family restores are not the +ESEM/DSEM engine (GAP-169 / #169). + +## Decision + +Add the `longitudinal_esem_dsem_composition_v1` analysis-run output profile to +`analysis_engine`. The executor: + +- consumes already-mapped posterior draws plus an explicit measurement design + and `available_time`; +- excludes observations whose availability is later than the request + `knowledge_cutoff`; +- refuses point estimates (`InsufficientDraws` for fewer than two draws); +- invokes `interpret_as_reflective`, `compare_latent_means`, + `refuse_between_as_within_change`, nested-ICC membership refusal, and + event-time lag admission without inventing an ESEM/DSEM sampler; +- invokes `claim_causal_effect` so temporal precedence cannot promote the + composition to a causal estimand; +- refuses OLS recovery offered as DSEM; +- emits a canonical SHA-256-digested + `tepp.longitudinal_esem_dsem_composition.v1` artifact with observation/draw + counts, excluded-after-cutoff count, posterior-draw mean, construct class, + preserved membership design, within component, event-time clock, strong or + strict invariance, and inference status `composed_engine_not_estimator`; +- does not persist rows, collapse cross-classified or multiple-membership + designs, or claim implemented-main. + +Cross-classified membership without collapse is recorded as `cross_classified`. +This is engine composition, not an estimator, not CWC slopes, and not a +`std`-family restore. + +## Alternatives considered + +1. Restore another Driver p.16 standardised matrix — rejected because those + recoveries are already a live micro-PR family and do not bind composition to + an analysis run. +2. Put ESEM/DSEM composition into `tepp_api` — rejected because transport + contracts and scientific composition would become one service boundary. +3. Bind recovered psychometric, longitudinal, and membership gates to ADR 0022's + analysis-run profile — accepted. + +## Consequences + +Operators can request cutoff-safe longitudinal ESEM/DSEM engine composition as a +digest-bound terminal result. The artifact is not an ESEM fit, not a DSEM +sampler, not a causal effect, and not implemented-main until exact-head Checks +and two independent approvals land. + +## Verification + +```text +cargo fmt -p analysis_engine -- --check +cargo test -p analysis_engine +cargo clippy -p analysis_engine --all-targets -- -D warnings +``` + +Known-truth equal draws recover posterior-draw mean `0.5`. Cutoff exclusion, +snapshot/profile mismatch, empty eligibility, point estimates, formative or +network reinterpretation, metric-only means, between-as-within, non-event +clocks, collapsed non-nested membership, OLS-as-DSEM, and causal-from-precedence +fail closed. Cross-classified without collapse succeeds. + +## Rollback and supersession + +Rollback removes the `longitudinal_esem_dsem_composition_v1` profile. No +persisted schema migration is introduced. Supersede only with an ADR that keeps +engine composition distinct from a fitted ESEM/DSEM estimator and from causal +identification. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..88a1e5c58 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -28,6 +28,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0020](0020-span-grounded-semantic-units.md) | Span-grounded semantic units; language tags are not identity | Accepted | active-PR | First ADR 0004 production slice; concept alignment, invariance, and topic estimation are not claimed. | | [0021](0021-lineageweave-project-history-boundary.md) | LineageWeave project-history service boundary | Accepted | active-PR | Credential-free bounded project-history API preserves LineageWeave authorization ownership. | | [0022](0022-deterministic-analysis-run-execution.md) | Deterministic cutoff-safe analysis-run execution | Accepted | active-PR | Closes the first executable product path from accepted run to digest-bound terminal result without claiming estimator authority. | +| [0036](0036-longitudinal-esem-dsem-composition.md) | Longitudinal ESEM/DSEM engine composition as an analysis-run output profile | Accepted | active-PR | Binds recovered construct, membership, within/between, event-time, and strong/strict mean gates to `longitudinal_esem_dsem_composition_v1`; cutoff-filters posterior draws; refuses OLS-as-DSEM and causal promotion. | | [0024](0024-lineage-pair-criterion-and-project-journey-posterior.md) | Independent Event Lineage pair criterion and posterior Project Journey | Proposed | active-PR | Strict artifacts preserve criterion/event-time draws, branches, ties, and CPU/GPU receipts without claiming the scientific estimator is complete. | | [0025](0025-macos-native-rust-mlx-metal-boundary.md) | macOS-native Rust-owned MLX Metal execution | Accepted | accepted-target | Compose authenticates to a native host service; Linux never claims Metal, and actual backend/parity receipts fail closed. | | [0023](0023-lineage-criterion-anchor-contract.md) | TEPP-owned Event Lineage criterion anchor | Accepted | active-PR | PR #237 publishes the strict accepted/rejected artifact and identities; estimator execution remains fail-closed future work. | @@ -138,6 +139,7 @@ Use the narrowest owning ADR when decisions overlap: - **project-history wire-size symmetry:** ADR 0019. - **LineageWeave project-history service boundary:** ADR 0021. - **accepted-run execution and terminal artifact production:** ADR 0022. +- **longitudinal ESEM/DSEM engine composition analysis-run output profile:** ADR 0036. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. diff --git a/docs/doctoring/longitudinal-esem-dsem-composition.md b/docs/doctoring/longitudinal-esem-dsem-composition.md new file mode 100644 index 000000000..e1f19bc84 --- /dev/null +++ b/docs/doctoring/longitudinal-esem-dsem-composition.md @@ -0,0 +1,19 @@ +# Longitudinal ESEM/DSEM engine composition analysis-run bind + +**Review date:** 2026-08-31 +**Active slice:** GAP-169 / issue #169 remaining operator-visible composition + +Protected main already recovers construct-class, posterior-draw, invariance, +event-time, membership, and within/between gates in scientific crates. This +slice binds those gates to `analysis_engine` as a cutoff-safe analysis-run +output: eligibility against the request knowledge cutoff, digest-bound +`tepp.longitudinal_esem_dsem_composition.v1`, and an explicit refusal to treat +the composition as an estimator or a causal effect. + +This is not a new sampler, not a Driver p.16 `std` restore, not persistence, +and not implemented-main. + +## Evidence boundary + +Exact-head checks, independent review, and protected merge are required before +the profile can be promoted. From 02c22755f284662a8ecf0f4e1bf64f390d620f48 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 03:11:41 +0900 Subject: [PATCH 2/2] fix: close longitudinal composition gates --- .../src/longitudinal_esem_dsem_artifact.rs | 39 +++++++------- ...ngitudinal_esem_dsem_execution_contract.rs | 51 +++++++++++++++++++ crates/membership_core/src/icc.rs | 19 +++++++ 3 files changed, 87 insertions(+), 22 deletions(-) diff --git a/crates/analysis_engine/src/longitudinal_esem_dsem_artifact.rs b/crates/analysis_engine/src/longitudinal_esem_dsem_artifact.rs index eb82927c2..066db5665 100644 --- a/crates/analysis_engine/src/longitudinal_esem_dsem_artifact.rs +++ b/crates/analysis_engine/src/longitudinal_esem_dsem_artifact.rs @@ -31,12 +31,14 @@ pub const LONGITUDINAL_ESEM_DSEM_OUTPUT_PROFILE: &str = "longitudinal_esem_dsem_ pub const LONGITUDINAL_ESEM_DSEM_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; const LONGITUDINAL_ESEM_DSEM_INFERENCE_STATUS: &str = "composed_engine_not_estimator"; -/// Run-level scientific design offered to one composition execution. #[derive(Clone, Debug, Eq, PartialEq)] #[allow( clippy::struct_excessive_bools, reason = "independent fail-closed composition gates" )] +/// Run-level scientific design offered to one composition execution. +/// +/// The design records every fail-closed scientific gate before execution. pub struct LongitudinalEsemDsemDesign { construct_class: ConstructClass, membership_design: MembershipDesign, @@ -62,6 +64,9 @@ impl LongitudinalEsemDsemDesign { clippy::fn_params_excessive_bools, reason = "audited composition gate sequence" )] + /// + /// Arguments preserve construct, membership, temporal, invariance, and + /// inference choices without silently replacing them with defaults. pub fn new( construct_class: ConstructClass, membership_design: MembershipDesign, @@ -261,15 +266,10 @@ impl LongitudinalEsemDsemArtifact { /// /// # Errors /// - /// Returns a typed validation, serialization, or size failure. + /// Returns a typed validation or serialization failure. pub fn to_json(&self) -> Result { self.validate()?; - let payload = - serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure)?; - if payload.len() > LONGITUDINAL_ESEM_DSEM_ARTIFACT_BYTE_LIMIT { - return Err(AnalysisEngineError::LimitExceeded); - } - Ok(payload) + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure) } /// Return the lowercase SHA-256 digest of canonical artifact JSON. @@ -308,17 +308,6 @@ fn membership_design_wire_name_is_unknown(name: &str) -> bool { !matches!(name, "nested" | "cross_classified" | "multiple_membership") } -fn membership_design_wire_name( - design: MembershipDesign, -) -> Result<&'static str, AnalysisEngineError> { - match design { - MembershipDesign::Nested => Ok("nested"), - MembershipDesign::CrossClassified => Ok("cross_classified"), - MembershipDesign::MultipleMembership => Ok("multiple_membership"), - _ => Err(AnalysisEngineError::InvalidEvidence), - } -} - /// One completed composition artifact and its request-bound terminal result. #[derive(Clone, Debug, PartialEq)] pub struct LongitudinalEsemDsemExecution { @@ -393,7 +382,8 @@ fn apply_composition_gates(design: &LongitudinalEsemDsemDesign) -> Result<(), An }, design.comparison_scope(), design.model_version(), - )?; + ) + .expect("design constructor requires nonempty invariance labels"); compare_latent_means(&evidence)?; if design.treat_ols_as_dsem { return Err(AnalysisEngineError::InvalidEvidence); @@ -405,6 +395,10 @@ fn apply_composition_gates(design: &LongitudinalEsemDsemDesign) -> Result<(), An Ok(()) } +#[expect( + clippy::missing_panics_doc, + reason = "fixed summary fields and bounded count are valid" +)] /// Execute cutoff-safe longitudinal ESEM/DSEM engine composition. /// /// The caller supplies already-mapped posterior draws and an explicit @@ -453,7 +447,7 @@ pub fn execute_longitudinal_esem_dsem_run( excluded_after_cutoff_count: eligible.excluded_after_cutoff_count, posterior_draw_mean, construct_class: design.construct_class.as_str().into(), - membership_design: membership_design_wire_name(design.membership_design)?.into(), + membership_design: design.membership_design.wire_name().into(), component_level: design.component_level.wire_name().into(), lag_clock: design.lag_clock.as_str().into(), invariance_status: design.invariance_status.as_str().into(), @@ -465,7 +459,8 @@ pub fn execute_longitudinal_esem_dsem_run( eligible.observation_count, 1, LONGITUDINAL_ESEM_DSEM_INFERENCE_STATUS, - )?; + ) + .expect("fixed composition summary is valid"); let terminal_result = AnalysisRunTerminalResult::succeeded( request, accepted, diff --git a/crates/analysis_engine/tests/longitudinal_esem_dsem_execution_contract.rs b/crates/analysis_engine/tests/longitudinal_esem_dsem_execution_contract.rs index 33a4fcfe7..d8368b438 100644 --- a/crates/analysis_engine/tests/longitudinal_esem_dsem_execution_contract.rs +++ b/crates/analysis_engine/tests/longitudinal_esem_dsem_execution_contract.rs @@ -489,7 +489,43 @@ fn execution_refuses_point_estimates_empty_cutoff_receipt_mismatch_and_limits() ), Err(AnalysisEngineError::InvalidEvidence) ); + assert_eq!( + LongitudinalEsemDsemDesign::new( + ConstructClass::Reflective, + MembershipDesign::Nested, + false, + ComponentLevel::Within, + LagClock::EventTime, + MeanInvarianceStatus::Strong, + "group-contrast", + "", + false, + false, + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + let nested_collapse = design( + ConstructClass::Reflective, + MembershipDesign::Nested, + true, + ComponentLevel::Within, + LagClock::EventTime, + MeanInvarianceStatus::Strong, + false, + false, + ); + assert!( + execute( + &request, + &accepted, + "snapshot-longitudinal-esem-dsem", + cutoff(), + &nested_collapse, + &eligible_observations(), + ) + .is_ok() + ); let mut early_request = request.clone(); early_request.knowledge_cutoff = "2026-06-01T00:00:00Z".into(); let too_early = KnowledgeCutoff::parse_rfc3339("2026-06-01T00:00:00Z").expect("cutoff"); @@ -536,4 +572,19 @@ fn execution_refuses_point_estimates_empty_cutoff_receipt_mismatch_and_limits() ), Err(AnalysisEngineError::LimitExceeded) ); + + assert_eq!( + execute_longitudinal_esem_dsem_run( + &request, + &accepted, + "snapshot-longitudinal-esem-dsem", + cutoff(), + &valid_design(), + &eligible_observations(), + "invalid", + ), + Err(AnalysisEngineError::Api( + tepp_api::ApiError::InvalidWirePayload + )) + ); } diff --git a/crates/membership_core/src/icc.rs b/crates/membership_core/src/icc.rs index 013079568..1831d72a9 100644 --- a/crates/membership_core/src/icc.rs +++ b/crates/membership_core/src/icc.rs @@ -17,6 +17,16 @@ pub enum MembershipDesign { } impl MembershipDesign { + /// Return the stable wire name for this membership design. + #[must_use] + pub const fn wire_name(self) -> &'static str { + match self { + Self::Nested => "nested", + Self::CrossClassified => "cross_classified", + Self::MultipleMembership => "multiple_membership", + } + } + /// Return whether a one-way nested ICC is identified for this design. #[must_use] pub const fn allows_nested_icc(self) -> bool { @@ -228,6 +238,15 @@ mod tests { assert!(MembershipDesign::Nested.allows_nested_icc()); assert!(!MembershipDesign::CrossClassified.allows_nested_icc()); assert!(!MembershipDesign::MultipleMembership.allows_nested_icc()); + assert_eq!(MembershipDesign::Nested.wire_name(), "nested"); + assert_eq!( + MembershipDesign::CrossClassified.wire_name(), + "cross_classified" + ); + assert_eq!( + MembershipDesign::MultipleMembership.wire_name(), + "multiple_membership" + ); let member = MemberId::new(); let outcome = NestedOutcome::new(member, 1.5).expect("finite"); assert_eq!(outcome.member_id(), member);