diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ffe514db..c4e98ea4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -61,6 +61,7 @@ boundaries above remain the target modular MSA architecture. | `tepp_simulation` | known-truth temporal/event data generation | | `validation_core` | RMSE, bias, coverage, graph, and Monte Carlo metrics | | `tepp_api` | versioned DTO, schema, and export contracts | +| `psychometric_core` | posterior-aware structural input gates and CPU `f64` loading point-estimate recovery | No crate exposes placeholder production behavior in Task 1. This prevents an empty façade from becoming a de facto public API before its invariants and tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 36c2e8dd..8892ebf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added +- `psychometric_core` posterior-aware structural input gates: construct classification, refusal of raw-proportion Pearson/OLS, explicit ALR-versus-ILR geometry boundaries, CPU `f64` OLS recovery, posterior-draw loading point-estimate averaging without Rubin uncertainty claims, invariance-gated latent-mean comparison, and causal-heuristic refusal (ADR 0005 first production slice; no new migration). - `tepp_api` naruon live loopback HTTP/1.1 listener: `serve_one` installs a read/write deadline, requires a loopback `Host`, refuses `Transfer-Encoding` and NIM/proxy credential headers, parses `knowledge_cutoff` as RFC 3339 and refuses a future cutoff, keys analysis-run idempotency by tenant plus key, and proves both analysis-run and export POSTs over a real `TcpStream`. Not a production TLS/`$PORT` service (ADR 0011). - `tepp_api` adaptive orchestration router (ADR 0010): versioned `direct`/`verify`/`committee`/`conductor`/`abstain` selection from CPU `f64` risk, ambiguity, evidence, and token-budget inputs; recorded stages, recursion, decomposition, access lists, and role-specific reasoning effort; fail-closed document-controlled policy/access/credentials; LLM plans remain proposals under deterministic statistical authority; comparable-budget ablation requires a direct baseline; credential-free contextual-orchestrator binding. Live NIM HTTP remains accepted-target. - `tepp_api` purpose-bound provider-payload minimization: time-bounded `PurposeGrant` evaluation, fail-closed expired/not-yet-valid/inverted/cross-tenant/impossible-calendar denial, semantic UTC calendar validation, refusal to copy identity mappings into model-provider payloads or ordinary logs, preservation of opaque analytical identifiers and membership roles (no blanket PII mask), a separately authorized scientific re-identification path, and an internally bound FIPS 180-4 SHA-256 audit digest appended through `ReidentificationAuditSink` before disclosure. diff --git a/Cargo.lock b/Cargo.lock index fb502b9c..edde053e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -856,6 +856,10 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psychometric_core" +version = "0.1.0" + [[package]] name = "quote" version = "1.0.47" diff --git a/Cargo.toml b/Cargo.toml index 92565940..c556a747 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/psychometric_core", ] default-members = [ "crates/evidence_core", @@ -23,6 +24,7 @@ default-members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/psychometric_core", ] [workspace.package] diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 3f094947..3c92d7b1 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -33,6 +33,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Hourly NIM product-development operations | [`docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md`](docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md) | | Actions workflow fleet audit | [`docs/operations/ACTIONS_WORKFLOW_FLEET.md`](docs/operations/ACTIONS_WORKFLOW_FLEET.md) | | Actions fleet research doctoring | [`docs/research/actions-workflow-fleet.md`](docs/research/actions-workflow-fleet.md) | +| Posterior ESEM/DSEM input-gate doctoring | [`docs/research/posterior-esem-input-gates.md`](docs/research/posterior-esem-input-gates.md) | | Retention/deletion/legal-hold doctoring | [`docs/research/retention-deletion-legal-hold.md`](docs/research/retention-deletion-legal-hold.md) | | Provider-payload minimization doctoring | [`docs/research/provider-payload-minimization.md`](docs/research/provider-payload-minimization.md) | | Adaptive orchestration router doctoring | [`docs/research/adaptive-orchestration-router.md`](docs/research/adaptive-orchestration-router.md) | diff --git a/README.md b/README.md index ae74015d..bbf041ad 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,8 @@ implemented in Rust. ## Current implementation state This branch establishes the Task 1 Rust workspace and quality-gate foundation. -The ten bounded crates compile independently but intentionally expose no -placeholder production APIs. Domain behavior begins in Task 2 with immutable -evidence identifiers and source records. +The eleven bounded crates compile independently. Domain crates expose only +validated production APIs; placeholder surfaces are prohibited. ```text crates/evidence_core @@ -22,6 +21,7 @@ crates/corpus_split crates/tepp_simulation crates/validation_core crates/tepp_api +crates/psychometric_core # construct/input gates; not a full ESEM/DSEM estimator ``` ## Local verification diff --git a/crates/psychometric_core/Cargo.toml b/crates/psychometric_core/Cargo.toml new file mode 100644 index 00000000..1ed33dc6 --- /dev/null +++ b/crates/psychometric_core/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "psychometric_core" +description = "Posterior-aware ESEM/DSEM input gates and CPU f64 loading recovery." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true +publish = false + +[dependencies] + +[lints] +workspace = true diff --git a/crates/psychometric_core/src/causality.rs b/crates/psychometric_core/src/causality.rs new file mode 100644 index 00000000..50a1d8fe --- /dev/null +++ b/crates/psychometric_core/src/causality.rs @@ -0,0 +1,65 @@ +//! Refusal of causal language from non-identifying heuristics. + +use crate::error::PsychometricError; + +/// A heuristic that is not, by itself, causal identification. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum CausalHeuristic { + /// Event-time or document-time precedence. + TemporalPrecedence, + /// A citation, revision, or other document link. + DocumentLinkage, + /// TDT-style event tracking or coreference. + EventTracking, + /// A model prediction or schema completion. + ModelPrediction, +} + +impl CausalHeuristic { + /// Stable wire name for the heuristic. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::TemporalPrecedence => "temporal_precedence", + Self::DocumentLinkage => "document_linkage", + Self::EventTracking => "event_tracking", + Self::ModelPrediction => "model_prediction", + } + } +} + +/// Refuse a causal-effect claim that rests only on a non-identifying heuristic. +/// +/// ADR 0005: temporal precedence, document linkage, event tracking, or model +/// prediction alone do not justify causal language. +/// +/// # Errors +/// +/// Always returns [`PsychometricError::CausalUnderidentified`]. +pub fn claim_causal_effect(_heuristic: CausalHeuristic) -> Result<(), PsychometricError> { + Err(PsychometricError::CausalUnderidentified) +} + +#[cfg(test)] +mod tests { + use super::{CausalHeuristic, claim_causal_effect}; + use crate::error::PsychometricError; + + #[test] + fn every_heuristic_is_underidentified() { + assert_eq!( + claim_causal_effect(CausalHeuristic::DocumentLinkage), + Err(PsychometricError::CausalUnderidentified) + ); + assert_eq!( + CausalHeuristic::TemporalPrecedence.as_str(), + "temporal_precedence" + ); + assert_eq!(CausalHeuristic::EventTracking.as_str(), "event_tracking"); + assert_eq!( + CausalHeuristic::ModelPrediction.as_str(), + "model_prediction" + ); + } +} diff --git a/crates/psychometric_core/src/construct.rs b/crates/psychometric_core/src/construct.rs new file mode 100644 index 00000000..0db5601b --- /dev/null +++ b/crates/psychometric_core/src/construct.rs @@ -0,0 +1,95 @@ +//! Construct-class classification and interpretation gates. + +use crate::error::PsychometricError; + +/// Higher-order construct class before ESEM, composite, or network modeling. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ConstructClass { + /// Reflective indicators of a common latent factor. + Reflective, + /// Formative or composite indicators that define the construct. + Formative, + /// Interacting indicators that belong in a network model. + Network, + /// Insufficient evidence to classify the construct. + Unresolved, +} + +impl ConstructClass { + /// Stable wire name for the construct class. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Reflective => "reflective", + Self::Formative => "formative", + Self::Network => "network", + Self::Unresolved => "unresolved", + } + } + + /// Return whether reflective ESEM/set-ESEM is admissible. + #[must_use] + pub const fn admits_reflective_esem(self) -> bool { + matches!(self, Self::Reflective) + } +} + +/// Interpret a classified construct as reflective. +/// +/// A good global fit statistic is not authority to reinterpret a formative or +/// network structure as reflective (ADR 0005). +/// +/// # Errors +/// +/// Returns [`PsychometricError::FormativeReinterpretationForbidden`] for +/// formative or network classes and +/// [`PsychometricError::UnresolvedConstruct`] when the class is unresolved. +pub fn interpret_as_reflective( + classified: ConstructClass, + global_fit_acceptable: bool, +) -> Result { + match (classified, global_fit_acceptable) { + (ConstructClass::Reflective, true | false) => Ok(ConstructClass::Reflective), + (ConstructClass::Unresolved, true | false) => Err(PsychometricError::UnresolvedConstruct), + (ConstructClass::Formative | ConstructClass::Network, true | false) => { + Err(PsychometricError::FormativeReinterpretationForbidden) + } + } +} + +/// Permit a latent-mean or path comparison only when invariance evidence is +/// already established for the claimed comparison. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvarianceRequired`] when the required +/// invariance level has not been met. +pub fn compare_latent_means(invariance_level_met: bool) -> Result<(), PsychometricError> { + if invariance_level_met { + Ok(()) + } else { + Err(PsychometricError::InvarianceRequired) + } +} + +#[cfg(test)] +mod tests { + use super::{ConstructClass, compare_latent_means, interpret_as_reflective}; + use crate::error::PsychometricError; + + #[test] + fn reflective_only_admits_esem_and_invariance_is_required() { + assert!(ConstructClass::Reflective.admits_reflective_esem()); + assert!(!ConstructClass::Formative.admits_reflective_esem()); + compare_latent_means(true).expect("ok"); + assert_eq!( + interpret_as_reflective(ConstructClass::Reflective, true).expect("fit unused"), + ConstructClass::Reflective + ); + assert_eq!( + interpret_as_reflective(ConstructClass::Network, false), + Err(PsychometricError::FormativeReinterpretationForbidden) + ); + } +} diff --git a/crates/psychometric_core/src/error.rs b/crates/psychometric_core/src/error.rs new file mode 100644 index 00000000..337ac5fa --- /dev/null +++ b/crates/psychometric_core/src/error.rs @@ -0,0 +1,85 @@ +//! Fail-closed psychometric input and recovery errors. + +use std::fmt; + +/// A fail-closed psychometric-domain error. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum PsychometricError { + /// Raw simplex proportions were offered as Euclidean indicators. + RawProportionForbidden, + /// Empty, unequal-length, or non-finite numeric input. + InvalidNumericInput, + /// A predictor or indicator vector has zero variance. + SingularDesign, + /// A good global fit was used to reinterpret a formative or network + /// construct as reflective. + FormativeReinterpretationForbidden, + /// Temporal precedence, linkage, tracking, or prediction was treated as + /// causal identification. + CausalUnderidentified, + /// The construct class is unresolved and cannot support a reflective + /// interpretation. + UnresolvedConstruct, + /// Latent-mean or path comparison was requested without invariance + /// evidence. + InvarianceRequired, +} + +impl fmt::Display for PsychometricError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::RawProportionForbidden => { + "raw topic proportions are forbidden psychometric indicators" + } + Self::InvalidNumericInput => "invalid psychometric numeric input", + Self::SingularDesign => "singular psychometric design matrix", + Self::FormativeReinterpretationForbidden => { + "formative or network constructs cannot be reinterpreted as reflective" + } + Self::CausalUnderidentified => "temporal precedence is not causal identification", + Self::UnresolvedConstruct => "construct class is unresolved", + Self::InvarianceRequired => "latent-mean comparison requires invariance evidence", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for PsychometricError {} + +#[cfg(test)] +mod tests { + use super::PsychometricError; + + #[test] + fn messages_are_stable() { + assert_eq!( + PsychometricError::RawProportionForbidden.to_string(), + "raw topic proportions are forbidden psychometric indicators" + ); + assert_eq!( + PsychometricError::InvalidNumericInput.to_string(), + "invalid psychometric numeric input" + ); + assert_eq!( + PsychometricError::SingularDesign.to_string(), + "singular psychometric design matrix" + ); + assert_eq!( + PsychometricError::FormativeReinterpretationForbidden.to_string(), + "formative or network constructs cannot be reinterpreted as reflective" + ); + assert_eq!( + PsychometricError::CausalUnderidentified.to_string(), + "temporal precedence is not causal identification" + ); + assert_eq!( + PsychometricError::UnresolvedConstruct.to_string(), + "construct class is unresolved" + ); + assert_eq!( + PsychometricError::InvarianceRequired.to_string(), + "latent-mean comparison requires invariance evidence" + ); + } +} diff --git a/crates/psychometric_core/src/indicator.rs b/crates/psychometric_core/src/indicator.rs new file mode 100644 index 00000000..cd1a42c1 --- /dev/null +++ b/crates/psychometric_core/src/indicator.rs @@ -0,0 +1,173 @@ +//! Valid structural indicator coordinates and compositional-geometry claims. + +use crate::error::PsychometricError; + +/// Kind of indicator coordinates offered to a structural model. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum IndicatorKind { + /// Additive log-ratio (logistic-normal) coordinates. + AdditiveLogRatio, + /// Isometric log-ratio coordinates. + IsometricLogRatio, + /// Logistic-normal coordinates already mapped from the simplex. + LogisticNormal, + /// Raw topic proportions on the simplex. + RawProportion, +} + +impl IndicatorKind { + /// Stable wire name for the indicator kind. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::AdditiveLogRatio => "alr", + Self::IsometricLogRatio => "ilr", + Self::LogisticNormal => "logistic_normal", + Self::RawProportion => "raw_proportion", + } + } + + /// Return whether the kind is an admissible unconstrained structural input. + /// + /// This does not claim that the coordinates are orthonormal or preserve + /// Aitchison distance. ALR is reference-dependent; only ILR carries that + /// orthonormal compositional-geometry claim. + #[must_use] + pub const fn is_valid_structural_input(self) -> bool { + !matches!(self, Self::RawProportion) + } + + /// Return whether the coordinate kind is an orthonormal Aitchison isometry. + #[must_use] + pub const fn preserves_aitchison_distance(self) -> bool { + matches!(self, Self::IsometricLogRatio) + } +} + +/// Refuse raw topic proportions as psychometric indicators. +/// +/// # Errors +/// +/// Returns [`PsychometricError::RawProportionForbidden`] for +/// [`IndicatorKind::RawProportion`]. +pub fn require_valid_indicator(kind: IndicatorKind) -> Result<(), PsychometricError> { + if kind.is_valid_structural_input() { + Ok(()) + } else { + Err(PsychometricError::RawProportionForbidden) + } +} + +/// Pearson product-moment correlation on already-mapped coordinates. +/// +/// For ALR this is a reference-dependent coordinate correlation, not an +/// Aitchison-distance-preserving statistic. Use an ILR basis when orthonormal +/// compositional geometry is part of the estimand. +/// +/// # Errors +/// +/// Returns [`PsychometricError::RawProportionForbidden`] when `kind` is a raw +/// simplex, [`PsychometricError::InvalidNumericInput`] for empty, singleton, +/// unequal-length, or non-finite vectors, and +/// [`PsychometricError::SingularDesign`] when either vector has zero variance. +pub fn pearson_correlation( + left: &[f64], + right: &[f64], + kind: IndicatorKind, +) -> Result { + require_valid_indicator(kind)?; + let (left_dev, right_dev, _) = centered_pairs(left, right)?; + let mut cross = 0.0_f64; + let mut left_ss = 0.0_f64; + let mut right_ss = 0.0_f64; + for (left_value, right_value) in left_dev.iter().zip(&right_dev) { + cross += left_value * right_value; + left_ss += left_value * left_value; + right_ss += right_value * right_value; + } + if left_ss <= 0.0 || right_ss <= 0.0 { + return Err(PsychometricError::SingularDesign); + } + let denom = (left_ss * right_ss).sqrt(); + require_finite(cross / denom) +} + +pub(crate) fn centered_pairs( + left: &[f64], + right: &[f64], +) -> Result<(Vec, Vec, f64), PsychometricError> { + if left.len() < 2 || left.len() != right.len() { + return Err(PsychometricError::InvalidNumericInput); + } + let n = left.len() as f64; + let mut left_sum = 0.0_f64; + let mut right_sum = 0.0_f64; + for (left_value, right_value) in left.iter().zip(right) { + if !left_value.is_finite() || !right_value.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + left_sum += left_value; + right_sum += right_value; + } + let left_mean = left_sum / n; + let right_mean = right_sum / n; + let left_dev: Vec = left.iter().map(|value| value - left_mean).collect(); + let right_dev: Vec = right.iter().map(|value| value - right_mean).collect(); + Ok((left_dev, right_dev, n)) +} + +pub(crate) fn require_finite(value: f64) -> Result { + if value.is_finite() { + Ok(value) + } else { + Err(PsychometricError::InvalidNumericInput) + } +} + +#[cfg(test)] +mod tests { + use super::{IndicatorKind, pearson_correlation, require_valid_indicator}; + use crate::error::PsychometricError; + + #[test] + fn valid_kinds_pass_and_zero_right_variance_is_singular() { + require_valid_indicator(IndicatorKind::IsometricLogRatio).expect("ilr"); + assert_eq!( + require_valid_indicator(IndicatorKind::RawProportion), + Err(PsychometricError::RawProportionForbidden) + ); + assert_eq!( + pearson_correlation(&[1.0, 2.0], &[3.0, 3.0], IndicatorKind::LogisticNormal), + Err(PsychometricError::SingularDesign) + ); + assert_eq!( + pearson_correlation(&[2.0, 2.0], &[1.0, 2.0], IndicatorKind::AdditiveLogRatio), + Err(PsychometricError::SingularDesign) + ); + assert_eq!( + pearson_correlation(&[1.0, 1.0], &[2.0, 3.0], IndicatorKind::LogisticNormal), + Err(PsychometricError::SingularDesign) + ); + assert_eq!( + pearson_correlation(&[1.0, 2.0], &[1.0], IndicatorKind::LogisticNormal), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + pearson_correlation(&[f64::NAN, 2.0], &[1.0, 2.0], IndicatorKind::LogisticNormal), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + pearson_correlation(&[1.0, 2.0], &[1.0, f64::NAN], IndicatorKind::LogisticNormal), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + pearson_correlation( + &[0.0, f64::MAX], + &[0.0, f64::MAX], + IndicatorKind::AdditiveLogRatio + ), + Err(PsychometricError::InvalidNumericInput) + ); + } +} diff --git a/crates/psychometric_core/src/lib.rs b/crates/psychometric_core/src/lib.rs new file mode 100644 index 00000000..3476e0b9 --- /dev/null +++ b/crates/psychometric_core/src/lib.rs @@ -0,0 +1,44 @@ +#![forbid(unsafe_code)] +#![deny(missing_docs)] +#![allow(clippy::cast_precision_loss)] +//! Posterior-aware psychometric input gates for ESEM/DSEM. +//! +//! Raw topic proportions are not unconstrained structural indicators. This +//! crate classifies constructs, admits mapped log-ratio/logistic-normal inputs, +//! distinguishes ALR from orthonormal ILR geometry, averages loading point +//! estimates across posterior draws on a CPU `f64` path without claiming Rubin +//! uncertainty pooling, and refuses causal language from non-identifying cues. + +mod causality; +mod construct; +mod error; +mod indicator; +mod loading; +mod plausible; + +/// A heuristic that is not causal identification. +pub use causality::CausalHeuristic; +/// Refuse a causal-effect claim from a non-identifying heuristic. +pub use causality::claim_causal_effect; +/// Higher-order construct class. +pub use construct::ConstructClass; +/// Permit latent-mean comparison only with invariance evidence. +pub use construct::compare_latent_means; +/// Refuse fit-driven reinterpretation as reflective. +pub use construct::interpret_as_reflective; +/// Fail-closed psychometric errors. +pub use error::PsychometricError; +/// Indicator coordinate kind. +pub use indicator::IndicatorKind; +/// Pearson correlation on valid coordinates. +pub use indicator::pearson_correlation; +/// Refuse raw topic proportions as psychometric indicators. +pub use indicator::require_valid_indicator; +/// Ordinary least-squares slope. +pub use loading::ordinary_least_squares_slope; +/// Recover one reflective loading. +pub use loading::recover_reflective_loading; +/// Arithmetic mean of posterior-draw point estimates. +pub use plausible::posterior_draw_point_estimate_mean; +/// Average OLS loading point estimates across posterior indicator draws. +pub use plausible::recover_loading_point_estimate_mean; diff --git a/crates/psychometric_core/src/loading.rs b/crates/psychometric_core/src/loading.rs new file mode 100644 index 00000000..75c45f3f --- /dev/null +++ b/crates/psychometric_core/src/loading.rs @@ -0,0 +1,76 @@ +//! CPU `f64` ordinary-least-squares loading recovery. + +use crate::error::PsychometricError; +use crate::indicator::{IndicatorKind, centered_pairs, require_finite, require_valid_indicator}; + +/// Ordinary least-squares slope of `outcome` on `predictor`. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidNumericInput`] for empty, singleton, +/// unequal-length, or non-finite vectors and +/// [`PsychometricError::SingularDesign`] when the predictor has zero variance. +pub fn ordinary_least_squares_slope( + predictor: &[f64], + outcome: &[f64], +) -> Result { + let (pred_dev, out_dev, _) = centered_pairs(predictor, outcome)?; + let mut cross = 0.0_f64; + let mut pred_ss = 0.0_f64; + for (pred, out) in pred_dev.iter().zip(&out_dev) { + cross += pred * out; + pred_ss += pred * pred; + } + if pred_ss <= 0.0 { + return Err(PsychometricError::SingularDesign); + } + require_finite(cross / pred_ss) +} + +/// Recover a single reflective loading from factor scores and an indicator. +/// +/// # Errors +/// +/// Returns the indicator-kind or OLS errors from +/// [`require_valid_indicator`] and [`ordinary_least_squares_slope`]. +pub fn recover_reflective_loading( + factor_scores: &[f64], + indicators: &[f64], + kind: IndicatorKind, +) -> Result { + require_valid_indicator(kind)?; + ordinary_least_squares_slope(factor_scores, indicators) +} + +#[cfg(test)] +mod tests { + use super::{ordinary_least_squares_slope, recover_reflective_loading}; + use crate::error::PsychometricError; + use crate::indicator::IndicatorKind; + + #[test] + fn unit_slope_recovers_and_empty_or_overflow_input_fails() { + let slope = ordinary_least_squares_slope(&[0.0, 1.0], &[0.0, 1.0]).expect("unit"); + assert!((slope - 1.0).abs() < 1e-15); + assert_eq!( + recover_reflective_loading(&[], &[], IndicatorKind::AdditiveLogRatio), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + ordinary_least_squares_slope(&[0.0, f64::MAX], &[0.0, f64::MAX]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + ordinary_least_squares_slope(&[1.0, 2.0], &[1.0]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + ordinary_least_squares_slope(&[f64::NAN, 2.0], &[1.0, 2.0]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + ordinary_least_squares_slope(&[1.0, 2.0], &[1.0, f64::NAN]), + Err(PsychometricError::InvalidNumericInput) + ); + } +} diff --git a/crates/psychometric_core/src/plausible.rs b/crates/psychometric_core/src/plausible.rs new file mode 100644 index 00000000..d24fae41 --- /dev/null +++ b/crates/psychometric_core/src/plausible.rs @@ -0,0 +1,92 @@ +//! Point-estimate aggregation across posterior structural draws. + +use crate::error::PsychometricError; +use crate::indicator::{IndicatorKind, require_finite, require_valid_indicator}; +use crate::loading::recover_reflective_loading; + +/// Arithmetic mean of finite posterior-draw point estimates. +/// +/// This helper does not pool within-draw and between-draw uncertainty and must +/// not be described as Rubin multiple-imputation variance pooling. +/// +/// Scaling by the largest absolute draw prevents valid finite posterior values +/// from overflowing during aggregation. Compensated accumulation preserves +/// cancellation when draws span very different magnitudes. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidNumericInput`] when `draws` is empty or +/// contains a non-finite value. +pub fn posterior_draw_point_estimate_mean(draws: &[f64]) -> Result { + if draws.is_empty() { + return Err(PsychometricError::InvalidNumericInput); + } + let mut scale = 0.0_f64; + for &value in draws { + if !value.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + scale = scale.max(value.abs()); + } + if scale == 0.0 { + return Ok(0.0); + } + + let mut normalized_sum = 0.0_f64; + let mut compensation = 0.0_f64; + for &value in draws { + let adjusted = value / scale - compensation; + let next = normalized_sum + adjusted; + compensation = (next - normalized_sum) - adjusted; + normalized_sum = next; + } + require_finite((normalized_sum / draws.len() as f64) * scale) +} + +/// Recover a reflective loading point estimate by averaging OLS slopes across +/// posterior indicator draws. +/// +/// The result is a point-estimate summary only. It does not estimate within-draw +/// variance, between-draw variance, total variance, degrees of freedom, or a +/// confidence interval, and therefore is not Rubin-style uncertainty pooling. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidNumericInput`] when no draws are +/// supplied, and otherwise the first indicator-kind or OLS error from a draw. +pub fn recover_loading_point_estimate_mean( + factor_scores: &[f64], + indicator_draws: &[Vec], + kind: IndicatorKind, +) -> Result { + require_valid_indicator(kind)?; + if indicator_draws.is_empty() { + return Err(PsychometricError::InvalidNumericInput); + } + let mut recovered = Vec::with_capacity(indicator_draws.len()); + for draw in indicator_draws { + recovered.push(recover_reflective_loading(factor_scores, draw, kind)?); + } + posterior_draw_point_estimate_mean(&recovered) +} + +#[cfg(test)] +mod tests { + use super::{posterior_draw_point_estimate_mean, recover_loading_point_estimate_mean}; + use crate::error::PsychometricError; + use crate::indicator::IndicatorKind; + + #[test] + fn mean_of_two_point_estimates_and_nonfinite_mean_fail_closed() { + let mean = posterior_draw_point_estimate_mean(&[1.0, 3.0]).expect("mean"); + assert!((mean - 2.0).abs() < 1e-15); + assert_eq!( + recover_loading_point_estimate_mean( + &[0.0, 1.0], + &[vec![0.0, f64::NAN]], + IndicatorKind::AdditiveLogRatio + ), + Err(PsychometricError::InvalidNumericInput) + ); + } +} diff --git a/crates/psychometric_core/tests/crate_contract.rs b/crates/psychometric_core/tests/crate_contract.rs new file mode 100644 index 00000000..c2a53d58 --- /dev/null +++ b/crates/psychometric_core/tests/crate_contract.rs @@ -0,0 +1,7 @@ +//! Integration contract for the `psychometric_core` package identity. + +#[test] +fn package_identity_is_stable() { + let observed = std::hint::black_box(env!("CARGO_PKG_NAME")); + assert_eq!(observed, "psychometric_core"); +} diff --git a/crates/psychometric_core/tests/esem_input_recovery_contract.rs b/crates/psychometric_core/tests/esem_input_recovery_contract.rs new file mode 100644 index 00000000..3e19b547 --- /dev/null +++ b/crates/psychometric_core/tests/esem_input_recovery_contract.rs @@ -0,0 +1,283 @@ +//! True-parameter recovery and fail-closed ESEM/DSEM input gates. +#![allow(clippy::cast_precision_loss)] + +use psychometric_core::{ + CausalHeuristic, ConstructClass, IndicatorKind, PsychometricError, claim_causal_effect, + compare_latent_means, interpret_as_reflective, ordinary_least_squares_slope, + pearson_correlation, posterior_draw_point_estimate_mean, recover_loading_point_estimate_mean, + recover_reflective_loading, require_valid_indicator, +}; + +fn rmse(truth: &[f64], recovered: &[f64]) -> f64 { + let n = truth.len() as f64; + let sum_sq: f64 = truth + .iter() + .zip(recovered) + .map(|(left, right)| { + let residual = left - right; + residual * residual + }) + .sum(); + (sum_sq / n).sqrt() +} + +fn centered_scores(count: usize) -> Vec { + let mean = (count as f64 - 1.0) / 2.0; + (0..count).map(|index| index as f64 - mean).collect() +} + +#[test] +fn known_loading_recovers_through_ols_with_computed_rmse() { + let true_loading = 0.8_f64; + let factor_scores = centered_scores(16); + let indicators: Vec = factor_scores + .iter() + .map(|score| true_loading * score) + .collect(); + + let recovered = + recover_reflective_loading(&factor_scores, &indicators, IndicatorKind::AdditiveLogRatio) + .expect("noiseless reflective loading"); + let error = rmse(&[true_loading], &[recovered]); + assert!( + error < 1e-12, + "noiseless OLS RMSE {error} exceeded machine-scale bound" + ); +} + +#[test] +fn posterior_draw_point_estimate_mean_recovers_true_loading_under_symmetric_draw_noise() { + let true_loading = 0.8_f64; + let factor_scores = centered_scores(16); + let mut indicator_draws = Vec::with_capacity(5); + for draw in 0..5 { + let draw_loading = true_loading + 0.01 * (f64::from(draw) - 2.0); + indicator_draws.push( + factor_scores + .iter() + .map(|score| draw_loading * score) + .collect::>(), + ); + } + + let pooled = recover_loading_point_estimate_mean( + &factor_scores, + &indicator_draws, + IndicatorKind::LogisticNormal, + ) + .expect("posterior-draw point-estimate loading"); + let pooled_error = rmse(&[true_loading], &[pooled]); + assert!( + pooled_error < 1e-12, + "symmetric posterior-draw point-estimate RMSE {pooled_error} should cancel" + ); + + let single = recover_reflective_loading( + &factor_scores, + &indicator_draws[0], + IndicatorKind::IsometricLogRatio, + ) + .expect("single draw"); + let single_error = rmse(&[true_loading], &[single]); + assert!( + single_error > pooled_error, + "single-draw RMSE {single_error} should exceed pooled RMSE {pooled_error}" + ); +} + +#[test] +fn raw_proportions_and_invalid_numeric_inputs_fail_closed() { + assert_eq!( + require_valid_indicator(IndicatorKind::RawProportion), + Err(PsychometricError::RawProportionForbidden) + ); + assert_eq!( + pearson_correlation(&[0.2, 0.3], &[0.8, 0.7], IndicatorKind::RawProportion), + Err(PsychometricError::RawProportionForbidden) + ); + assert_eq!( + recover_reflective_loading(&[1.0, 2.0], &[0.5, 0.5], IndicatorKind::RawProportion), + Err(PsychometricError::RawProportionForbidden) + ); + assert_eq!( + recover_loading_point_estimate_mean( + &[1.0, 2.0], + &[vec![0.5, 0.5]], + IndicatorKind::RawProportion + ), + Err(PsychometricError::RawProportionForbidden) + ); + + assert_eq!( + pearson_correlation(&[1.0], &[1.0], IndicatorKind::AdditiveLogRatio), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + pearson_correlation(&[1.0, 2.0], &[1.0], IndicatorKind::AdditiveLogRatio), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + pearson_correlation( + &[1.0, f64::NAN], + &[1.0, 2.0], + IndicatorKind::AdditiveLogRatio + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + ordinary_least_squares_slope(&[1.0, 1.0], &[2.0, 3.0]), + Err(PsychometricError::SingularDesign) + ); + assert_eq!( + ordinary_least_squares_slope(&[0.0, f64::MAX], &[0.0, f64::MAX]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + pearson_correlation(&[1.0, 1.0], &[2.0, 3.0], IndicatorKind::AdditiveLogRatio), + Err(PsychometricError::SingularDesign) + ); + assert_eq!( + pearson_correlation(&[1.0, 2.0], &[3.0, 3.0], IndicatorKind::AdditiveLogRatio), + Err(PsychometricError::SingularDesign) + ); + assert_eq!( + pearson_correlation( + &[1.0, 2.0], + &[1.0, f64::NAN], + IndicatorKind::AdditiveLogRatio + ), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + posterior_draw_point_estimate_mean(&[]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + posterior_draw_point_estimate_mean(&[1.0, f64::INFINITY]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_loading_point_estimate_mean(&[1.0, 2.0], &[], IndicatorKind::AdditiveLogRatio), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_loading_point_estimate_mean( + &[1.0, 2.0], + &[vec![1.0]], + IndicatorKind::AdditiveLogRatio + ), + Err(PsychometricError::InvalidNumericInput) + ); +} + +#[test] +fn construct_class_and_causal_heuristics_refuse_overclaim() { + assert!(ConstructClass::Reflective.admits_reflective_esem()); + assert!(!ConstructClass::Formative.admits_reflective_esem()); + assert!(!ConstructClass::Network.admits_reflective_esem()); + assert!(!ConstructClass::Unresolved.admits_reflective_esem()); + assert_eq!(ConstructClass::Reflective.as_str(), "reflective"); + assert_eq!(ConstructClass::Formative.as_str(), "formative"); + assert_eq!(ConstructClass::Network.as_str(), "network"); + assert_eq!(ConstructClass::Unresolved.as_str(), "unresolved"); + + assert_eq!( + interpret_as_reflective(ConstructClass::Reflective, false).expect("reflective"), + ConstructClass::Reflective + ); + assert_eq!( + interpret_as_reflective(ConstructClass::Reflective, true).expect("fit unused"), + ConstructClass::Reflective + ); + assert_eq!( + interpret_as_reflective(ConstructClass::Formative, true), + Err(PsychometricError::FormativeReinterpretationForbidden) + ); + assert_eq!( + interpret_as_reflective(ConstructClass::Formative, false), + Err(PsychometricError::FormativeReinterpretationForbidden) + ); + assert_eq!( + interpret_as_reflective(ConstructClass::Network, true), + Err(PsychometricError::FormativeReinterpretationForbidden) + ); + assert_eq!( + interpret_as_reflective(ConstructClass::Unresolved, true), + Err(PsychometricError::UnresolvedConstruct) + ); + assert_eq!( + interpret_as_reflective(ConstructClass::Unresolved, false), + Err(PsychometricError::UnresolvedConstruct) + ); + + compare_latent_means(true).expect("invariance met"); + assert_eq!( + compare_latent_means(false), + Err(PsychometricError::InvarianceRequired) + ); + + for heuristic in [ + CausalHeuristic::TemporalPrecedence, + CausalHeuristic::DocumentLinkage, + CausalHeuristic::EventTracking, + CausalHeuristic::ModelPrediction, + ] { + assert_eq!( + claim_causal_effect(heuristic), + Err(PsychometricError::CausalUnderidentified) + ); + assert!(!heuristic.as_str().is_empty()); + } + + assert!(IndicatorKind::AdditiveLogRatio.is_valid_structural_input()); + assert!(IndicatorKind::IsometricLogRatio.is_valid_structural_input()); + assert!(IndicatorKind::LogisticNormal.is_valid_structural_input()); + assert!(!IndicatorKind::RawProportion.is_valid_structural_input()); + assert_eq!(IndicatorKind::AdditiveLogRatio.as_str(), "alr"); + assert_eq!(IndicatorKind::IsometricLogRatio.as_str(), "ilr"); + assert_eq!(IndicatorKind::LogisticNormal.as_str(), "logistic_normal"); + assert_eq!(IndicatorKind::RawProportion.as_str(), "raw_proportion"); +} + +#[test] +fn finite_alr_correlation_and_error_messages_are_stable() { + let left = [0.0_f64, 1.0, 2.0]; + let right = [0.0_f64, 2.0, 4.0]; + let correlation = pearson_correlation(&left, &right, IndicatorKind::AdditiveLogRatio) + .expect("perfect positive"); + assert!((correlation - 1.0).abs() < 1e-12); + + let slope = ordinary_least_squares_slope(&left, &right).expect("slope"); + assert!((slope - 2.0).abs() < 1e-12); + let mean = posterior_draw_point_estimate_mean(&[0.7, 0.8, 0.9]).expect("mean"); + assert!((mean - 0.8).abs() < 1e-15); + + assert_eq!( + PsychometricError::RawProportionForbidden.to_string(), + "raw topic proportions are forbidden psychometric indicators" + ); + assert_eq!( + PsychometricError::InvalidNumericInput.to_string(), + "invalid psychometric numeric input" + ); + assert_eq!( + PsychometricError::SingularDesign.to_string(), + "singular psychometric design matrix" + ); + assert_eq!( + PsychometricError::FormativeReinterpretationForbidden.to_string(), + "formative or network constructs cannot be reinterpreted as reflective" + ); + assert_eq!( + PsychometricError::CausalUnderidentified.to_string(), + "temporal precedence is not causal identification" + ); + assert_eq!( + PsychometricError::UnresolvedConstruct.to_string(), + "construct class is unresolved" + ); + assert_eq!( + PsychometricError::InvarianceRequired.to_string(), + "latent-mean comparison requires invariance evidence" + ); +} diff --git a/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs b/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs new file mode 100644 index 00000000..8d86a158 --- /dev/null +++ b/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs @@ -0,0 +1,31 @@ +//! Posterior-draw point-estimate aggregation must remain finite under valid extreme draws. + +use psychometric_core::{PsychometricError, posterior_draw_point_estimate_mean}; + +#[test] +fn scaled_mean_recovers_balanced_extreme_posterior_draws() { + let mean = posterior_draw_point_estimate_mean(&[f64::MAX, f64::MAX, -f64::MAX, -f64::MAX]) + .expect("balanced finite draws have a finite mean"); + assert!(mean.abs() < f64::EPSILON); +} + +#[test] +fn scaled_mean_preserves_an_extreme_constant_draw() { + let mean = posterior_draw_point_estimate_mean(&[f64::MAX, f64::MAX]) + .expect("constant finite extreme draws have a finite mean"); + assert_eq!(mean.to_bits(), f64::MAX.to_bits()); +} + +#[test] +fn all_zero_draws_have_an_exact_zero_mean() { + let mean = posterior_draw_point_estimate_mean(&[0.0, 0.0, 0.0]).expect("zero draws"); + assert!(mean.abs() < f64::EPSILON); +} + +#[test] +fn nonfinite_draws_remain_rejected() { + assert_eq!( + posterior_draw_point_estimate_mean(&[1.0, f64::INFINITY]), + Err(PsychometricError::InvalidNumericInput) + ); +} diff --git a/crates/psychometric_core/tests/scientific_claim_boundary_contract.rs b/crates/psychometric_core/tests/scientific_claim_boundary_contract.rs new file mode 100644 index 00000000..91240def --- /dev/null +++ b/crates/psychometric_core/tests/scientific_claim_boundary_contract.rs @@ -0,0 +1,34 @@ +//! Scientific claim boundaries for compositional coordinates and posterior draws. + +use psychometric_core::{ + IndicatorKind, posterior_draw_point_estimate_mean, recover_loading_point_estimate_mean, +}; + +#[test] +fn only_ilr_claims_orthonormal_aitchison_geometry() { + assert!(IndicatorKind::AdditiveLogRatio.is_valid_structural_input()); + assert!(!IndicatorKind::AdditiveLogRatio.preserves_aitchison_distance()); + assert!(IndicatorKind::IsometricLogRatio.is_valid_structural_input()); + assert!(IndicatorKind::IsometricLogRatio.preserves_aitchison_distance()); + assert!(IndicatorKind::LogisticNormal.is_valid_structural_input()); + assert!(!IndicatorKind::LogisticNormal.preserves_aitchison_distance()); + assert!(!IndicatorKind::RawProportion.is_valid_structural_input()); + assert!(!IndicatorKind::RawProportion.preserves_aitchison_distance()); +} + +#[test] +fn posterior_draw_helpers_report_point_estimates_without_rubin_variance_claims() { + let mean = posterior_draw_point_estimate_mean(&[0.7, 0.8, 0.9]) + .expect("finite posterior point estimates"); + assert!((mean - 0.8).abs() < 1e-15); + + let factor_scores = [-1.0_f64, 0.0, 1.0]; + let indicator_draws = vec![vec![-0.7, 0.0, 0.7], vec![-0.9, 0.0, 0.9]]; + let loading = recover_loading_point_estimate_mean( + &factor_scores, + &indicator_draws, + IndicatorKind::AdditiveLogRatio, + ) + .expect("posterior-draw point-estimate mean"); + assert!((loading - 0.8).abs() < 1e-15); +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index a3e674cf..67e06e36 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -28,7 +28,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | report template/section/copied/style/modality method effects | ADR 0004/0012; PRD/TRD | simulation truth factors implemented; estimator-side method model remains future | partial | | candidate K statistical/Pareto gates + blinded LLM review | ADR 0012; research | future `model_selection` | accepted-target | | compositional topic correlation / stable clustering | ADR 0005/0012; research | future `network_analysis` | accepted-target | -| posterior ESEM / longitudinal invariance / DSEM | ADR 0005 | future `psychometric_core` | accepted-target | +| posterior ESEM / longitudinal invariance / DSEM | ADR 0005 | `psychometric_core` construct/input gates, true-loading OLS recovery, and posterior-draw point-estimate averaging on the active PR; full ESEM/DSEM and Rubin/joint uncertainty propagation remaining | partial | | CPU bounded multithreading + GPU/VRAM streaming/parity | ADR 0001/0006 | future `compute_backend` | accepted-target | | TDT detection/tracking vs CHRONOS schema/prediction/temporal consistency | ADR 0016; PRD/research | future `event_intelligence` | accepted-target | | evidence-bounded LLM interpretation | ADR 0010/0012; PRD | `tepp_api` router plus future `interpretation_gateway` | partial | diff --git a/docs/adr/0005-posterior-esem-dsem.md b/docs/adr/0005-posterior-esem-dsem.md index 09e5b0ce..3eb214a0 100644 --- a/docs/adr/0005-posterior-esem-dsem.md +++ b/docs/adr/0005-posterior-esem-dsem.md @@ -1,8 +1,8 @@ # ADR 0005 — Posterior-aware ESEM/DSEM and structural interpretation -**Decision status:** Accepted -**Implementation maturity:** accepted-target -**Date:** 2026-08-05 +**Decision status:** Accepted +**Implementation maturity:** partial — construct classification, valid log-ratio/logistic-normal indicator gates, CPU `f64` OLS and posterior-draw loading point-estimate averaging (not Rubin variance pooling), invariance-gated mean comparison, and causal-heuristic refusal are implemented on the active PR and are not implemented-main until exact-head checks, review, and protected-main integration complete; full ESEM/set-ESEM, formative composites, DSEM, and continuous-time dynamics remain accepted-target +**Date:** 2026-08-05 **Supersedes:** None. ADR 0012 governs upstream topic measurement/network coordinates; this ADR governs higher-order psychometric structure and longitudinal interpretation. ## Context @@ -14,6 +14,7 @@ TEPP also needs to distinguish stable between-unit differences from within-unit ## Decision Topic proportions are not treated as error-free ordinary indicators. TEPP uses logistic-normal latent coordinates or valid orthonormal log-ratio coordinates and propagates topic posterior uncertainty through plausible values or a joint text-measurement/structural model. +The current executable slice only averages loading point estimates across posterior draws. It does not yet pool within-draw and between-draw uncertainty and therefore does not satisfy the full posterior-propagation decision by itself. Before ESEM/SEM interpretation, each higher-order construct is classified as reflective, formative/composite, network, or unresolved. Reflective indicators may use ESEM/set-ESEM; formative structures use composite/formative models; interacting structures use network models. A good global fit statistic is not authority to reinterpret a formative/network structure as reflective. diff --git a/docs/adr/0011-standalone-modular-msa-boundary.md b/docs/adr/0011-standalone-modular-msa-boundary.md index d545ee23..04181fb3 100644 --- a/docs/adr/0011-standalone-modular-msa-boundary.md +++ b/docs/adr/0011-standalone-modular-msa-boundary.md @@ -1,7 +1,7 @@ # ADR 0011 — Standalone operation and modular CWL MSA boundary **Decision status:** Accepted -**Implementation maturity:** partial — Rust crates are independently usable; naruon HTTP interchange and loopback live listener (`POST /v1/analysis-runs` and `/v1/exports`, fail-closed table-access, NIM/proxy headers, RFC 3339 cutoff, stream deadline) are on the active PR (not implemented-main); production TLS/`$PORT` and remaining persistence integrations remain accepted-target +**Implementation maturity:** partial — Rust crates are independently usable; naruon HTTP interchange and loopback live listener (`POST /v1/analysis-runs` and `/v1/exports`, fail-closed table-access, NIM/proxy headers, RFC 3339 cutoff, stream deadline) are on the active PR (not implemented-main); production TLS/`$PORT` and remaining persistence integrations remain accepted-target **Date:** 2026-08-10 **Supersedes:** The broad cross-service ownership wording in ADR 0001. ADR 0001 remains authoritative for Rust-first numerical architecture. diff --git a/docs/adr/README.md b/docs/adr/README.md index 258eb7f3..62067c64 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -10,7 +10,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0002](0002-six-clock-temporal-semantics.md) | Six-clock temporal semantics and fail-closed historical leakage prevention | Accepted | active-PR | Unmerged PR #8 is the canonical Task 3 replacement implementing typed clocks/intervals against the current protected-main lineage; conflicted PR #5 is superseded lineage. Later graph/split enforcement remains target work. | | [0003](0003-relational-event-multiple-membership.md) | Relational event ontology and time-varying cross-classified multiple membership | Accepted | partial | Weighted time-varying membership network/roles are active-PR (PR #12); full multilevel estimators, graph ontology, and persistence remain accepted-target. ADR 0016 owns event-intelligence tasks. | | [0004](0004-shared-multilingual-latent-space.md) | One shared multilingual latent space with explicit invariance status | Accepted | accepted-target | ADR 0012 owns the full topic-estimator/backend/global-topic contract. | -| [0005](0005-posterior-esem-dsem.md) | Posterior-aware ESEM/DSEM and valid compositional coordinates | Accepted | accepted-target | Downstream psychometric authority; upstream topic/network model is clarified by ADR 0012. | +| [0005](0005-posterior-esem-dsem.md) | Posterior-aware ESEM/DSEM and valid compositional coordinates | Accepted | partial | Input gates, posterior-draw loading point-estimate averaging, and causal-refusal are on the active PR; Rubin uncertainty pooling remains target work; full ESEM/DSEM estimator remains accepted-target. | | [0006](0006-vram-gpu-nvidia-orchestration.md) | VRAM-adaptive GPU compute and model-credential boundary | Accepted | accepted-target | LLM orchestration policy superseded by ADR 0010; autonomous development authority governed by ADR 0015. | | [0007](0007-rust-workspace-quality-gates.md) | Explicit Rust workspace, pinned toolchains, and exact quality gates | Accepted | implemented-main | ADR 0014 governs scientific/product claim promotion beyond repository-quality tooling. | | [0008](0008-immutable-evidence-identities-digests-and-spans.md) | Immutable evidence identities, `SHA-256` digests, exact spans, and strict wire reconstruction | Accepted | implemented-main | ADR 0013 governs future persistence/reproducibility/split authority. | diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 2e4f4d6c..5fe0424c 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -1,6 +1,6 @@ # naruon modular consumer contract for TEPP artifacts -**Status:** Partial — versioned DTO, HTTP interchange, and loopback live listener on the active PR; production TLS/`$PORT` remaining +**Status:** Partial — versioned DTO, HTTP interchange, and loopback live listener on the active PR; production TLS/`$PORT` remaining **Last reviewed:** 2026-08-16 ## Boundary diff --git a/docs/research/posterior-esem-input-gates.md b/docs/research/posterior-esem-input-gates.md new file mode 100644 index 00000000..b431aae1 --- /dev/null +++ b/docs/research/posterior-esem-input-gates.md @@ -0,0 +1,42 @@ +# Posterior-aware ESEM/DSEM input gates + +## Scope + +This slice delivers the first executable ADR 0005 contract in `psychometric_core`: + +1. classify each higher-order construct as reflective, formative, network, or unresolved before any ESEM/SEM interpretation; +2. refuse raw topic-proportion Pearson correlations and OLS loadings as psychometric inputs; +3. admit ALR, ILR, or logistic-normal coordinates as unconstrained structural inputs while reserving orthonormal Aitchison-distance claims for ILR; +4. recover a reflective loading point estimate by ordinary least squares on a CPU `f64` path; +5. average recovered loading point estimates across posterior indicator draws without claiming Rubin within/between uncertainty pooling; +6. refuse latent-mean comparison without invariance evidence; +7. refuse causal language that rests only on temporal precedence, document linkage, event tracking, or model prediction. + +Full ESEM/set-ESEM, formative composites, DSEM, and continuous-time dynamics remain accepted-target. + +## Authoritative sources + +Asparouhov, T., & Muthén, B. (2009). Exploratory structural equation modeling. *Structural Equation Modeling: A Multidisciplinary Journal, 16*(3), 397–438. https://doi.org/10.1080/10705510903008204 + +Asparouhov, T., Hamaker, E. L., & Muthén, B. (2018). Dynamic structural equation models. *Structural Equation Modeling: A Multidisciplinary Journal, 25*(3), 359–388. https://doi.org/10.1080/10705511.2017.1406803 + +Aitchison, J. (1982). The statistical analysis of compositional data. *Journal of the Royal Statistical Society: Series B (Methodological), 44*(2), 139–177. https://doi.org/10.1111/j.2517-6161.1982.tb01195.x + +Bollen, K., & Lennox, R. (1991). Conventional wisdom on measurement: A structural equation perspective. *Psychological Bulletin, 110*(2), 305–314. https://doi.org/10.1037/0033-2909.110.2.305 + +Mislevy, R. J. (1991). Randomization-based inference about latent variables from complex samples. *Psychometrika, 56*(2), 177–196. https://doi.org/10.1007/BF02294457 + +Holland, P. W. (1986). Statistics and causal inference. *Journal of the American Statistical Association, 81*(396), 945–960. https://doi.org/10.1080/01621459.1986.10478354 + +## Formula notes + +- **OLS loading** \(\hat\lambda = \sum_i (f_i-\bar f)(y_i-\bar y) / \sum_i (f_i-\bar f)^2\) on already-mapped coordinates. +- **Posterior-draw loading point estimate** is the arithmetic mean of \(\hat\lambda_d\) across draws. This narrow slice does not compute within-draw variance, between-draw variance, total variance, degrees of freedom, or Rubin-style pooled uncertainty; Mislevy (1991) motivates the future full posterior-propagation contract rather than validating this point-estimate shortcut. +- **RMSE** is computed from recovered versus known true loadings; tests do not hard-code expected recovery numbers. +- A good global fit statistic is not authority to reinterpret a formative or network construct as reflective (Bollen & Lennox, 1991; Asparouhov & Muthén, 2009). + +## Verification + +- noiseless OLS recovers a known loading with machine-scale computed RMSE; +- symmetric posterior-draw point-estimate noise cancels in the arithmetic mean and has smaller computed RMSE than a single draw; +- raw-proportion, empty, non-finite, singular, invariance-missing, formative-reinterpretation, and causal-heuristic paths fail closed. diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index 28e62d5c..a58fcb56 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -12,7 +12,13 @@ Asparouhov, T., & Muthén, B. (2009). Exploratory structural equation modeling. Marsh, H. W., Morin, A. J. S., Parker, P. D., & Kaur, G. (2014). Exploratory structural equation modeling: An integration of the best features of exploratory and confirmatory factor analysis. *Annual Review of Clinical Psychology, 10*, 85–110. https://doi.org/10.1146/annurev-clinpsy-032813-153700 -TEPP applies these sources to construct definition, score interpretation, reliability, validity evidence, uncertainty, consequences, longitudinal invariance, ESEM cross-loadings, and DSEM. Topic outputs are treated as fallible indicators or components only after their construct role is evaluated. +Bollen, K., & Lennox, R. (1991). Conventional wisdom on measurement: A structural equation perspective. *Psychological Bulletin, 110*(2), 305–314. https://doi.org/10.1037/0033-2909.110.2.305 + +Mislevy, R. J. (1991). Randomization-based inference about latent variables from complex samples. *Psychometrika, 56*(2), 177–196. https://doi.org/10.1007/BF02294457 + +Holland, P. W. (1986). Statistics and causal inference. *Journal of the American Statistical Association, 81*(396), 945–960. https://doi.org/10.1080/01621459.1986.10478354 + +TEPP applies these sources to construct definition, score interpretation, reliability, validity evidence, uncertainty, consequences, longitudinal invariance, ESEM cross-loadings, and DSEM. Topic outputs are treated as fallible indicators or components only after their construct role is evaluated. Reflective, formative, and network classes remain distinct (Bollen & Lennox, 1991). Posterior uncertainty is propagated by averaging structural estimates across plausible values (Mislevy, 1991). Temporal precedence is not causal identification (Holland, 1986). ## Structural, correlated, dynamic, relational, and multilingual topic models diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index aae1a06e..d2dd8b8e 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -1,7 +1,7 @@ # Temporal Event Foundation — validation and release-readiness report -**Status:** Living validation ledger for the Temporal/Event foundation program -**Last reviewed:** 2026-08-12 +**Status:** Living validation ledger for the Temporal/Event foundation program +**Last reviewed:** 2026-08-20 **Authority:** ADR 0014 (claim promotion), ADR 0007 (quality gates), AGENTS.md scientific acceptance ## Scope @@ -23,6 +23,7 @@ This report tracks exact-head scientific and engineering evidence required befor | Truth corpora / manifests | `tepp_simulation` | implemented-main | — | deterministic generator tests | Task 10 / PR #18 | | Recovery metrics | `validation_core` | implemented-main | — | RMSE/bias/coverage/MC gates | Task 11 / PR #19 | | Versioned API/export contracts | `tepp_api` | implemented-main | naruon HTTP interchange | unknown-field/version/limit + naruon HTTPS interchange tests | Task 12 / PR #21; live HTTP service remaining | +| Psychometric structural input gates | `psychometric_core` | accepted-target | active PR | construct-class refusal + ALR/ILR boundary + true-loading RMSE + posterior-draw point-estimate mean; full ESEM/DSEM/Rubin uncertainty remaining | ADR 0005; `docs/research/posterior-esem-input-gates.md` | | Purpose-bound provider payloads | `tepp_api` | implemented-main | provider-payload minimization | expired/not-yet-valid/inverted/cross-tenant/impossible-calendar grant, mapping refusal, audited elevated re-id replay | ADR 0009; `docs/research/provider-payload-minimization.md` | | Adaptive orchestration router | `tepp_api` | accepted-target | active PR | mode selection, document-control denial, ablation, credential-free bind | ADR 0010; `docs/research/adaptive-orchestration-router.md` | | CWL modular connectors | `docs/connectors/*` | implemented-main | — | contract docs + examples | PR #22; live HTTP ports remaining | diff --git a/scripts/check_workspace_contract.py b/scripts/check_workspace_contract.py index c7b1ecf5..68ea23bf 100644 --- a/scripts/check_workspace_contract.py +++ b/scripts/check_workspace_contract.py @@ -23,6 +23,7 @@ "tepp_simulation", "validation_core", "tepp_api", + "psychometric_core", ) REQUIRED_CI_SNIPPETS: tuple[str, ...] = ( diff --git a/tests/quality/test_check_docstrings.py b/tests/quality/test_check_docstrings.py index 2c11f7a5..56d553d2 100644 --- a/tests/quality/test_check_docstrings.py +++ b/tests/quality/test_check_docstrings.py @@ -11,6 +11,7 @@ from unittest import mock from scripts import check_docstrings as docstrings +from scripts import check_workspace_contract as contract REPOSITORY_ROOT = Path(__file__).resolve().parents[2] @@ -24,7 +25,7 @@ def test_live_repository_is_documented(self) -> None: sources = docstrings.rust_sources(REPOSITORY_ROOT) crate_roots = sorted(REPOSITORY_ROOT.glob("crates/*/src/lib.rs")) - self.assertEqual(len(crate_roots), 10) + self.assertEqual(len(crate_roots), len(contract.EXPECTED_CRATES)) self.assertTrue(set(crate_roots).issubset(sources)) self.assertGreaterEqual(len(sources), len(crate_roots)) self.assertEqual(docstrings.validate_repository(REPOSITORY_ROOT), [])