diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..c6811fc48 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` jointly binds two-group OLS invariance classification (`classify_two_group_ols_invariance`) and the strong/strict-gated latent-mean difference (`recover_strong_gated_latent_mean_difference`) to the `two_group_ols_invariance_v1` analysis-run output profile. Observations unavailable at the request cutoff are excluded; the digest-bound `tepp.two_group_ols_invariance.v1` artifact records local status, `#84` wire name (`scalar` or `null`), OLS intercepts/loadings/residuals, and the gated mean difference, and refuses metric-only means. This is not MGCFA, not CWC, not Rubin `T`, not a Driver p.16 `std` restore, and not persistence. + - `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..28a0f0cb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,6 +74,7 @@ dependencies = [ "corpus_split", "event_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..d1f55851c 100644 --- a/crates/analysis_engine/Cargo.toml +++ b/crates/analysis_engine/Cargo.toml @@ -15,6 +15,7 @@ publish = false [dependencies] event_core = { path = "../event_core", version = "0.2.0" } +psychometric_core = { path = "../psychometric_core", version = "0.2.0" } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } @@ -26,6 +27,7 @@ uuid.workspace = true [dev-dependencies] corpus_split = { path = "../corpus_split", version = "0.2.0" } membership_core = { path = "../membership_core", version = "0.2.0" } +psychometric_core = { path = "../psychometric_core", version = "0.2.0" } relation_graph = { path = "../relation_graph", version = "0.2.0" } [lints] diff --git a/crates/analysis_engine/src/invariance_artifact.rs b/crates/analysis_engine/src/invariance_artifact.rs new file mode 100644 index 000000000..b6144d2f6 --- /dev/null +++ b/crates/analysis_engine/src/invariance_artifact.rs @@ -0,0 +1,556 @@ +//! Digest-bound two-group OLS invariance as an analysis-run profile. + +use psychometric_core::{ + GroupIndicatorSeries, IndicatorKind, PsychometricError, classify_two_group_ols_invariance, + recover_strong_gated_latent_mean_difference, +}; +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 two-group OLS invariance artifact. +pub const TWO_GROUP_OLS_INVARIANCE_ARTIFACT_SCHEMA_VERSION: &str = + "tepp.two_group_ols_invariance.v1"; +/// Model contract required by the two-group OLS invariance execution path. +pub const TWO_GROUP_OLS_INVARIANCE_MODEL_CONTRACT_VERSION: &str = "two_group_ols_invariance_v1"; +/// Analysis-run output profile required for a two-group OLS invariance artifact. +pub const TWO_GROUP_OLS_INVARIANCE_OUTPUT_PROFILE: &str = "two_group_ols_invariance_v1"; +/// Maximum canonical artifact JSON size. +pub const TWO_GROUP_OLS_INVARIANCE_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; +const TWO_GROUP_OLS_INVARIANCE_INFERENCE_STATUS: &str = "two_group_ols_invariance_not_mgcfa"; +const TWO_GROUP_OLS_INVARIANCE_STATISTIC_COUNT: u64 = 7; +const TWO_GROUP_OLS_INVARIANCE_TOLERANCE: f64 = 1e-9; + +/// One already-mapped factor score and indicator bound to availability. +#[derive(Clone, Debug, PartialEq)] +pub struct InvarianceObservation { + factor_score: f64, + indicator: f64, + available_time: AvailableTime, +} + +impl InvarianceObservation { + /// Bind one factor score and indicator to an availability clock. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidEvidence`] when either coordinate + /// is non-finite. + pub fn new( + factor_score: f64, + indicator: f64, + available_time: AvailableTime, + ) -> Result { + if !factor_score.is_finite() || !indicator.is_finite() { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok(Self { + factor_score, + indicator, + available_time, + }) + } + + /// Return the already-mapped factor score. + #[must_use] + pub const fn factor_score(&self) -> f64 { + self.factor_score + } + + /// Return the already-mapped indicator coordinate. + #[must_use] + pub const fn indicator(&self) -> f64 { + self.indicator + } + + /// Return the availability clock used for cutoff eligibility. + #[must_use] + pub const fn available_time(&self) -> AvailableTime { + self.available_time + } +} + +/// Completed, bounded two-group OLS invariance result. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TwoGroupOlsInvarianceArtifact { + /// 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 classification. + pub knowledge_cutoff: String, + /// Eligible reference-group observations after cutoff. + pub reference_observation_count: u64, + /// Eligible comparison-group observations after cutoff. + pub comparison_observation_count: u64, + /// Reference observations excluded because availability was after the cutoff. + pub excluded_after_cutoff_reference_count: u64, + /// Comparison observations excluded because availability was after the cutoff. + pub excluded_after_cutoff_comparison_count: u64, + /// Admitted indicator-kind wire name. + pub indicator_kind: String, + /// Local Meredith-style status (`strong` / `strict`). + pub invariance_status: String, + /// `#84` wire name (`scalar`) or `null` when local strict has no `#84` name. + pub measurement_invariance_wire_name: Option, + /// Whether the classified status licenses latent-mean comparison. + pub licenses_latent_mean_comparison: bool, + /// Strong/strict-gated `(ȳ_c − ȳ_r) / λ`. + pub latent_mean_difference: f64, + /// Reference-group OLS intercept. + pub reference_intercept: f64, + /// Reference-group OLS loading. + pub reference_loading: f64, + /// Comparison-group OLS intercept. + pub comparison_intercept: f64, + /// Comparison-group OLS loading. + pub comparison_loading: f64, + /// Reference residual variance. + pub reference_residual_variance: f64, + /// Comparison residual variance. + pub comparison_residual_variance: f64, + /// Fixed claim boundary for consumer copy. + pub inference_status: String, +} + +impl TwoGroupOlsInvarianceArtifact { + /// Parse and fully validate a bounded artifact JSON payload. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidTwoGroupOlsInvarianceArtifact`] + /// when the schema, identifiers, counts, OLS values, or claim boundary fail. + pub fn from_json(payload: &str) -> Result { + if payload.len() > TWO_GROUP_OLS_INVARIANCE_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + let artifact: Self = serde_json::from_str(payload) + .map_err(|_| AnalysisEngineError::InvalidTwoGroupOlsInvarianceArtifact)?; + 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() > TWO_GROUP_OLS_INVARIANCE_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 != TWO_GROUP_OLS_INVARIANCE_ARTIFACT_SCHEMA_VERSION + || !valid_identifier(&self.run_id) + || !valid_identifier(&self.snapshot_id) + || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() + || self.reference_observation_count < 2 + || self.comparison_observation_count < 2 + || !admitted_indicator_kind(&self.indicator_kind) + || !valid_invariance_status_and_wire( + &self.invariance_status, + self.measurement_invariance_wire_name.as_deref(), + ) + || !self.licenses_latent_mean_comparison + || !self.latent_mean_difference.is_finite() + || !self.reference_intercept.is_finite() + || !self.reference_loading.is_finite() + || !self.comparison_intercept.is_finite() + || !self.comparison_loading.is_finite() + || !self.reference_residual_variance.is_finite() + || self.reference_residual_variance < 0.0 + || !self.comparison_residual_variance.is_finite() + || self.comparison_residual_variance < 0.0 + || self.inference_status != TWO_GROUP_OLS_INVARIANCE_INFERENCE_STATUS + { + return Err(AnalysisEngineError::InvalidTwoGroupOlsInvarianceArtifact); + } + Ok(()) + } +} + +/// One completed two-group OLS invariance artifact and terminal result. +#[derive(Clone, Debug, PartialEq)] +pub struct TwoGroupOlsInvarianceExecution { + /// Digest-bound completed invariance artifact. + pub artifact: TwoGroupOlsInvarianceArtifact, + /// Terminal result carrying the artifact identity, digest, and schema. + pub terminal_result: AnalysisRunTerminalResult, +} + +struct EligibleGroup { + series: GroupIndicatorSeries, + excluded_after_cutoff_count: u64, +} + +fn admitted_indicator_kind(label: &str) -> bool { + matches!(label, "alr" | "ilr" | "logistic_normal") +} + +fn valid_invariance_status_and_wire(status: &str, wire: Option<&str>) -> bool { + matches!( + (status, wire), + ("strong", Some("scalar")) | ("strict", None) + ) +} + +fn admit_group_at_cutoff( + observations: &[InvarianceObservation], + knowledge_cutoff: KnowledgeCutoff, +) -> Result { + let mut factor_scores = Vec::new(); + let mut indicators = Vec::new(); + let mut excluded_after_cutoff_count = 0_u64; + for observation in observations { + if observation.available_time.instant() <= knowledge_cutoff.instant() { + factor_scores.push(observation.factor_score); + indicators.push(observation.indicator); + } else { + excluded_after_cutoff_count += 1; + } + } + if factor_scores.is_empty() { + return Err(AnalysisEngineError::Psychometric( + PsychometricError::InvalidNumericInput, + )); + } + Ok(EligibleGroup { + series: GroupIndicatorSeries { + factor_scores, + indicators, + }, + excluded_after_cutoff_count, + }) +} + +/// Execute cutoff-safe two-group OLS invariance as one analysis-run profile. +/// +/// The caller supplies already-mapped factor scores and indicators for a +/// reference group and a comparison group. This executor jointly invokes +/// [`classify_two_group_ols_invariance`] and +/// [`recover_strong_gated_latent_mean_difference`]. Metric/configural status +/// fails closed: metric does not license latent-mean comparison. It does not +/// invent an MGCFA sampler, persist rows, or restore a Driver p.16 `std` map. +/// +/// # Errors +/// +/// Returns a request/receipt/snapshot/cutoff/profile error, psychometric +/// recovery failure, or invalid artifact error. +#[allow(clippy::too_many_arguments)] +pub fn execute_two_group_ols_invariance_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + kind: IndicatorKind, + reference: &[InvarianceObservation], + comparison: &[InvarianceObservation], + 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 != TWO_GROUP_OLS_INVARIANCE_MODEL_CONTRACT_VERSION + || request.output_profile != TWO_GROUP_OLS_INVARIANCE_OUTPUT_PROFILE + { + return Err(AnalysisEngineError::InvalidEvidence); + } + if reference.len().saturating_add(comparison.len()) > MAX_EVIDENCE_UNITS { + return Err(AnalysisEngineError::LimitExceeded); + } + + let eligible_reference = admit_group_at_cutoff(reference, knowledge_cutoff)?; + let eligible_comparison = admit_group_at_cutoff(comparison, knowledge_cutoff)?; + let measurement = classify_two_group_ols_invariance( + &eligible_reference.series, + &eligible_comparison.series, + kind, + TWO_GROUP_OLS_INVARIANCE_TOLERANCE, + TWO_GROUP_OLS_INVARIANCE_TOLERANCE, + TWO_GROUP_OLS_INVARIANCE_TOLERANCE, + )?; + let latent_mean_difference = recover_strong_gated_latent_mean_difference( + &eligible_reference.series, + &eligible_comparison.series, + kind, + TWO_GROUP_OLS_INVARIANCE_TOLERANCE, + TWO_GROUP_OLS_INVARIANCE_TOLERANCE, + TWO_GROUP_OLS_INVARIANCE_TOLERANCE, + )?; + let reference_observation_count = u64::try_from(eligible_reference.series.factor_scores.len()) + .map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + let comparison_observation_count = + u64::try_from(eligible_comparison.series.factor_scores.len()) + .map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + let evidence_count = reference_observation_count + .checked_add(comparison_observation_count) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + let artifact = TwoGroupOlsInvarianceArtifact { + schema_version: TWO_GROUP_OLS_INVARIANCE_ARTIFACT_SCHEMA_VERSION.into(), + run_id: accepted.run_id.clone(), + snapshot_id: snapshot_id.to_owned(), + knowledge_cutoff: knowledge_cutoff.to_rfc3339(), + reference_observation_count, + comparison_observation_count, + excluded_after_cutoff_reference_count: eligible_reference.excluded_after_cutoff_count, + excluded_after_cutoff_comparison_count: eligible_comparison.excluded_after_cutoff_count, + indicator_kind: kind.as_str().to_owned(), + invariance_status: measurement.status.as_str().to_owned(), + measurement_invariance_wire_name: measurement + .status + .as_measurement_invariance_wire_name() + .map(str::to_owned), + licenses_latent_mean_comparison: measurement.status.licenses_latent_mean_comparison(), + latent_mean_difference, + reference_intercept: measurement.reference_intercept, + reference_loading: measurement.reference_loading, + comparison_intercept: measurement.comparison_intercept, + comparison_loading: measurement.comparison_loading, + reference_residual_variance: measurement.reference_residual_variance, + comparison_residual_variance: measurement.comparison_residual_variance, + inference_status: TWO_GROUP_OLS_INVARIANCE_INFERENCE_STATUS.into(), + }; + let digest = artifact.sha256()?; + let summary = AnalysisResultSummary::new( + "two_group_ols_invariance", + evidence_count, + TWO_GROUP_OLS_INVARIANCE_STATISTIC_COUNT, + TWO_GROUP_OLS_INVARIANCE_INFERENCE_STATUS, + )?; + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + format!("two_group_ols_invariance_artifact_{}", &digest[..16]), + digest, + TWO_GROUP_OLS_INVARIANCE_ARTIFACT_SCHEMA_VERSION, + completed_at, + summary, + )?; + Ok(TwoGroupOlsInvarianceExecution { + artifact, + terminal_result, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + TWO_GROUP_OLS_INVARIANCE_ARTIFACT_BYTE_LIMIT, + TWO_GROUP_OLS_INVARIANCE_ARTIFACT_SCHEMA_VERSION, + TWO_GROUP_OLS_INVARIANCE_INFERENCE_STATUS, TwoGroupOlsInvarianceArtifact, + }; + use crate::AnalysisEngineError; + + fn artifact() -> TwoGroupOlsInvarianceArtifact { + TwoGroupOlsInvarianceArtifact { + schema_version: TWO_GROUP_OLS_INVARIANCE_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + reference_observation_count: 3, + comparison_observation_count: 3, + excluded_after_cutoff_reference_count: 0, + excluded_after_cutoff_comparison_count: 0, + indicator_kind: "alr".into(), + invariance_status: "strict".into(), + measurement_invariance_wire_name: None, + licenses_latent_mean_comparison: true, + latent_mean_difference: 2.0, + reference_intercept: 0.5, + reference_loading: 1.2, + comparison_intercept: 0.5, + comparison_loading: 1.2, + reference_residual_variance: 0.0, + comparison_residual_variance: 0.0, + inference_status: TWO_GROUP_OLS_INVARIANCE_INFERENCE_STATUS.into(), + } + } + + fn assert_invalid(artifact: &TwoGroupOlsInvarianceArtifact) { + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidTwoGroupOlsInvarianceArtifact) + ); + } + + #[test] + fn artifact_round_trip_and_size_bounds_fail_closed() { + let artifact = artifact(); + let payload = artifact.to_json().expect("json"); + assert_eq!( + TwoGroupOlsInvarianceArtifact::from_json(&payload), + Ok(artifact.clone()) + ); + assert_eq!(artifact.sha256().expect("digest").len(), 64); + assert_eq!( + TwoGroupOlsInvarianceArtifact::from_json("{}"), + Err(AnalysisEngineError::InvalidTwoGroupOlsInvarianceArtifact) + ); + assert_eq!( + TwoGroupOlsInvarianceArtifact::from_json( + &"x".repeat(TWO_GROUP_OLS_INVARIANCE_ARTIFACT_BYTE_LIMIT + 1) + ), + Err(AnalysisEngineError::LimitExceeded) + ); + } + + #[test] + fn artifact_identity_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.reference_observation_count = 1; + value + }, + { + let mut value = artifact.clone(); + value.comparison_observation_count = 1; + value + }, + { + let mut value = artifact.clone(); + value.indicator_kind = "raw_proportion".into(); + value + }, + { + let mut value = artifact.clone(); + value.invariance_status = "metric".into(); + value + }, + { + let mut value = artifact.clone(); + value.invariance_status = "strict".into(); + value.measurement_invariance_wire_name = Some("scalar".into()); + value + }, + { + let mut value = artifact.clone(); + value.invariance_status = "strong".into(); + value.measurement_invariance_wire_name = None; + value + }, + ]; + for invalid in invalid_artifacts { + assert_invalid(&invalid); + } + } + + #[test] + fn artifact_numeric_tampering_fails_closed() { + let artifact = artifact(); + let invalid_artifacts = [ + { + let mut value = artifact.clone(); + value.licenses_latent_mean_comparison = false; + value + }, + { + let mut value = artifact.clone(); + value.latent_mean_difference = f64::NAN; + value + }, + { + let mut value = artifact.clone(); + value.reference_intercept = f64::INFINITY; + value + }, + { + let mut value = artifact.clone(); + value.reference_loading = f64::NAN; + value + }, + { + let mut value = artifact.clone(); + value.comparison_intercept = f64::NEG_INFINITY; + value + }, + { + let mut value = artifact.clone(); + value.comparison_loading = f64::NAN; + value + }, + { + let mut value = artifact.clone(); + value.reference_residual_variance = -0.1; + value + }, + { + let mut value = artifact.clone(); + value.comparison_residual_variance = f64::NAN; + value + }, + { + let mut value = artifact.clone(); + value.inference_status.clear(); + value + }, + ]; + for invalid in invalid_artifacts { + assert_invalid(&invalid); + } + } + + #[test] + fn strong_scalar_wire_name_round_trips() { + let mut artifact = artifact(); + artifact.invariance_status = "strong".into(); + artifact.measurement_invariance_wire_name = Some("scalar".into()); + let payload = artifact.to_json().expect("json"); + assert_eq!( + TwoGroupOlsInvarianceArtifact::from_json(&payload), + Ok(artifact) + ); + } +} diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index 72bd5854c..d3d94f0c6 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -8,13 +8,16 @@ //! 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. Two-group OLS invariance +//! is invoked through [`psychometric_core`] and is not MGCFA. mod case_deletion_refit; +mod invariance_artifact; mod lineage_criterion; mod topic_context_posterior; mod topic_lineage_artifact; +use psychometric_core::PsychometricError; use serde::Serialize; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; @@ -41,6 +44,14 @@ pub use case_deletion_refit::ExhaustiveCaseDeletionError; pub use case_deletion_refit::ExhaustiveCaseDeletionFits; /// Fit the full corpus and every actual one-document deletion. pub use case_deletion_refit::fit_exhaustive_case_deletion; +/// Two-group OLS invariance artifact and execution contracts. +pub use invariance_artifact::{ + InvarianceObservation, TWO_GROUP_OLS_INVARIANCE_ARTIFACT_BYTE_LIMIT, + TWO_GROUP_OLS_INVARIANCE_ARTIFACT_SCHEMA_VERSION, + TWO_GROUP_OLS_INVARIANCE_MODEL_CONTRACT_VERSION, TWO_GROUP_OLS_INVARIANCE_OUTPUT_PROFILE, + TwoGroupOlsInvarianceArtifact, TwoGroupOlsInvarianceExecution, + execute_two_group_ols_invariance_run, +}; /// Rust-owned independent TDT link-criterion posterior fitting contracts. pub use lineage_criterion::{ LineageCriterionFit, LineageCriterionFitError, LineageCriterionObservation, @@ -248,6 +259,10 @@ 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 two-group OLS invariance artifact violated its bounded schema. + InvalidTwoGroupOlsInvarianceArtifact, } impl fmt::Display for AnalysisEngineError { @@ -262,6 +277,10 @@ 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::InvalidTwoGroupOlsInvarianceArtifact => { + "invalid two-group OLS invariance artifact" + } }; formatter.write_str(message) } @@ -281,6 +300,12 @@ impl From for AnalysisEngineError { } } +impl From for AnalysisEngineError { + fn from(error: PsychometricError) -> Self { + Self::Psychometric(error) + } +} + /// Execute the cutoff-safe temporal evidence readiness analysis. /// /// Evidence whose `available_time` is later than the request cutoff is excluded @@ -415,6 +440,7 @@ mod tests { AnalysisEngineError, AnalysisEvidenceUnit, MAX_ANALYSIS_IDENTIFIER_BYTES, MAX_EVIDENCE_UNITS, TopicMeasurementError, add_membership_count, execute_analysis_run, }; + use psychometric_core::PsychometricError; use temporal_core::{AvailableTime, EventTime}; use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState, ApiError}; @@ -681,6 +707,14 @@ mod tests { AnalysisEngineError::InvalidTopicLineageArtifact, "invalid topic lineage artifact", ), + ( + AnalysisEngineError::Psychometric(PsychometricError::StrongInvarianceRequired), + "latent-mean comparison requires strong or strict invariance; metric/weak is not enough", + ), + ( + AnalysisEngineError::InvalidTwoGroupOlsInvarianceArtifact, + "invalid two-group OLS invariance artifact", + ), ]; for (error, message) in messages { assert_eq!(error.to_string(), message); @@ -689,6 +723,11 @@ 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::StrongInvarianceRequired.into(); + assert_eq!( + from_psych.to_string(), + "latent-mean comparison requires strong or strict invariance; metric/weak is not enough" + ); assert_eq!( add_membership_count(u64::MAX, 1), Err(AnalysisEngineError::ArithmeticOverflow) diff --git a/crates/analysis_engine/tests/invariance_execution_contract.rs b/crates/analysis_engine/tests/invariance_execution_contract.rs new file mode 100644 index 000000000..1fe3d8168 --- /dev/null +++ b/crates/analysis_engine/tests/invariance_execution_contract.rs @@ -0,0 +1,398 @@ +//! End-to-end contract for cutoff-safe two-group OLS invariance. + +use analysis_engine::{ + AnalysisEngineError, InvarianceObservation, MAX_EVIDENCE_UNITS, + TWO_GROUP_OLS_INVARIANCE_ARTIFACT_SCHEMA_VERSION, + TWO_GROUP_OLS_INVARIANCE_MODEL_CONTRACT_VERSION, TWO_GROUP_OLS_INVARIANCE_OUTPUT_PROFILE, + execute_two_group_ols_invariance_run, +}; +use psychometric_core::{IndicatorKind, 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 series(factors: &[f64], intercept: f64, loading: f64) -> Vec { + factors + .iter() + .map(|score| { + InvarianceObservation::new( + *score, + intercept + loading * score, + available("2026-07-01T00:00:00Z"), + ) + .expect("row") + }) + .collect() +} + +fn strict_reference() -> Vec { + series(&[-1.0, 0.0, 1.0], 0.5, 1.2) +} + +fn strict_comparison() -> Vec { + series(&[1.0, 2.0, 3.0], 0.5, 1.2) +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "invariance-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-invariance".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: TWO_GROUP_OLS_INVARIANCE_MODEL_CONTRACT_VERSION.into(), + output_profile: TWO_GROUP_OLS_INVARIANCE_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new("run-invariance", "accepted", &request.idempotency_key) + .expect("accepted") +} + +fn execute( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + kind: IndicatorKind, + reference: &[InvarianceObservation], + comparison: &[InvarianceObservation], +) -> Result { + execute_two_group_ols_invariance_run( + request, + accepted, + snapshot_id, + knowledge_cutoff, + kind, + reference, + comparison, + "2026-08-02T00:00:00Z", + ) +} + +#[test] +fn noiseless_strict_series_emit_digest_bound_latent_mean_difference() { + let request = request(); + let accepted = accepted(&request); + let reference = strict_reference(); + let comparison = strict_comparison(); + let execution = execute( + &request, + &accepted, + "snapshot-invariance", + cutoff(), + IndicatorKind::AdditiveLogRatio, + &reference, + &comparison, + ) + .expect("execution"); + + assert_eq!( + execution.artifact.schema_version, + TWO_GROUP_OLS_INVARIANCE_ARTIFACT_SCHEMA_VERSION + ); + assert_eq!(execution.artifact.reference_observation_count, 3); + assert_eq!(execution.artifact.comparison_observation_count, 3); + assert_eq!(execution.artifact.excluded_after_cutoff_reference_count, 0); + assert_eq!(execution.artifact.excluded_after_cutoff_comparison_count, 0); + assert_eq!(execution.artifact.indicator_kind, "alr"); + assert_eq!(execution.artifact.invariance_status, "strict"); + assert_eq!(execution.artifact.measurement_invariance_wire_name, None); + assert!(execution.artifact.licenses_latent_mean_comparison); + assert!((execution.artifact.latent_mean_difference - 2.0).abs() < 1e-12); + assert!((execution.artifact.reference_intercept - 0.5).abs() < 1e-12); + assert!((execution.artifact.reference_loading - 1.2).abs() < 1e-12); + assert!((execution.artifact.comparison_intercept - 0.5).abs() < 1e-12); + assert!((execution.artifact.comparison_loading - 1.2).abs() < 1e-12); + assert!(execution.artifact.reference_residual_variance.abs() < 1e-12); + assert!(execution.artifact.comparison_residual_variance.abs() < 1e-12); + assert_eq!( + execution.artifact.inference_status, + "two_group_ols_invariance_not_mgcfa" + ); + 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(TWO_GROUP_OLS_INVARIANCE_ARTIFACT_SCHEMA_VERSION) + ); + assert!((reference[0].factor_score() + 1.0).abs() < f64::EPSILON); + assert!((reference[0].indicator() - (0.5 - 1.2)).abs() < 1e-12); + assert_eq!( + reference[0].available_time(), + available("2026-07-01T00:00:00Z") + ); +} + +#[test] +fn two_observation_series_cap_at_strong_and_recover_difference() { + let request = request(); + let accepted = accepted(&request); + let reference = series(&[-1.0, 1.0], 0.5, 1.2); + let comparison = series(&[0.0, 2.0], 0.5, 1.2); + let execution = execute( + &request, + &accepted, + "snapshot-invariance", + cutoff(), + IndicatorKind::AdditiveLogRatio, + &reference, + &comparison, + ) + .expect("execution"); + assert_eq!(execution.artifact.invariance_status, "strong"); + assert_eq!( + execution + .artifact + .measurement_invariance_wire_name + .as_deref(), + Some("scalar") + ); + assert!(execution.artifact.licenses_latent_mean_comparison); + assert!((execution.artifact.latent_mean_difference - 1.0).abs() < 1e-12); + assert_eq!( + execution.artifact.reference_residual_variance.to_bits(), + 0.0_f64.to_bits() + ); + assert_eq!( + execution.artifact.comparison_residual_variance.to_bits(), + 0.0_f64.to_bits() + ); +} + +#[test] +fn execution_excludes_rows_unavailable_at_the_request_cutoff() { + let request = request(); + let accepted = accepted(&request); + let reference = strict_reference(); + let mut comparison = strict_comparison(); + comparison.push( + InvarianceObservation::new(10.0, 100.0, available("2026-08-15T00:00:00Z")).expect("late"), + ); + let execution = execute( + &request, + &accepted, + "snapshot-invariance", + cutoff(), + IndicatorKind::AdditiveLogRatio, + &reference, + &comparison, + ) + .expect("execution"); + assert_eq!(execution.artifact.comparison_observation_count, 3); + assert_eq!(execution.artifact.excluded_after_cutoff_comparison_count, 1); + assert!((execution.artifact.latent_mean_difference - 2.0).abs() < 1e-12); + assert_eq!(execution.artifact.invariance_status, "strict"); +} + +#[test] +fn metric_only_and_configural_refuse_latent_means() { + let request = request(); + let accepted = accepted(&request); + let reference = strict_reference(); + let metric_only = series(&[1.0, 2.0, 3.0], 1.5, 1.2); + assert_eq!( + execute( + &request, + &accepted, + "snapshot-invariance", + cutoff(), + IndicatorKind::AdditiveLogRatio, + &reference, + &metric_only, + ), + Err(AnalysisEngineError::Psychometric( + PsychometricError::StrongInvarianceRequired + )) + ); + + let configural = series(&[1.0, 2.0, 3.0], 0.5, 0.4); + assert_eq!( + execute( + &request, + &accepted, + "snapshot-invariance", + cutoff(), + IndicatorKind::AdditiveLogRatio, + &reference, + &configural, + ), + Err(AnalysisEngineError::Psychometric( + PsychometricError::StrongInvarianceRequired + )) + ); +} + +#[test] +fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { + let request = request(); + let accepted = accepted(&request); + let reference = strict_reference(); + let comparison = strict_comparison(); + assert_eq!( + execute( + &request, + &accepted, + "other-snapshot", + cutoff(), + IndicatorKind::AdditiveLogRatio, + &reference, + &comparison, + ), + 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-invariance", + cutoff(), + IndicatorKind::AdditiveLogRatio, + &reference, + &comparison, + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + } +} + +#[test] +fn constructor_and_empty_cutoff_fail_closed() { + let request = request(); + let accepted = accepted(&request); + assert_eq!( + InvarianceObservation::new(f64::NAN, 1.0, available("2026-07-01T00:00:00Z")), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + InvarianceObservation::new(1.0, f64::INFINITY, available("2026-07-01T00:00:00Z")), + 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-invariance", + too_early, + IndicatorKind::AdditiveLogRatio, + &strict_reference(), + &strict_comparison(), + ), + Err(AnalysisEngineError::Psychometric( + PsychometricError::InvalidNumericInput + )) + ); +} + +#[test] +fn execution_refuses_raw_proportion_and_singular_loading() { + let request = request(); + let accepted = accepted(&request); + assert_eq!( + execute( + &request, + &accepted, + "snapshot-invariance", + cutoff(), + IndicatorKind::RawProportion, + &strict_reference(), + &strict_comparison(), + ), + Err(AnalysisEngineError::Psychometric( + PsychometricError::RawProportionForbidden + )) + ); + + let zero = series(&[-1.0, 0.0, 1.0], 2.0, 0.0); + let other = series(&[0.0, 1.0, 2.0], 2.0, 0.0); + assert_eq!( + execute( + &request, + &accepted, + "snapshot-invariance", + cutoff(), + IndicatorKind::IsometricLogRatio, + &zero, + &other, + ), + Err(AnalysisEngineError::Psychometric( + PsychometricError::SingularDesign + )) + ); +} + +#[test] +fn execution_refuses_receipt_mismatch_and_oversized_corpus() { + let request = request(); + let accepted = accepted(&request); + let wrong_receipt = + AnalysisRunAccepted::new("run-invariance", "accepted", "other-key").expect("accepted"); + assert_eq!( + execute( + &request, + &wrong_receipt, + "snapshot-invariance", + cutoff(), + IndicatorKind::AdditiveLogRatio, + &strict_reference(), + &strict_comparison(), + ) + .expect_err("receipt"), + AnalysisEngineError::Api(tepp_api::ApiError::InvalidWirePayload) + ); + + let oversized_reference = + vec![ + InvarianceObservation::new(1.0, 1.0, available("2026-07-01T00:00:00Z")).expect("row"); + MAX_EVIDENCE_UNITS + ]; + let oversized_comparison = + vec![InvarianceObservation::new(2.0, 2.0, available("2026-07-01T00:00:00Z")).expect("row")]; + assert_eq!( + execute( + &request, + &accepted, + "snapshot-invariance", + cutoff(), + IndicatorKind::AdditiveLogRatio, + &oversized_reference, + &oversized_comparison, + ), + Err(AnalysisEngineError::LimitExceeded) + ); +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..819c604fd 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 | +| cutoff-safe two-group OLS invariance | ADR 0005/0039; Putnick and Bornstein (2016) | `analysis_engine` `two_group_ols_invariance_v1` jointly binds `psychometric_core` two-group OLS classification and strong/strict-gated latent-mean difference to a digest-bound `tepp.two_group_ols_invariance.v1` artifact; metric does not license means; not MGCFA; 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/0039-two-group-ols-invariance-analysis-run.md b/docs/adr/0039-two-group-ols-invariance-analysis-run.md new file mode 100644 index 000000000..30b0b8748 --- /dev/null +++ b/docs/adr/0039-two-group-ols-invariance-analysis-run.md @@ -0,0 +1,96 @@ +# ADR 0039 — Two-group OLS invariance 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 (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 classifies two-group OLS invariance +(`configural` / `metric` / `strong` / `strict`) and recovers a +strong/strict-gated latent-mean difference `(ȳ_c − ȳ_r) / λ` inside +`psychometric_core`. Operators still cannot request that joint wiring as a +digest-bound analysis-run output. Recovery primitives alone are not the +ESEM/DSEM engine (GAP-006 / #169). A second Driver p.16 `std`-family restore, +another CWC bind, another Rubin/plausible-value bind, GAP-169 composition, or +another GAP-003A HTTP slice would not close this operator-visible gap. + +The library helpers are explicit: metric/weak invariance licenses shared +*metric* meaning and does not license latent-mean comparison. `#84` wire names +are `configural` / `metric` / `scalar`; local `strict` has no `#84` wire name. +This is two-group OLS, not MGCFA. + +## Decision + +Add the `two_group_ols_invariance_v1` analysis-run output profile to +`analysis_engine`. The executor: + +- consumes already-mapped factor scores, indicators, an admitted indicator + kind, and `available_time` for a reference group and a comparison group; +- excludes observations whose availability is later than the request + `knowledge_cutoff`; +- jointly invokes `classify_two_group_ols_invariance` and + `recover_strong_gated_latent_mean_difference` without reimplementing either + helper, using the library's hardcoded `1e-9` OLS tolerances; +- emits a canonical SHA-256-digested `tepp.two_group_ols_invariance.v1` + artifact with per-group observation counts, excluded-after-cutoff counts, + indicator kind, local status, `#84` wire name (`scalar` or `null`), + `licenses_latent_mean_comparison`, the gated latent-mean difference, OLS + intercepts/loadings/residuals, and inference status + `two_group_ols_invariance_not_mgcfa`; +- fails closed with `PsychometricError::StrongInvarianceRequired` when the + classified status is configural or metric; +- does not invent an MGCFA sampler, persist rows, treat metric as a mean + license, or claim implemented-main. + +This is two-group OLS invariance evidence, not MGCFA, not CWC, not Rubin `T`, +and not a random-effects sampler. + +## 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 invariance to + an analysis run. +2. Duplicate the GAP-006 CWC, Rubin, or GAP-169 composition analysis-run + binds — rejected because those estimands are already occupied by live PRs. +3. Emit metric-only classification as a succeeded terminal result — rejected + because metric does not license latent-mean comparison; the profile fails + closed instead of leaking a mean. +4. Bind the existing `psychometric_core` classify/recover helpers to ADR + 0022's analysis-run profile — accepted. + +## Consequences + +Operators can request cutoff-safe two-group OLS invariance and a +strong/strict-gated latent-mean difference as a digest-bound terminal result. +Metric-only series fail closed. The artifact is not MGCFA, not an ESEM fit, +and not implemented-main until exact-head Checks and two independent +approvals land. + +0026–0038 remain on other live PRs (GAP-003A HTTP stack, TDT/CHRONOS, CWC, +Rubin, GAP-169 composition). This decision uses 0039. + +## Verification + +```text +cargo fmt -p analysis_engine -- --check +cargo test -p analysis_engine +cargo clippy -p analysis_engine --all-targets -- -D warnings +``` + +Known-truth reference series `([-1, 0, 1], intercept 0.5, loading 1.2)` versus +comparison `([1, 2, 3], 0.5, 1.2)` recovers difference `2.0` and classifies +`strict`. Metric-only intercept `1.5` returns `StrongInvarianceRequired`. +Two-observation series cap at `strong` (`#84` `scalar`) and recover `1.0`. +Cutoff exclusion, snapshot/profile mismatch, empty eligibility, raw +proportions, and singular loadings fail closed. + +## Rollback and supersession + +Rollback removes the `two_group_ols_invariance_v1` profile. No persisted +schema migration is introduced. Supersede only with an ADR that keeps metric +from licensing latent-mean comparison and keeps two-group OLS distinct from +MGCFA. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..7d21a5b3e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -30,6 +30,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [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. | | [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. | +| [0039](0039-two-group-ols-invariance-analysis-run.md) | Two-group OLS invariance as an analysis-run output profile | Accepted | active-PR | Binds classify/recover helpers to `two_group_ols_invariance_v1`; metric does not license means; not MGCFA. 0026–0038 remain on other live PRs. | | [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. | | [0024](0024-independent-topic-importance-anchor.md) | Posterior topic-context producer contract | Accepted | contract-only active-PR | Strict DTO/schema only; the current estimator does not emit it. fast-mlsirm owns case-deletion influence. | | [0001](0001-rust-first-modular-msa.md) | Rust-first numerical core and CPU `f64` reference | Accepted | partial | ADR 0011 owns cross-service/MSA authority; 0001 retains numerical/backend authority. | @@ -140,6 +141,7 @@ Use the narrowest owning ADR when decisions overlap: - **accepted-run execution and terminal artifact production:** ADR 0022. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. +- **two-group OLS invariance analysis-run output profile:** ADR 0039. ## Change and supersession rule diff --git a/docs/doctoring/two-group-ols-invariance.md b/docs/doctoring/two-group-ols-invariance.md new file mode 100644 index 000000000..f638cbc1b --- /dev/null +++ b/docs/doctoring/two-group-ols-invariance.md @@ -0,0 +1,19 @@ +# Two-group OLS invariance analysis-run bind + +**Review date:** 2026-08-31 +**Active slice:** GAP-006 / issue #169 remaining operator-visible composition + +Protected main already classifies two-group OLS invariance and recovers a +strong/strict-gated latent-mean difference in `psychometric_core`. This slice +binds those helpers jointly to `analysis_engine` as a cutoff-safe analysis-run +output: eligibility against the request knowledge cutoff, digest-bound +`tepp.two_group_ols_invariance.v1`, and an explicit refusal to treat metric +invariance as a latent-mean license. + +This is not a new estimator, not MGCFA, not a Driver p.16 `std` restore, not +CWC, not Rubin `T`, not persistence, and not implemented-main. + +## Evidence boundary + +Exact-head checks, independent review, and protected merge are required before +the profile can be promoted.